mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 02:52:07 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e3f09f17 |
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/node:2": {
|
"ghcr.io/devcontainers/features/node:2": {
|
||||||
"version": "26.13.0",
|
"version": "24.22.0",
|
||||||
"pnpmVersion": "12.18.0"
|
"pnpmVersion": "12.14.0"
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers/features/rust:1": {
|
"ghcr.io/devcontainers/features/rust:1": {
|
||||||
"version": "1.99.1",
|
"version": "1.99.1",
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
# 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,8 +10,6 @@ body:
|
|||||||
value: |
|
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)
|
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 下进行讨论
|
2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似 issue,否则请在已有的 issue 下进行讨论
|
||||||
3. 请 **务必** 给 issue 填写一个简洁明了的标题,以便他人快速检索
|
3. 请 **务必** 给 issue 填写一个简洁明了的标题,以便他人快速检索
|
||||||
@@ -21,8 +19,6 @@ body:
|
|||||||
|
|
||||||
## Before submitting the issue, please make sure of the following checklist:
|
## 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)
|
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
|
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
|
3. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
|
||||||
|
|||||||
@@ -9,16 +9,12 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
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) 确认软件不存在类似的功能
|
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 下进行讨论
|
2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似 issue,否则请在已有的 issue 下进行讨论
|
||||||
3. 请 **务必** 给issue填写一个简洁明了的标题,以便他人快速检索
|
3. 请 **务必** 给issue填写一个简洁明了的标题,以便他人快速检索
|
||||||
4. 请 **务必** 先下载 [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) 版本测试,确保该功能还未实现
|
4. 请 **务必** 先下载 [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) 版本测试,确保该功能还未实现
|
||||||
5. 请 **务必** 按照模板规范详细描述问题,否则 issue 将会被关闭
|
5. 请 **务必** 按照模板规范详细描述问题,否则 issue 将会被关闭
|
||||||
## Before submitting the issue, please make sure of the following checklist:
|
## 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
|
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
|
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
|
3. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
What breaks or what is missing. Product bug fixes and features require a
|
What breaks or what is missing. Link the issue if one exists (`Fixes #123`).
|
||||||
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
|
## Approach
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
run: bash ./scripts/extract_update_logs.sh
|
run: bash ./scripts/extract_update_logs.sh
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6.1.1
|
- uses: pnpm/action-setup@v6.1.0
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -157,12 +157,15 @@ jobs:
|
|||||||
toolchain: '1.99.1'
|
toolchain: '1.99.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: rustup target add ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
prefix-key: 'v1-rust'
|
prefix-key: 'v1-rust'
|
||||||
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
cache-all-crates: true
|
cache-all-crates: true
|
||||||
@@ -174,7 +177,7 @@ jobs:
|
|||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6.1.1
|
- uses: pnpm/action-setup@v6.1.0
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
@@ -182,9 +185,17 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Pnpm Cache
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/.pnpm-store
|
||||||
|
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
|
||||||
|
|
||||||
- name: Pnpm install and check
|
- name: Pnpm install and check
|
||||||
run: |
|
run: |
|
||||||
pnpm i
|
pnpm i
|
||||||
@@ -193,6 +204,12 @@ jobs:
|
|||||||
- name: Release ${{ env.TAG_CHANNEL }} Version
|
- name: Release ${{ env.TAG_CHANNEL }} Version
|
||||||
run: pnpm release-version autobuild-latest
|
run: pnpm release-version autobuild-latest
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: |
|
||||||
|
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 for Windows-macOS-Linux
|
- name: Tauri build for Windows-macOS-Linux
|
||||||
uses: tauri-apps/tauri-action@v1
|
uses: tauri-apps/tauri-action@v1
|
||||||
env:
|
env:
|
||||||
@@ -243,28 +260,39 @@ jobs:
|
|||||||
toolchain: '1.99.1'
|
toolchain: '1.99.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: rustup target add ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
prefix-key: 'v1-rust'
|
prefix-key: 'v1-rust'
|
||||||
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
cache-all-crates: true
|
cache-all-crates: true
|
||||||
cache-workspace-crates: true
|
cache-workspace-crates: true
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v6.1.1
|
uses: pnpm/action-setup@v6.1.0
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Pnpm Cache
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/.pnpm-store
|
||||||
|
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
|
||||||
|
|
||||||
- name: Pnpm install and check
|
- name: Pnpm install and check
|
||||||
run: |
|
run: |
|
||||||
pnpm i
|
pnpm i
|
||||||
@@ -317,6 +345,12 @@ jobs:
|
|||||||
gcc-arm-linux-gnueabihf \
|
gcc-arm-linux-gnueabihf \
|
||||||
g++-arm-linux-gnueabihf
|
g++-arm-linux-gnueabihf
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: |
|
||||||
|
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 for Linux
|
- name: Tauri Build for Linux
|
||||||
run: |
|
run: |
|
||||||
export PKG_CONFIG_ALLOW_CROSS=1
|
export PKG_CONFIG_ALLOW_CROSS=1
|
||||||
@@ -370,34 +404,39 @@ jobs:
|
|||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install Rust Stable
|
- name: Add Rust Target
|
||||||
uses: dtolnay/rust-toolchain@master
|
run: rustup target add ${{ matrix.target }}
|
||||||
with:
|
|
||||||
toolchain: '1.99.1'
|
|
||||||
targets: ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
prefix-key: 'v1-rust'
|
prefix-key: 'v1-rust'
|
||||||
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}-fixed-webview2'
|
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
cache-all-crates: true
|
cache-all-crates: true
|
||||||
cache-workspace-crates: true
|
cache-workspace-crates: true
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v6.1.1
|
uses: pnpm/action-setup@v6.1.0
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Pnpm Cache
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/.pnpm-store
|
||||||
|
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
|
||||||
|
|
||||||
- name: Pnpm install and check
|
- name: Pnpm install and check
|
||||||
run: |
|
run: |
|
||||||
pnpm i
|
pnpm i
|
||||||
@@ -413,6 +452,12 @@ jobs:
|
|||||||
Remove-Item .\src-tauri\tauri.windows.conf.json
|
Remove-Item .\src-tauri\tauri.windows.conf.json
|
||||||
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json tauri.windows.conf.json
|
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json tauri.windows.conf.json
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: |
|
||||||
|
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 for Windows
|
- name: Tauri build for Windows
|
||||||
id: build
|
id: build
|
||||||
uses: tauri-apps/tauri-action@v1
|
uses: tauri-apps/tauri-action@v1
|
||||||
@@ -479,9 +524,9 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6.1.1
|
- uses: pnpm/action-setup@v6.1.0
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
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
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|||||||
@@ -77,12 +77,11 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@1.99.1
|
uses: dtolnay/rust-toolchain@1.99.1
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
if: github.event.inputs[matrix.input] == 'true'
|
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
prefix-key: 'v1-rust'
|
prefix-key: 'v1-rust'
|
||||||
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
cache-all-crates: true
|
cache-all-crates: true
|
||||||
@@ -104,9 +103,18 @@ jobs:
|
|||||||
if: github.event.inputs[matrix.input] == 'true'
|
if: github.event.inputs[matrix.input] == 'true'
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Pnpm Cache
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/.pnpm-store
|
||||||
|
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
|
||||||
|
lookup-only: true
|
||||||
|
|
||||||
- name: Pnpm install and check
|
- name: Pnpm install and check
|
||||||
if: github.event.inputs[matrix.input] == 'true'
|
if: github.event.inputs[matrix.input] == 'true'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -2,23 +2,11 @@ name: Frontend Check
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
|
||||||
- "src/**"
|
|
||||||
- "scripts/**"
|
|
||||||
- "**/*.js"
|
|
||||||
- "**/*.ts"
|
|
||||||
- "**/*.tsx"
|
|
||||||
- "**/*.css"
|
|
||||||
- "**/*.scss"
|
|
||||||
- "**/*.json"
|
|
||||||
- "pnpm-lock.yaml"
|
|
||||||
- "pnpm-workspace.yaml"
|
|
||||||
- "vite.config.*"
|
|
||||||
- ".github/workflows/frontend-check.yml"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
@@ -29,40 +17,142 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- 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
|
- name: Install pnpm
|
||||||
|
if: steps.check_frontend.outputs.frontend == 'true'
|
||||||
uses: pnpm/action-setup@v6
|
uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v7
|
||||||
|
if: steps.check_frontend.outputs.frontend == 'true'
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- name: Restore pnpm cache
|
||||||
|
if: steps.check_frontend.outputs.frontend == 'true'
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/.pnpm-store
|
||||||
|
key: "pnpm-shared-stable-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}"
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-shared-stable-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Check generated i18n types are up to date
|
- run: pnpm install --frozen-lockfile
|
||||||
run: |
|
if: steps.check_frontend.outputs.frontend == 'true'
|
||||||
pnpm i18n:types
|
|
||||||
if ! git diff --exit-code -- src/types/generated; then
|
|
||||||
echo "::error::src/types/generated is stale; run pnpm i18n:types and commit the result"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Run Prettier check
|
- name: Run Prettier check
|
||||||
|
if: steps.check_frontend.outputs.frontend == 'true'
|
||||||
run: pnpm format:check
|
run: pnpm format:check
|
||||||
|
|
||||||
- name: Run ESLint
|
- name: Run ESLint
|
||||||
|
if: steps.check_frontend.outputs.frontend == 'true'
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
|
|
||||||
- name: Run TypeScript typecheck
|
- name: Run TypeScript typecheck
|
||||||
|
if: steps.check_frontend.outputs.frontend == 'true'
|
||||||
run: pnpm typecheck
|
run: pnpm typecheck
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
|
if: steps.check_frontend.outputs.frontend == 'true'
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|
||||||
- name: Run Knip
|
- name: Run Knip
|
||||||
|
if: steps.check_frontend.outputs.frontend == 'true'
|
||||||
run: pnpm knip:check
|
run: pnpm knip:check
|
||||||
|
|
||||||
- name: Architecture gates (listen/emit only in the event bus, invoke only in services)
|
- 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
|
run: node scripts/check-architecture.mjs listen emit invoke
|
||||||
|
|
||||||
|
contract-parity:
|
||||||
|
runs-on: ubuntu-26.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Check contract inputs
|
||||||
|
id: check_inputs
|
||||||
|
uses: dorny/paths-filter@v4
|
||||||
|
with:
|
||||||
|
filters: scripts/contract-inputs.json
|
||||||
|
|
||||||
|
- name: Skip if contract inputs unchanged
|
||||||
|
if: steps.check_inputs.outputs.contract != 'true'
|
||||||
|
run: echo "No contract inputs changed, skipping parity check."
|
||||||
|
|
||||||
|
- name: Install Rust Stable
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
|
||||||
|
- name: Rust Cache
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
prefix-key: 'v1-rust'
|
||||||
|
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
|
||||||
|
workspaces: |
|
||||||
|
. -> target
|
||||||
|
cache-all-crates: true
|
||||||
|
cache-workspace-crates: true
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
uses: pnpm/action-setup@v6
|
||||||
|
with:
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v7
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
with:
|
||||||
|
node-version: '24.22.0'
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
|
||||||
|
- name: Provision sidecar placeholders for tauri-build
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/sidecar src-tauri/resources dist
|
||||||
|
touch dist/index.html
|
||||||
|
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
|
||||||
|
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Check generated contract without changing the checkout
|
||||||
|
if: steps.check_inputs.outputs.contract == 'true'
|
||||||
|
run: pnpm gen:contract --check
|
||||||
|
|||||||
@@ -2,14 +2,6 @@ name: Clippy Lint
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
|
||||||
- "src-tauri/**"
|
|
||||||
- "crates/**"
|
|
||||||
- "Cargo.toml"
|
|
||||||
- "Cargo.lock"
|
|
||||||
- "rust-toolchain.toml"
|
|
||||||
- ".cargo/config.toml"
|
|
||||||
- ".github/workflows/lint-clippy.yml"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
env:
|
env:
|
||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
@@ -31,6 +23,28 @@ jobs:
|
|||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
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
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
@@ -65,7 +79,7 @@ jobs:
|
|||||||
run: cargo clippy-all
|
run: cargo clippy-all
|
||||||
|
|
||||||
- name: Run Windows security tests
|
- name: Run Windows security tests
|
||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest' && (github.event_name == 'workflow_dispatch' || steps.check_changes.outputs.rust == 'true')
|
||||||
working-directory: ./src-tauri
|
working-directory: ./src-tauri
|
||||||
run: cargo test --all-features --target ${{ matrix.target }}
|
run: cargo test --all-features --target ${{ matrix.target }}
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
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"
|
|
||||||
+118
-141
@@ -1,9 +1,6 @@
|
|||||||
name: Release Build
|
name: Release Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
types: [edited]
|
|
||||||
branches: [main]
|
|
||||||
push:
|
push:
|
||||||
# -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新
|
# -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新
|
||||||
tags:
|
tags:
|
||||||
@@ -14,59 +11,75 @@ env:
|
|||||||
RUST_BACKTRACE: short
|
RUST_BACKTRACE: short
|
||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-publication
|
group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}'
|
||||||
cancel-in-progress: false
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare_release:
|
check_tag_version:
|
||||||
name: Approve Snapshot and Prepare Draft Release
|
name: Check Release Tag and package.json Version Consistency
|
||||||
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
|
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:
|
steps:
|
||||||
- name: Checkout trusted release tooling
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: refs/heads/dev
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-node@v7
|
|
||||||
with:
|
|
||||||
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
|
- name: Check if tag is from main branch
|
||||||
id: draft
|
run: |
|
||||||
env:
|
TAG_REF="${GITHUB_REF##*/}"
|
||||||
RELEASE_TAG: ${{ steps.source.outputs.release_tag }}
|
echo "Checking if tag $TAG_REF is from main branch..."
|
||||||
SOURCE_SHA: ${{ steps.source.outputs.source_sha }}
|
|
||||||
run: node scripts-workflow/release.mjs prepare-draft
|
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
|
||||||
|
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') }}
|
||||||
|
|
||||||
update_tag:
|
update_tag:
|
||||||
name: Publish Completed Release
|
name: Update tag
|
||||||
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
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
[
|
[
|
||||||
@@ -78,8 +91,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
|
||||||
ref: ${{ needs.prepare_release.outputs.source_sha }}
|
|
||||||
|
|
||||||
- name: Group changelog platforms
|
- name: Group changelog platforms
|
||||||
run: bash ./scripts-workflow/group_platforms.sh
|
run: bash ./scripts-workflow/group_platforms.sh
|
||||||
@@ -92,7 +103,7 @@ jobs:
|
|||||||
- name: Set Env
|
- name: Set Env
|
||||||
run: |
|
run: |
|
||||||
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
|
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
|
||||||
TAG_REF="$RELEASE_TAG"
|
TAG_REF="${GITHUB_REF##*/}"
|
||||||
echo "TAG_NAME=$TAG_REF" >> $GITHUB_ENV
|
echo "TAG_NAME=$TAG_REF" >> $GITHUB_ENV
|
||||||
VERSION=$(echo "$TAG_REF" | sed 's/^v//')
|
VERSION=$(echo "$TAG_REF" | sed 's/^v//')
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
@@ -138,16 +149,10 @@ jobs:
|
|||||||
Created at ${{ env.BUILDTIME }}.
|
Created at ${{ env.BUILDTIME }}.
|
||||||
EOF
|
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
|
- name: Publish Release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
PRERELEASE: ${{ contains(env.RELEASE_TAG, '-rc') }}
|
PRERELEASE: ${{ contains(github.ref_name, '-rc') }}
|
||||||
run: |
|
run: |
|
||||||
release_id="${{ needs.prepare_release.outputs.release_id }}"
|
release_id="${{ needs.prepare_release.outputs.release_id }}"
|
||||||
|
|
||||||
@@ -176,8 +181,6 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
name: Release Build
|
name: Release Build
|
||||||
needs: [prepare_release]
|
needs: [prepare_release]
|
||||||
env:
|
|
||||||
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -197,8 +200,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
|
||||||
ref: ${{ needs.prepare_release.outputs.source_sha }}
|
|
||||||
|
|
||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
@@ -206,12 +207,15 @@ jobs:
|
|||||||
toolchain: '1.99.1'
|
toolchain: '1.99.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: rustup target add ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
prefix-key: 'v1-rust'
|
prefix-key: 'v1-rust'
|
||||||
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
cache-all-crates: true
|
cache-all-crates: true
|
||||||
@@ -223,23 +227,29 @@ jobs:
|
|||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Install Node
|
||||||
|
uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: '24.22.0'
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Install Node
|
|
||||||
uses: actions/setup-node@v7
|
|
||||||
with:
|
|
||||||
node-version: '26.13.0'
|
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Pnpm install and check
|
- name: Pnpm install and check
|
||||||
run: |
|
run: |
|
||||||
pnpm i
|
pnpm i
|
||||||
pnpm run prebuild ${{ matrix.target }}
|
pnpm run prebuild ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: |
|
||||||
|
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
|
- name: Tauri build
|
||||||
|
# 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本
|
||||||
uses: tauri-apps/tauri-action@v1.0.0
|
uses: tauri-apps/tauri-action@v1.0.0
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
@@ -254,14 +264,11 @@ jobs:
|
|||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
with:
|
with:
|
||||||
releaseId: ${{ needs.prepare_release.outputs.release_id }}
|
releaseId: ${{ needs.prepare_release.outputs.release_id }}
|
||||||
tagName: ${{ env.RELEASE_TAG }}
|
tagName: ${{ github.ref_name }}
|
||||||
releaseBody: 'Draft release, will be updated later.'
|
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
|
tauriScript: pnpm
|
||||||
args: --target ${{ matrix.target }}
|
args: --target ${{ matrix.target }}
|
||||||
uploadUpdaterJson: ${{ needs.prepare_release.outputs.channel == 'stable' }}
|
includeUpdaterJson: true
|
||||||
|
|
||||||
- name: Attest Windows bundles
|
- name: Attest Windows bundles
|
||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
@@ -286,8 +293,6 @@ jobs:
|
|||||||
release-for-linux-arm:
|
release-for-linux-arm:
|
||||||
name: Release Build for Linux ARM
|
name: Release Build for Linux ARM
|
||||||
needs: [prepare_release]
|
needs: [prepare_release]
|
||||||
env:
|
|
||||||
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -302,8 +307,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
|
||||||
ref: ${{ needs.prepare_release.outputs.source_sha }}
|
|
||||||
|
|
||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
@@ -311,28 +314,30 @@ jobs:
|
|||||||
toolchain: '1.99.1'
|
toolchain: '1.99.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: rustup target add ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
prefix-key: 'v1-rust'
|
prefix-key: 'v1-rust'
|
||||||
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
cache-all-crates: true
|
cache-all-crates: true
|
||||||
cache-workspace-crates: true
|
cache-workspace-crates: true
|
||||||
|
|
||||||
|
- name: Install Node
|
||||||
|
uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: '24.22.0'
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v6
|
uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Install Node
|
|
||||||
uses: actions/setup-node@v7
|
|
||||||
with:
|
|
||||||
node-version: '26.13.0'
|
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Pnpm install and check
|
- name: Pnpm install and check
|
||||||
run: |
|
run: |
|
||||||
pnpm i
|
pnpm i
|
||||||
@@ -382,6 +387,12 @@ jobs:
|
|||||||
gcc-arm-linux-gnueabihf \
|
gcc-arm-linux-gnueabihf \
|
||||||
g++-arm-linux-gnueabihf
|
g++-arm-linux-gnueabihf
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: |
|
||||||
|
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: Build for Linux
|
- name: Build for Linux
|
||||||
run: |
|
run: |
|
||||||
export PKG_CONFIG_ALLOW_CROSS=1
|
export PKG_CONFIG_ALLOW_CROSS=1
|
||||||
@@ -409,7 +420,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
gh release upload "$RELEASE_TAG" \
|
gh release upload "${{ github.ref_name }}" \
|
||||||
target/${{ matrix.target }}/release/bundle/deb/*.deb \
|
target/${{ matrix.target }}/release/bundle/deb/*.deb \
|
||||||
target/${{ matrix.target }}/release/bundle/rpm/*.rpm \
|
target/${{ matrix.target }}/release/bundle/rpm/*.rpm \
|
||||||
--clobber
|
--clobber
|
||||||
@@ -417,8 +428,6 @@ jobs:
|
|||||||
release-for-fixed-webview2:
|
release-for-fixed-webview2:
|
||||||
name: Release Build for Fixed WebView2
|
name: Release Build for Fixed WebView2
|
||||||
needs: [prepare_release]
|
needs: [prepare_release]
|
||||||
env:
|
|
||||||
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -433,8 +442,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
|
||||||
ref: ${{ needs.prepare_release.outputs.source_sha }}
|
|
||||||
|
|
||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
@@ -442,28 +449,30 @@ jobs:
|
|||||||
toolchain: '1.99.1'
|
toolchain: '1.99.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: rustup target add ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
save-if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
prefix-key: 'v1-rust'
|
prefix-key: 'v1-rust'
|
||||||
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}-fixed-webview2'
|
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||||
workspaces: |
|
workspaces: |
|
||||||
. -> target
|
. -> target
|
||||||
cache-all-crates: true
|
cache-all-crates: true
|
||||||
cache-workspace-crates: true
|
cache-workspace-crates: true
|
||||||
|
|
||||||
|
- name: Install Node
|
||||||
|
uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: '24.22.0'
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Install Node
|
|
||||||
uses: actions/setup-node@v7
|
|
||||||
with:
|
|
||||||
node-version: '26.13.0'
|
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Pnpm install and check
|
- name: Pnpm install and check
|
||||||
run: |
|
run: |
|
||||||
pnpm i
|
pnpm i
|
||||||
@@ -476,6 +485,12 @@ jobs:
|
|||||||
Remove-Item .\src-tauri\tauri.windows.conf.json
|
Remove-Item .\src-tauri\tauri.windows.conf.json
|
||||||
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json tauri.windows.conf.json
|
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json tauri.windows.conf.json
|
||||||
|
|
||||||
|
- name: Add Rust Target
|
||||||
|
run: |
|
||||||
|
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
|
- name: Tauri build
|
||||||
uses: tauri-apps/tauri-action@v1.0.0
|
uses: tauri-apps/tauri-action@v1.0.0
|
||||||
env:
|
env:
|
||||||
@@ -517,55 +532,22 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
$files = Get-ChildItem "target\${{ matrix.target }}\release\bundle\nsis\*setup*" | Select-Object -ExpandProperty FullName
|
$files = Get-ChildItem "target\${{ matrix.target }}\release\bundle\nsis\*setup*" | Select-Object -ExpandProperty FullName
|
||||||
gh release upload "$env:RELEASE_TAG" $files --clobber
|
gh release upload "${{ github.ref_name }}" $files --clobber
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
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:
|
release-update:
|
||||||
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
|
if: ${{ !contains(github.ref_name, '-rc') }}
|
||||||
name: Release Update
|
name: Release Update
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [update_tag]
|
needs: [update_tag]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
|
||||||
ref: ${{ needs.update_tag.outputs.source_sha }}
|
|
||||||
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -581,19 +563,17 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
release-update-for-fixed-webview2:
|
release-update-for-fixed-webview2:
|
||||||
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
|
if: ${{ !contains(github.ref_name, '-rc') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [update_tag]
|
needs: [update_tag]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
|
||||||
ref: ${{ needs.update_tag.outputs.source_sha }}
|
|
||||||
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -609,7 +589,7 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
submit-to-winget:
|
submit-to-winget:
|
||||||
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
|
if: ${{ !contains(github.ref_name, '-rc') }}
|
||||||
name: Submit to Winget
|
name: Submit to Winget
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [update_tag, release-update]
|
needs: [update_tag, release-update]
|
||||||
@@ -617,7 +597,6 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.update_tag.outputs.source_sha }}
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Get Version
|
- name: Get Version
|
||||||
run: |
|
run: |
|
||||||
@@ -634,7 +613,7 @@ jobs:
|
|||||||
token: ${{ secrets.WINGET_TOKEN }}
|
token: ${{ secrets.WINGET_TOKEN }}
|
||||||
|
|
||||||
notify-telegram:
|
notify-telegram:
|
||||||
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
|
if: ${{ !contains(github.ref_name, '-rc') }}
|
||||||
name: Notify Telegram
|
name: Notify Telegram
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
@@ -647,8 +626,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
|
||||||
ref: ${{ needs.update_tag.outputs.source_sha }}
|
|
||||||
|
|
||||||
- name: Group changelog platforms
|
- name: Group changelog platforms
|
||||||
run: bash ./scripts-workflow/group_platforms.sh
|
run: bash ./scripts-workflow/group_platforms.sh
|
||||||
@@ -661,7 +638,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|||||||
@@ -6,12 +6,6 @@ name: Check Formatting
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
|
||||||
- "src-tauri/**"
|
|
||||||
- "**/*.rs"
|
|
||||||
- "rustfmt.toml"
|
|
||||||
- "rust-toolchain.toml"
|
|
||||||
- ".github/workflows/rustfmt.yml"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -25,10 +19,25 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- 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
|
- name: install Rust stable and rustfmt
|
||||||
|
if: steps.check_rust.outputs.rust == 'true'
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
components: rustfmt
|
components: rustfmt
|
||||||
|
|
||||||
- name: run cargo fmt
|
- name: run cargo fmt
|
||||||
|
if: steps.check_rust.outputs.rust == 'true'
|
||||||
run: cargo fmt --manifest-path ./src-tauri/Cargo.toml --all -- --check
|
run: cargo fmt --manifest-path ./src-tauri/Cargo.toml --all -- --check
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v6
|
uses: pnpm/action-setup@v6
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '26.13.0'
|
node-version: '24.22.0'
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
nodejs 26.13.0
|
nodejs 24.22.0
|
||||||
pnpm 12.18.0
|
pnpm 12.14.0
|
||||||
rust 1.99.1
|
rust 1.99.1
|
||||||
|
|||||||
@@ -18,25 +18,22 @@ These rules apply to every change, whether human- or agent-authored. They match
|
|||||||
the ownership evidence the AI-slop review evaluates (see
|
the ownership evidence the AI-slop review evaluates (see
|
||||||
[`pr-ai-slop-review.md`](.github/workflows/pr-ai-slop-review.md)).
|
[`pr-ai-slop-review.md`](.github/workflows/pr-ai-slop-review.md)).
|
||||||
|
|
||||||
1. **Issue first.** Product bug fixes and features require a pre-existing issue;
|
1. **Issue first.** Non-trivial changes require a pre-existing issue describing
|
||||||
self-scoped maintenance work does not. Follow the PR template for linkage and
|
the problem. If none exists, ask the maintainers to open or approve one
|
||||||
revert references. If a required issue is missing, ask maintainers to open or
|
before implementing.
|
||||||
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
|
2. **Scope discipline.** Every changed file must be justifiable from the linked
|
||||||
issue or the stated PR scope. No drive-by refactors, renames, formatting
|
issue. No drive-by refactors, renames, formatting churn, or dependency bumps
|
||||||
churn, or dependency bumps unrelated to the work.
|
unrelated to the problem being fixed.
|
||||||
3. **Author accountability.** AI assistance is welcome, but the contributor owns
|
3. **Author accountability.** AI assistance is welcome, but the contributor owns
|
||||||
the result: understand the change, describe the problem and approach in your
|
the result: understand the change, describe the problem and approach in your
|
||||||
own words, and verify the change against the reported behavior before
|
own words, and verify the change against the reported behavior before
|
||||||
submitting.
|
submitting.
|
||||||
4. **Tests are justified, not default.** Do not add tests, test scaffolding, or
|
4. **Tests are justified, not default.** Do not add tests, test scaffolding, or
|
||||||
speculative defensive code unless the linked issue or stated task demands
|
speculative defensive code unless the linked issue demands them. When a test
|
||||||
them. When a test is genuinely necessary — it reproduces the reported
|
is genuinely necessary — it reproduces the reported regression or guards
|
||||||
regression or guards behavior whose breakage would otherwise go unnoticed —
|
behavior whose breakage would otherwise go unnoticed — keep it minimal and
|
||||||
keep it minimal and state in the PR body why it is needed. Bulk test files
|
state in the PR body why it is needed. Bulk test files and defensive
|
||||||
and defensive programming for hypothetical failure modes are PR bloat, not
|
programming for hypothetical failure modes are PR bloat, not rigor.
|
||||||
rigor.
|
|
||||||
5. **Comments state constraints, not narration.** Write a comment only for a
|
5. **Comments state constraints, not narration.** Write a comment only for a
|
||||||
non-obvious constraint the code cannot express; never restate what the code
|
non-obvious constraint the code cannot express; never restate what the code
|
||||||
does.
|
does.
|
||||||
@@ -47,12 +44,13 @@ the ownership evidence the AI-slop review evaluates (see
|
|||||||
real evidence instead: reproduction steps, failure output, targeted tests.
|
real evidence instead: reproduction steps, failure output, targeted tests.
|
||||||
8. **Minimal diffs.** Match the surrounding code's style, naming, and comment
|
8. **Minimal diffs.** Match the surrounding code's style, naming, and comment
|
||||||
density. Do not introduce new dependencies or restructure working code unless
|
density. Do not introduce new dependencies or restructure working code unless
|
||||||
the linked issue or stated task demands it.
|
the issue demands it.
|
||||||
9. **Disclose AI automation.** When an agent creates or co-creates a PR or Issue,
|
9. **Disclose AI automation.** When an agent produces or co-produces a change,
|
||||||
append a final body line with the model and effort used (e.g.
|
append a footer line to the PR body with the model and effort used (e.g.
|
||||||
`Assisted by: GPT-5.6 High`). Effort may be omitted when the runtime does not
|
`Assisted by: GPT-5.6 High`). The PR template intentionally omits this line —
|
||||||
report it. Human contributors are not asked to disclose AI assistance.
|
the agent adds it itself, humans are not asked to declare anything. Effort may
|
||||||
Disclosure is transparency only and carries no positive or negative weight.
|
be omitted when the runtime does not report it. Disclosure is transparency
|
||||||
|
only; it does not substitute for any rule above.
|
||||||
10. **Compiled workflows.** The AI-slop review policy in
|
10. **Compiled workflows.** The AI-slop review policy in
|
||||||
[pr-ai-slop-review.md](.github/workflows/pr-ai-slop-review.md) is compiled:
|
[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
|
after editing it, run `gh aw compile` and commit the regenerated
|
||||||
@@ -61,10 +59,8 @@ the ownership evidence the AI-slop review evaluates (see
|
|||||||
[`template/Changelog.md`](template/Changelog.md): one line per
|
[`template/Changelog.md`](template/Changelog.md): one line per
|
||||||
user-visible change, no internals.
|
user-visible change, no internals.
|
||||||
|
|
||||||
## Issue and PR Workflows
|
## Pull Request Shape
|
||||||
|
|
||||||
When drafting, triaging, splitting, or implementing from a community issue, use
|
Describe three things, briefly: the problem (with issue link), why this approach
|
||||||
the conditional checklist in [`.github/ISSUE_GUIDELINES.md`](.github/ISSUE_GUIDELINES.md).
|
solves it, and what changed. See
|
||||||
Do not make GitHub issue changes without explicit authorization. For PRs, follow
|
[`PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md).
|
||||||
the directly loaded [PR template](.github/PULL_REQUEST_TEMPLATE.md); do not
|
|
||||||
duplicate its prompts here.
|
|
||||||
|
|||||||
+6
-19
@@ -28,7 +28,7 @@ Before contributing, you need to set up your development environment. Follow the
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
1. **Install Rust and Node.js**
|
1. **Install Rust and Node.js**
|
||||||
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.
|
Our project requires both Rust and Node.js. Follow the official installation instructions [here](https://tauri.app/start/prerequisites/).
|
||||||
|
|
||||||
### Windows Users
|
### Windows Users
|
||||||
|
|
||||||
@@ -51,21 +51,18 @@ rustup set default-host x86_64-pc-windows-msvc
|
|||||||
|
|
||||||
### Install Node.js Package Manager
|
### Install Node.js Package Manager
|
||||||
|
|
||||||
[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:
|
Enable `corepack`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install --global corepack
|
|
||||||
corepack enable
|
corepack enable
|
||||||
```
|
```
|
||||||
|
|
||||||
Run pnpm from this repository so Corepack selects the version pinned by `packageManager` in [`package.json`](package.json).
|
|
||||||
|
|
||||||
### Install Project Dependencies
|
### Install Project Dependencies
|
||||||
|
|
||||||
Node.js dependencies:
|
Node.js dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install --frozen-lockfile
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
Ubuntu-only system packages:
|
Ubuntu-only system packages:
|
||||||
@@ -81,24 +78,14 @@ pnpm run prebuild
|
|||||||
pnpm run prebuild --force # Re-download and overwrite Mihomo core and service binaries
|
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 the Development Server
|
||||||
|
|
||||||
Run these commands from the root of `clash-verge-rev`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev # Build the development service and start the desktop app
|
pnpm dev # Standard
|
||||||
pnpm dev:diff # Alias of pnpm dev
|
pnpm dev:diff # If an app instance already exists
|
||||||
pnpm dev:tauri # Run Tauri directly with the tauri-dev feature
|
pnpm dev:tauri # Run Tauri development mode
|
||||||
```
|
```
|
||||||
|
|
||||||
For frontend-only development, use `pnpm web:dev`. It does not start the Rust backend or build the development service.
|
|
||||||
|
|
||||||
### Build the Project
|
### Build the Project
|
||||||
|
|
||||||
Standard build:
|
Standard build:
|
||||||
|
|||||||
Generated
+319
-237
File diff suppressed because it is too large
Load Diff
+8
-6
@@ -61,20 +61,21 @@ tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", bra
|
|||||||
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
|
|
||||||
tauri = { version = "2.12.5" }
|
tauri = { version = "2.12.1" }
|
||||||
tauri-plugin-clipboard-manager = "2.5.0"
|
tauri-plugin-clipboard-manager = "2.4.1"
|
||||||
parking_lot = "0.13.0"
|
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
chrono = "0.4.45"
|
chrono = "0.4.45"
|
||||||
criterion = { version = "0.8.2", features = ["async_tokio"] }
|
criterion = { version = "0.8.2", features = ["async_tokio"] }
|
||||||
tokio = { version = "1.54.1", features = [
|
tokio = { version = "1.53.3", features = [
|
||||||
"rt-multi-thread",
|
"rt-multi-thread",
|
||||||
"macros",
|
"macros",
|
||||||
"time",
|
"time",
|
||||||
"sync",
|
"sync",
|
||||||
] }
|
] }
|
||||||
flexi_logger = "0.31.10"
|
flexi_logger = "0.31.10"
|
||||||
log = "0.4.35"
|
log = "0.4.34"
|
||||||
|
regex = "1.13.1"
|
||||||
reqwest = { version = "0.13.5", features = [
|
reqwest = { version = "0.13.5", features = [
|
||||||
"json",
|
"json",
|
||||||
"cookies",
|
"cookies",
|
||||||
@@ -84,11 +85,12 @@ reqwest = { version = "0.13.5", features = [
|
|||||||
] }
|
] }
|
||||||
rust_iso3166 = "0.2.0"
|
rust_iso3166 = "0.2.0"
|
||||||
|
|
||||||
smartstring = { version = "2.0.0" }
|
smartstring = { version = "1.0.1" }
|
||||||
|
|
||||||
serde = { version = "1.0.229" }
|
serde = { version = "1.0.229" }
|
||||||
serde_json = { version = "1.0.154" }
|
serde_json = { version = "1.0.154" }
|
||||||
serde_yaml_ng = { version = "0.10.0" }
|
serde_yaml_ng = { version = "0.10.0" }
|
||||||
|
bitflags = { version = "2.13.2" }
|
||||||
socket2 = "0.6.5"
|
socket2 = "0.6.5"
|
||||||
|
|
||||||
# *** For Windows platform only ***
|
# *** For Windows platform only ***
|
||||||
|
|||||||
+10
-31
@@ -1,37 +1,16 @@
|
|||||||
<!-- Release-notes skeleton. Entries accumulate in ./Changelog.md as PRs land;
|
## v2.5.9
|
||||||
before publishing a release, distill them:
|
|
||||||
- user-visible changes only — no dev tooling, CI fixes, or refactors whose
|
|
||||||
effect is already covered by another entry
|
|
||||||
- describe outcomes, not internals: no internal module names, IPC or
|
|
||||||
protocol details, or implementation mechanics — write what the user
|
|
||||||
observes, not how the code achieves it
|
|
||||||
- one line per theme: merge entries that share a root cause
|
|
||||||
- each entry fits a single readable line (about 60 CJK characters); if it
|
|
||||||
overflows, it is packing multiple themes or detail that belongs in the
|
|
||||||
linked issue/PR — split it or drop the detail
|
|
||||||
- line grammar: open with a verb (修复/新增/优化), with the platform
|
|
||||||
first (Windows/macOS/Linux) when the change is platform-specific;
|
|
||||||
merge sub-symptoms as 主题:症状、症状; full-width punctuation,
|
|
||||||
「」 for UI labels, backticks for config keys and commands
|
|
||||||
- platform entries sit under their platform heading (bold, emoji
|
|
||||||
prefixed, ordered Windows/macOS/Linux, combined for pairs like
|
|
||||||
macOS/Linux) at the end of their section;
|
|
||||||
scripts-workflow/group_platforms.sh regroups, --check verifies
|
|
||||||
HTML comments are stripped by the update dialog's sanitizer and hidden on
|
|
||||||
GitHub, so they never reach users. -->
|
|
||||||
## v2.6.0
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||||
|
|
||||||
- 修复检测更新后又发布新版时,应用内更新失败
|
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
|
||||||
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
|
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
|
||||||
- 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用
|
- 修复 首页 网站测速失败后卡片一直显示测试中
|
||||||
|
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
|
||||||
|
|
||||||
**🍎 macOS**
|
**🐧 Linux**
|
||||||
|
|
||||||
- 修复 macOS TUN 模式下原有 DNS 被清除、关闭后 DNS 未恢复及联网后设置未生效
|
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
|
||||||
- 修复 macOS 断网时误报代理已回退直连,恢复联网后自动重试系统代理
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -44,10 +23,10 @@
|
|||||||
<details>
|
<details>
|
||||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||||
|
|
||||||
- 优化全局模式代理列表:仅显示 `GLOBAL` 节点,选中其中的策略组时才显示该组
|
- 优化「链式代理」:开启前需确认网速影响提示,空面板新增「关闭」按钮
|
||||||
|
|
||||||
**🍎 macOS**
|
**🖥️ Windows**
|
||||||
|
|
||||||
- 优化 macOS TUN 模式的系统 DNS,增加阿里和 Cloudflare 地址
|
- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
specta = { version = "=2.0.0-rc.26", features = ["derive", "function"] }
|
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
|
||||||
clash-verge-logging = { workspace = true }
|
clash-verge-logging = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ mod chatgpt;
|
|||||||
mod claude;
|
mod claude;
|
||||||
mod disney_plus;
|
mod disney_plus;
|
||||||
mod gemini;
|
mod gemini;
|
||||||
mod muse;
|
|
||||||
mod netflix;
|
mod netflix;
|
||||||
mod prime_video;
|
mod prime_video;
|
||||||
mod spotify;
|
mod spotify;
|
||||||
@@ -31,7 +30,6 @@ enum UnlockCheck {
|
|||||||
ChatGptWeb,
|
ChatGptWeb,
|
||||||
Claude,
|
Claude,
|
||||||
Gemini,
|
Gemini,
|
||||||
MuseAi,
|
|
||||||
YouTubePremium,
|
YouTubePremium,
|
||||||
BahamutAnime,
|
BahamutAnime,
|
||||||
Netflix,
|
Netflix,
|
||||||
@@ -48,7 +46,6 @@ impl UnlockCheck {
|
|||||||
Self::ChatGptWeb,
|
Self::ChatGptWeb,
|
||||||
Self::Claude,
|
Self::Claude,
|
||||||
Self::Gemini,
|
Self::Gemini,
|
||||||
Self::MuseAi,
|
|
||||||
Self::YouTubePremium,
|
Self::YouTubePremium,
|
||||||
Self::BahamutAnime,
|
Self::BahamutAnime,
|
||||||
Self::Netflix,
|
Self::Netflix,
|
||||||
@@ -69,7 +66,6 @@ impl UnlockCheck {
|
|||||||
Self::ChatGptWeb => chatgpt::CHATGPT_WEB_NAME,
|
Self::ChatGptWeb => chatgpt::CHATGPT_WEB_NAME,
|
||||||
Self::Claude => claude::CLAUDE_NAME,
|
Self::Claude => claude::CLAUDE_NAME,
|
||||||
Self::Gemini => gemini::GEMINI_NAME,
|
Self::Gemini => gemini::GEMINI_NAME,
|
||||||
Self::MuseAi => muse::MUSE_AI_NAME,
|
|
||||||
Self::YouTubePremium => youtube::YOUTUBE_PREMIUM_NAME,
|
Self::YouTubePremium => youtube::YOUTUBE_PREMIUM_NAME,
|
||||||
Self::BahamutAnime => bahamut::BAHAMUT_ANIME_NAME,
|
Self::BahamutAnime => bahamut::BAHAMUT_ANIME_NAME,
|
||||||
Self::Netflix => netflix::NETFLIX_NAME,
|
Self::Netflix => netflix::NETFLIX_NAME,
|
||||||
@@ -87,7 +83,6 @@ impl UnlockCheck {
|
|||||||
Self::ChatGptWeb => chatgpt::check_chatgpt(client).await,
|
Self::ChatGptWeb => chatgpt::check_chatgpt(client).await,
|
||||||
Self::Claude => claude::check_claude(client).await,
|
Self::Claude => claude::check_claude(client).await,
|
||||||
Self::Gemini => gemini::check_gemini(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::YouTubePremium => youtube::check_youtube_premium(client).await,
|
||||||
Self::BahamutAnime => bahamut::check_bahamut_anime(client).await,
|
Self::BahamutAnime => bahamut::check_bahamut_anime(client).await,
|
||||||
Self::Netflix => netflix::check_netflix(client).await,
|
Self::Netflix => netflix::check_netflix(client).await,
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -5,14 +5,14 @@ edition = "2024"
|
|||||||
rust-version = "1.91"
|
rust-version = "1.91"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
specta = { version = "=2.0.0-rc.26", features = ["derive", "function"] }
|
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
|
||||||
tauri = { workspace = true }
|
tauri = { workspace = true }
|
||||||
tauri-plugin-clipboard-manager = { workspace = true }
|
tauri-plugin-clipboard-manager = { workspace = true }
|
||||||
parking_lot = { workspace = true }
|
parking_lot = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
# sysinfo 0.38.2 conflicts with dark-light
|
# sysinfo 0.38.2 conflicts with dark-light
|
||||||
# see https://github.com/GuillaumeGomez/sysinfo/issues/1623
|
# see https://github.com/GuillaumeGomez/sysinfo/issues/1623
|
||||||
sysinfo = { version = "0.40.1", default-features = false, features = ["network", "system"] }
|
sysinfo = { version = "0.39.6", features = ["network", "system"] }
|
||||||
|
|
||||||
[target.'cfg(not(windows))'.dependencies]
|
[target.'cfg(not(windows))'.dependencies]
|
||||||
libc = "0.2.191"
|
libc = "0.2.191"
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ pub struct SysInfo {
|
|||||||
impl Default for SysInfo {
|
impl Default for SysInfo {
|
||||||
#[inline]
|
#[inline]
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let system_name = System::name().unwrap_or_else(|_| "Null".into());
|
let system_name = System::name().unwrap_or_else(|| "Null".into());
|
||||||
let system_version = System::long_os_version().unwrap_or_else(|_| "Null".into());
|
let system_version = System::long_os_version().unwrap_or_else(|| "Null".into());
|
||||||
let system_kernel_version = System::kernel_version().unwrap_or_else(|_| "Null".into());
|
let system_kernel_version = System::kernel_version().unwrap_or_else(|| "Null".into());
|
||||||
let system_arch = System::cpu_arch();
|
let system_arch = System::cpu_arch();
|
||||||
Self {
|
Self {
|
||||||
system_name,
|
system_name,
|
||||||
@@ -130,14 +130,9 @@ pub fn current_gid() -> u32 {
|
|||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn list_network_interfaces() -> Vec<String> {
|
pub fn list_network_interfaces() -> Vec<String> {
|
||||||
let Ok(mut networks) = Networks::new() else {
|
let mut networks = Networks::new();
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
networks.refresh(false);
|
networks.refresh(false);
|
||||||
networks
|
networks.keys().map(|name| name.to_owned()).collect()
|
||||||
.keys()
|
|
||||||
.map(|name| name.to_string_lossy().into_owned())
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
|
|||||||
@@ -1,80 +1,3 @@
|
|||||||
## v2.5.10
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
|
||||||
|
|
||||||
- 修复全局模式缺少 `GLOBAL` 策略组,无法单独选择全局出口
|
|
||||||
- 修复服务模式下,SSH 节点的 `private-key` 使用私钥文件时内核无法启动
|
|
||||||
- 修复服务模式下,内核更新的 GEO 数据库不会同步回应用目录,导致使用新分类的规则无法通过校验
|
|
||||||
- 修复订阅的 DNS 配置变化后,「DNS 覆写」实际已关闭但开关仍显示开启且没有提示
|
|
||||||
|
|
||||||
**🖥️ Windows**
|
|
||||||
|
|
||||||
- Windows:修复安装程序重新安装或覆盖时未提示退出运行中应用,导致卸载报错或覆盖失败
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
|
||||||
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
|
||||||
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
|
|
||||||
## v2.5.9
|
|
||||||
|
|
||||||
- **Mihomo(Meta) 内核升级至 v1.19.33**
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
|
||||||
|
|
||||||
- 修复规则扩展包含空列表项时,「编辑规则」界面报错的问题
|
|
||||||
- 修复 重启应用后定时自动备份不执行的问题
|
|
||||||
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
|
|
||||||
- 修复通过 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**
|
|
||||||
|
|
||||||
- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
|
|
||||||
## v2.5.8
|
## v2.5.8
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -129,7 +129,6 @@ export default defineConfig([
|
|||||||
'eslint.config.ts',
|
'eslint.config.ts',
|
||||||
`vite.config.mts`,
|
`vite.config.mts`,
|
||||||
'scripts/*.mjs',
|
'scripts/*.mjs',
|
||||||
'scripts-workflow/*.mjs',
|
|
||||||
'tests/*.ts',
|
'tests/*.ts',
|
||||||
'src/polyfills/*.js',
|
'src/polyfills/*.js',
|
||||||
'tests/*.mjs',
|
'tests/*.mjs',
|
||||||
@@ -239,13 +238,6 @@ export default defineConfig([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
files: ['scripts-workflow/*.mjs'],
|
|
||||||
languageOptions: {
|
|
||||||
globals: globals.node,
|
|
||||||
},
|
|
||||||
rules: { 'es-x/no-object-groupby': 'off' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
files: ['scripts/perf/*.{mjs,mts}'],
|
files: ['scripts/perf/*.{mjs,mts}'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
|||||||
+20
-19
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clash-verge",
|
"name": "clash-verge",
|
||||||
"version": "2.6.0",
|
"version": "2.5.9",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky || true",
|
"prepare": "husky || true",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
|
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"web:dev": "vite",
|
"web:dev": "vite",
|
||||||
"web:build": "tsrs -p . --noEmit && vite build",
|
"web:build": "tsc --noEmit && vite build",
|
||||||
"web:serve": "vite preview",
|
"web:serve": "vite preview",
|
||||||
"prebuild": "node scripts/prebuild.mjs",
|
"prebuild": "node scripts/prebuild.mjs",
|
||||||
"updater": "node scripts/updater.mjs",
|
"updater": "node scripts/updater.mjs",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"i18n:types": "node scripts/generate-i18n-keys.mjs",
|
"i18n:types": "node scripts/generate-i18n-keys.mjs",
|
||||||
"gen:contract": "node scripts/gen-contract.mjs",
|
"gen:contract": "node scripts/gen-contract.mjs",
|
||||||
"arch:check": "node scripts/check-architecture.mjs listen emit invoke",
|
"arch:check": "node scripts/check-architecture.mjs listen emit invoke",
|
||||||
"typecheck": "tsrs -p . --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"knip:check": "knip",
|
"knip:check": "knip",
|
||||||
@@ -54,13 +54,13 @@
|
|||||||
"@mui/icons-material": "^9.4.0",
|
"@mui/icons-material": "^9.4.0",
|
||||||
"@mui/material": "^9.4.0",
|
"@mui/material": "^9.4.0",
|
||||||
"@tanstack/react-virtual": "^3.14.13",
|
"@tanstack/react-virtual": "^3.14.13",
|
||||||
"@tauri-apps/api": "2.12.4",
|
"@tauri-apps/api": "2.12.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "2.5.0",
|
"@tauri-apps/plugin-clipboard-manager": "2.4.0",
|
||||||
"@tauri-apps/plugin-dialog": "2.8.4",
|
"@tauri-apps/plugin-dialog": "2.8.0",
|
||||||
"@tauri-apps/plugin-fs": "2.7.1",
|
"@tauri-apps/plugin-fs": "2.6.0",
|
||||||
"@tauri-apps/plugin-http": "2.8.3",
|
"@tauri-apps/plugin-http": "2.8.0",
|
||||||
"@tauri-apps/plugin-opener": "2.7.2",
|
"@tauri-apps/plugin-opener": "2.7.0",
|
||||||
"@tauri-apps/plugin-updater": "2.14.1",
|
"@tauri-apps/plugin-updater": "2.13.1",
|
||||||
"ahooks": "^3.10.0",
|
"ahooks": "^3.10.0",
|
||||||
"cidr-block": "^2.3.0",
|
"cidr-block": "^2.3.0",
|
||||||
"compare-versions": "^6.1.1",
|
"compare-versions": "^6.1.1",
|
||||||
@@ -79,11 +79,12 @@
|
|||||||
"react-dom": "19.3.1",
|
"react-dom": "19.3.1",
|
||||||
"react-error-boundary": "6.1.6",
|
"react-error-boundary": "6.1.6",
|
||||||
"react-hook-form": "^7.88.0",
|
"react-hook-form": "^7.88.0",
|
||||||
"react-i18next": "17.1.10",
|
"react-i18next": "17.0.19",
|
||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
"react-router": "^8.4.0",
|
"react-router": "^8.4.0",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"swr": "^2.5.1",
|
"swr": "^2.5.1",
|
||||||
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
|
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
|
||||||
"types-pac": "^1.0.3",
|
"types-pac": "^1.0.3",
|
||||||
@@ -94,15 +95,15 @@
|
|||||||
"@biomejs/biome": "^2.5.14",
|
"@biomejs/biome": "^2.5.14",
|
||||||
"@eslint-react/eslint-plugin": "^5.20.5",
|
"@eslint-react/eslint-plugin": "^5.20.5",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tauri-apps/cli": "2.12.4",
|
"@tauri-apps/cli": "2.12.1",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^26.0.0",
|
"@types/node": "^24.13.6",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.3.0",
|
||||||
"@types/validator": "^13.15.10",
|
"@types/validator": "^13.15.10",
|
||||||
"@vitejs/plugin-legacy": "^8.2.3",
|
"@vitejs/plugin-legacy": "^8.2.3",
|
||||||
"@vitejs/plugin-react": "^6.1.1",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"@wdio/tauri-service": "1.5.1",
|
"@wdio/tauri-service": "1.4.0",
|
||||||
"adm-zip": "^0.6.1",
|
"adm-zip": "^0.6.1",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"cli-color": "^2.0.4",
|
"cli-color": "^2.0.4",
|
||||||
@@ -117,23 +118,23 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.7",
|
"eslint-plugin-react-refresh": "^0.5.7",
|
||||||
"eslint-plugin-sukka": "^10.2.0",
|
"eslint-plugin-sukka": "^10.2.0",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
|
"glob": "^13.0.6",
|
||||||
"globals": "^17.12.0",
|
"globals": "^17.12.0",
|
||||||
"https-proxy-agent": "^9.1.0",
|
"https-proxy-agent": "^9.1.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jiti": "^2.7.0",
|
"jiti": "^2.7.0",
|
||||||
"knip": "6.44.1",
|
"knip": "6.41.0",
|
||||||
"lint-staged": "^17.5.1",
|
"lint-staged": "^17.5.1",
|
||||||
"sass": "^1.104.1",
|
"sass": "^1.104.1",
|
||||||
"tar": "^7.5.22",
|
"tar": "^7.5.22",
|
||||||
"terser": "^5.51.2",
|
"terser": "^5.51.2",
|
||||||
"tsrs": "0.20.0-ts7.1.0-beta",
|
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.70.0",
|
"typescript-eslint": "^8.70.0",
|
||||||
"vite": "^8.3.0",
|
"vite": "^8.3.0",
|
||||||
"vite-plugin-svgr": "^5.2.0",
|
"vite-plugin-svgr": "^5.2.0",
|
||||||
"vitest": "^5.0.1",
|
"vitest": "^5.0.1",
|
||||||
"webdriverio": "10.1.0",
|
"webdriverio": "10.0.2",
|
||||||
"ws": "8.22.1"
|
"ws": "8.22.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,js,mjs}": [
|
"*.{ts,tsx,js,mjs}": [
|
||||||
@@ -145,5 +146,5 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@12.18.0"
|
"packageManager": "pnpm@12.14.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+950
-627
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,3 @@ allowBuilds:
|
|||||||
unrs-resolver: true
|
unrs-resolver: true
|
||||||
minimumReleaseAgeExclude:
|
minimumReleaseAgeExclude:
|
||||||
- dayjs@1.11.21
|
- 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
|
|
||||||
|
|||||||
@@ -79,13 +79,6 @@
|
|||||||
"matchPackageNames": ["typescript"],
|
"matchPackageNames": ["typescript"],
|
||||||
"allowedVersions": "<7.0.0 || >=7.1.0"
|
"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",
|
"description": "Prevent project from supply chain attack",
|
||||||
"matchDatasources": ["npm"],
|
"matchDatasources": ["npm"],
|
||||||
@@ -113,27 +106,6 @@
|
|||||||
"ghcr.io/devcontainers/features/rust"
|
"ghcr.io/devcontainers/features/rust"
|
||||||
],
|
],
|
||||||
"groupName": "Rust toolchain"
|
"groupName": "Rust toolchain"
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Update the Specta contract toolchain together",
|
|
||||||
"matchManagers": ["cargo"],
|
|
||||||
"matchPackageNames": ["specta", "tauri-specta", "specta-typescript"],
|
|
||||||
"groupName": "specta",
|
|
||||||
"groupSlug": "specta",
|
|
||||||
"prCreation": "immediate",
|
|
||||||
"automerge": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Keep Specta aligned with the supported tauri-specta release",
|
|
||||||
"matchManagers": ["cargo"],
|
|
||||||
"matchPackageNames": ["specta", "tauri-specta"],
|
|
||||||
"allowedVersions": "/^2\\.0\\.0-rc\\.26$/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Keep the TypeScript exporter compatible with Specta rc.26",
|
|
||||||
"matchManagers": ["cargo"],
|
|
||||||
"matchPackageNames": ["specta-typescript"],
|
|
||||||
"allowedVersions": "/^0\\.0\\.13$/"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"customManagers": [
|
"customManagers": [
|
||||||
|
|||||||
@@ -41,10 +41,7 @@ fi
|
|||||||
|
|
||||||
tmpfile=$(mktemp)
|
tmpfile=$(mktemp)
|
||||||
{
|
{
|
||||||
node -e '
|
cat "$CHANGELOG"
|
||||||
const fs = require("node:fs");
|
|
||||||
process.stdout.write(fs.readFileSync(process.argv[1], "utf8").replace(/<!--[\s\S]*?-->/g, "").trimStart());
|
|
||||||
' "$CHANGELOG"
|
|
||||||
echo
|
echo
|
||||||
echo ""
|
echo ""
|
||||||
if [ -f "$HISTORY" ]; then
|
if [ -f "$HISTORY" ]; then
|
||||||
|
|||||||
@@ -1,803 +0,0 @@
|
|||||||
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)}
|
|
||||||
|
|
||||||
@clash-verge-rev/release
|
|
||||||
|
|
||||||
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 result = run('gh', [
|
|
||||||
'api',
|
|
||||||
'--paginate',
|
|
||||||
endpoint('releases?per_page=100'),
|
|
||||||
'--jq',
|
|
||||||
`.[] | select(.tag_name == ${JSON.stringify(tag)}) | {id, draft}`,
|
|
||||||
])
|
|
||||||
const release = result ? JSON.parse(result) : null
|
|
||||||
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()
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"pnpm-lock.yaml",
|
"pnpm-lock.yaml",
|
||||||
"pnpm-workspace.yaml",
|
"pnpm-workspace.yaml",
|
||||||
"scripts/contract-inputs.json",
|
"scripts/contract-inputs.json",
|
||||||
".github/workflows/contract-parity.yml",
|
".github/workflows/frontend-check.yml",
|
||||||
".husky/pre-push"
|
".husky/pre-push"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -434,13 +434,12 @@ for (let iteration = 0; iteration < repeat && !interrupted; iteration++) {
|
|||||||
devicePixelRatio,
|
devicePixelRatio,
|
||||||
])
|
])
|
||||||
run.viewport = { dom: viewport, driver: await browser.getWindowRect() }
|
run.viewport = { dom: viewport, driver: await browser.getWindowRect() }
|
||||||
const driverScale = run.viewport.driver.width / 1000
|
|
||||||
assert(
|
assert(
|
||||||
viewport[0] === 1000 &&
|
run.viewport.driver.width / viewport[2] === 1000 &&
|
||||||
|
run.viewport.driver.height / viewport[2] === 700 &&
|
||||||
|
viewport[0] === 1000 &&
|
||||||
viewport[1] > 0 &&
|
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)}`,
|
`unexpected window dimensions: ${JSON.stringify(run.viewport)}`,
|
||||||
)
|
)
|
||||||
assert(
|
assert(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import path from 'node:path'
|
|||||||
import zlib from 'node:zlib'
|
import zlib from 'node:zlib'
|
||||||
|
|
||||||
import AdmZip from 'adm-zip'
|
import AdmZip from 'adm-zip'
|
||||||
|
import { glob } from 'glob'
|
||||||
import { HttpsProxyAgent } from 'https-proxy-agent'
|
import { HttpsProxyAgent } from 'https-proxy-agent'
|
||||||
import { extract } from 'tar'
|
import { extract } from 'tar'
|
||||||
|
|
||||||
@@ -265,13 +266,13 @@ async function getLatestReleaseVersion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!META_MAP[`${platform}-${arch}`]) {
|
if (!META_MAP[`${platform}-${arch}`]) {
|
||||||
throw new Error(`mihomo unsupported platform "${platform}-${arch}"`)
|
throw new Error(`clash meta unsupported platform "${platform}-${arch}"`)
|
||||||
}
|
}
|
||||||
if (!META_ALPHA_MAP[`${platform}-${arch}`]) {
|
if (!META_ALPHA_MAP[`${platform}-${arch}`]) {
|
||||||
throw new Error(`mihomo alpha unsupported platform "${platform}-${arch}"`)
|
throw new Error(`clash meta alpha unsupported platform "${platform}-${arch}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function mihomoAlpha() {
|
function clashMetaAlpha() {
|
||||||
const name = META_ALPHA_MAP[`${platform}-${arch}`]
|
const name = META_ALPHA_MAP[`${platform}-${arch}`]
|
||||||
const isWin = platform === 'win32'
|
const isWin = platform === 'win32'
|
||||||
const urlExt = isWin ? 'zip' : 'gz'
|
const urlExt = isWin ? 'zip' : 'gz'
|
||||||
@@ -284,7 +285,7 @@ function mihomoAlpha() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mihomo() {
|
function clashMeta() {
|
||||||
const name = META_MAP[`${platform}-${arch}`]
|
const name = META_MAP[`${platform}-${arch}`]
|
||||||
const isWin = platform === 'win32'
|
const isWin = platform === 'win32'
|
||||||
const urlExt = isWin ? 'zip' : 'gz'
|
const urlExt = isWin ? 'zip' : 'gz'
|
||||||
@@ -518,7 +519,7 @@ const resolveServicePermission = async () => {
|
|||||||
let hasChanges = false
|
let hasChanges = false
|
||||||
|
|
||||||
for (const f of serviceExecutables) {
|
for (const f of serviceExecutables) {
|
||||||
const files = fs.globSync(path.join(SERVICE_DIR, f))
|
const files = glob.sync(path.join(SERVICE_DIR, f))
|
||||||
for (const filePath of files) {
|
for (const filePath of files) {
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
const currentHash = await calculateFileHash(filePath)
|
const currentHash = await calculateFileHash(filePath)
|
||||||
@@ -732,12 +733,13 @@ const tasks = [
|
|||||||
{
|
{
|
||||||
name: 'verge-mihomo-alpha',
|
name: 'verge-mihomo-alpha',
|
||||||
func: () =>
|
func: () =>
|
||||||
getLatestAlphaVersion().then(() => resolveSidecar(mihomoAlpha())),
|
getLatestAlphaVersion().then(() => resolveSidecar(clashMetaAlpha())),
|
||||||
retry: 5,
|
retry: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'verge-mihomo',
|
name: 'verge-mihomo',
|
||||||
func: () => getLatestReleaseVersion().then(() => resolveSidecar(mihomo())),
|
func: () =>
|
||||||
|
getLatestReleaseVersion().then(() => resolveSidecar(clashMeta())),
|
||||||
retry: 5,
|
retry: 5,
|
||||||
},
|
},
|
||||||
// After both sidecar tasks: it hashes what they downloaded.
|
// After both sidecar tasks: it hashes what they downloaded.
|
||||||
|
|||||||
+17
-50
@@ -32,9 +32,7 @@ function is_valid_ip() {
|
|||||||
|
|
||||||
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
|
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
|
||||||
function sc_value() {
|
function sc_value() {
|
||||||
local dictionary
|
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
|
||||||
dictionary=$(printf 'show %s\nquit\n' "$1" | scutil) || return 1
|
|
||||||
sed -n "s/^ *$2 : //p" <<<"$dictionary"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
|
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
|
||||||
@@ -52,62 +50,31 @@ function dns_of() {
|
|||||||
echo "$dns"
|
echo "$dns"
|
||||||
}
|
}
|
||||||
|
|
||||||
[ $# -lt 1 ] && echo "Usage: $0 <newline-separated DNS servers> [legacy state directory]" >&2 && exit 1
|
[ $# -lt 1 ] && echo "Usage: $0 <IP address>" && exit 1
|
||||||
dns_servers=()
|
! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1
|
||||||
while read -r server; do
|
|
||||||
! is_valid_ip "$server" && echo "$server is not a valid IP address." >&2 && exit 1
|
|
||||||
dns_servers+=("$server")
|
|
||||||
done <<<"$1"
|
|
||||||
|
|
||||||
# Backups are keyed by service ID, which survives renaming the service and renumbering its interface.
|
# Backups are keyed by service ID, which survives renaming the service and renumbering its interface.
|
||||||
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService) || exit 1
|
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
|
||||||
hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName) || exit 1
|
hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName)
|
||||||
[ -z "$hardware_port" ] && echo "No primary network service; waiting for the network." >&2 && exit 75
|
[ -z "$hardware_port" ] && echo "No primary network service." && exit 1
|
||||||
|
|
||||||
original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; }
|
original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; }
|
||||||
|
|
||||||
backup=".original_dns/$service_id"
|
backup=".original_dns/$service_id"
|
||||||
mkdir -p .original_dns || exit 1
|
mkdir -p .original_dns || exit 1
|
||||||
# Key old single-slot backups before import so a later network switch cannot reassign them.
|
# Older releases kept a single backup for whichever service was primary.
|
||||||
for state_dir in "$PWD" "${2:-$PWD}"; do
|
if [ -f .original_dns.txt ]; then
|
||||||
if [ -f "$state_dir/.original_dns.txt" ]; then
|
if [ -f "$backup" ]; then
|
||||||
mkdir -p "$state_dir/.original_dns" || exit 1
|
rm -f .original_dns.txt || exit 1
|
||||||
if [ -f "$state_dir/.original_dns/$service_id" ]; then
|
else
|
||||||
rm -f "$state_dir/.original_dns.txt" || exit 1
|
mv .original_dns.txt "$backup" || exit 1
|
||||||
else
|
|
||||||
mv "$state_dir/.original_dns.txt" "$state_dir/.original_dns/$service_id" || exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
done
|
|
||||||
if [ ! -f "$backup" ] && [ -n "$2" ] && [ -f "$2/$backup" ]; then
|
|
||||||
cp "$2/$backup" ".original_dns/.$service_id" || exit 1
|
|
||||||
mv ".original_dns/.$service_id" "$backup" || exit 1
|
|
||||||
fi
|
|
||||||
backup_header='# clash-verge-dns-managed'
|
|
||||||
function save_backup() {
|
|
||||||
printf '%s\n%s\n' "$backup_header" "$1" >".original_dns/.$service_id" &&
|
|
||||||
mv ".original_dns/.$service_id" "$backup"
|
|
||||||
}
|
|
||||||
|
|
||||||
saved_dns=
|
|
||||||
legacy_backup=false
|
|
||||||
if [ -f "$backup" ]; then
|
|
||||||
saved_dns=$(cat "$backup") || exit 1
|
|
||||||
case "$saved_dns" in
|
|
||||||
"$backup_header"$'\n'*) saved_dns=${saved_dns#*$'\n'} ;;
|
|
||||||
*) legacy_backup=true ;;
|
|
||||||
esac
|
|
||||||
fi
|
fi
|
||||||
|
# The placeholder is ours, not the user's: keep the backup taken before it was applied.
|
||||||
if [ "$original_dns" != "$1" ]; then
|
if [ "$original_dns" != "$1" ]; then
|
||||||
# Only an unmarked historical backup owns the old single-address placeholder.
|
echo "$original_dns" >"$backup" || exit 1
|
||||||
if [ "$original_dns" != "114.114.114.114" ] || [ "$legacy_backup" != true ]; then
|
networksetup -setdnsservers "$hardware_port" "$1"
|
||||||
save_backup "$original_dns" || exit 1
|
|
||||||
legacy_backup=false
|
|
||||||
fi
|
|
||||||
networksetup -setdnsservers "$hardware_port" "${dns_servers[@]}"
|
|
||||||
[ "$(dns_of "$hardware_port")" = "$1" ] || { echo "Failed to set DNS for $hardware_port." >&2; exit 1; }
|
[ "$(dns_of "$hardware_port")" = "$1" ] || { echo "Failed to set DNS for $hardware_port." >&2; exit 1; }
|
||||||
fi
|
elif [ ! -f "$backup" ]; then
|
||||||
# Keep legacy ownership until the new list is verified, including a retry after a failed marker write.
|
echo "empty" >"$backup" || exit 1
|
||||||
if [ "$legacy_backup" = true ]; then
|
|
||||||
save_backup "$saved_dns" || exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
+36
-65
@@ -1,11 +1,9 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
[ $# -lt 1 ] && echo "Usage: $0 <newline-separated DNS servers> [legacy state directory]" >&2 && exit 1
|
[ $# -lt 1 ] && echo "Usage: $0 <placeholder IP address>" && exit 1
|
||||||
|
|
||||||
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
|
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
|
||||||
function sc_value() {
|
function sc_value() {
|
||||||
local dictionary
|
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
|
||||||
dictionary=$(printf 'show %s\nquit\n' "$1" | scutil) || return 1
|
|
||||||
sed -n "s/^ *$2 : //p" <<<"$dictionary"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
|
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
|
||||||
@@ -23,69 +21,42 @@ function dns_of() {
|
|||||||
echo "$dns"
|
echo "$dns"
|
||||||
}
|
}
|
||||||
|
|
||||||
backup_header='# clash-verge-dns-managed'
|
result=0
|
||||||
completed_backups=()
|
# Older releases kept a single backup for whichever service was primary.
|
||||||
|
if [ -f .original_dns.txt ]; then
|
||||||
|
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
|
||||||
|
if [ -z "$service_id" ] || ! mkdir -p .original_dns; then
|
||||||
|
result=1
|
||||||
|
elif [ -f ".original_dns/$service_id" ]; then
|
||||||
|
rm -f .original_dns.txt || result=1
|
||||||
|
elif ! mv .original_dns.txt ".original_dns/$service_id"; then
|
||||||
|
result=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
function restore_dns() {
|
for backup in .original_dns/*; do
|
||||||
local result=0
|
[ -f "$backup" ] || continue
|
||||||
# Older releases kept a single backup for whichever service was primary.
|
hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName)
|
||||||
if [ -f .original_dns.txt ]; then
|
# Kept for a service that is gone: there is nothing to write it back to.
|
||||||
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
|
[ -n "$hardware_port" ] || continue
|
||||||
if [ -z "$service_id" ] || ! mkdir -p .original_dns; then
|
if ! current_dns=$(dns_of "$hardware_port"); then
|
||||||
result=1
|
echo "Cannot read DNS servers of $hardware_port." >&2
|
||||||
elif [ -f ".original_dns/$service_id" ]; then
|
result=1
|
||||||
rm -f .original_dns.txt || result=1
|
continue
|
||||||
elif ! mv .original_dns.txt ".original_dns/$service_id"; then
|
fi
|
||||||
|
# A service no longer on the placeholder was changed by someone else, and that change stands.
|
||||||
|
if [ "$current_dns" = "$1" ]; then
|
||||||
|
original_dns=$(cat "$backup") || { result=1; continue; }
|
||||||
|
# Older releases could back up the placeholder itself.
|
||||||
|
[ "$original_dns" = "$1" ] && original_dns=empty
|
||||||
|
networksetup -setdnsservers "$hardware_port" $original_dns
|
||||||
|
if [ "$(dns_of "$hardware_port")" != "$original_dns" ]; then
|
||||||
|
echo "Failed to restore DNS for $hardware_port." >&2
|
||||||
result=1
|
result=1
|
||||||
|
continue
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
rm -f "$backup" || result=1
|
||||||
for backup in .original_dns/*; do
|
done
|
||||||
[ -f "$backup" ] || continue
|
rmdir .original_dns 2>/dev/null
|
||||||
# Current backups take precedence even after their DNS has already been restored.
|
|
||||||
if [ "$PWD" != "$state_dir" ] && [ -f "$state_dir/$backup" ]; then
|
|
||||||
completed_backups+=("$PWD/$backup")
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName) || { result=1; continue; }
|
|
||||||
# Kept for a service that is gone: there is nothing to write it back to.
|
|
||||||
[ -n "$hardware_port" ] || continue
|
|
||||||
if ! current_dns=$(dns_of "$hardware_port"); then
|
|
||||||
echo "Cannot read DNS servers of $hardware_port." >&2
|
|
||||||
result=1
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
original_dns=$(cat "$backup") || { result=1; continue; }
|
|
||||||
legacy_backup=false
|
|
||||||
case "$original_dns" in
|
|
||||||
"$backup_header"$'\n'*) original_dns=${original_dns#*$'\n'} ;;
|
|
||||||
*) legacy_backup=true ;;
|
|
||||||
esac
|
|
||||||
# A service no longer on its recorded placeholder was changed by someone else.
|
|
||||||
if [ "$current_dns" = "$1" ] || { [ "$legacy_backup" = true ] && [ "$current_dns" = "114.114.114.114" ]; }; then
|
|
||||||
networksetup -setdnsservers "$hardware_port" $original_dns
|
|
||||||
if [ "$(dns_of "$hardware_port")" != "$original_dns" ]; then
|
|
||||||
echo "Failed to restore DNS for $hardware_port." >&2
|
|
||||||
result=1
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
completed_backups+=("$PWD/$backup")
|
|
||||||
done
|
|
||||||
return $result
|
|
||||||
}
|
|
||||||
|
|
||||||
result=0
|
|
||||||
state_dir=$PWD
|
|
||||||
restore_dns "$1" || result=1
|
|
||||||
if [ -n "$2" ] && [ "$2" != "$PWD" ]; then
|
|
||||||
{ cd "$2" && restore_dns "$1"; } || result=1
|
|
||||||
fi
|
|
||||||
# Remove superseded legacy backups first; a cleanup failure must leave current authority intact.
|
|
||||||
if [ "$result" -eq 0 ]; then
|
|
||||||
for ((index=${#completed_backups[@]}-1; index>=0; index--)); do
|
|
||||||
rm -f "${completed_backups[index]}" || { result=1; break; }
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
rmdir "$state_dir/.original_dns" "${2:-$state_dir}/.original_dns" 2>/dev/null
|
|
||||||
exit $result
|
exit $result
|
||||||
|
|||||||
+28
-22
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clash-verge"
|
name = "clash-verge"
|
||||||
version = "2.6.0"
|
version = "2.5.9"
|
||||||
description = "clash verge"
|
description = "clash verge"
|
||||||
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
@@ -29,7 +29,7 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
|
|||||||
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.7.4", features = [] }
|
tauri-build = { version = "2.7.1", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clash-verge-draft = { workspace = true }
|
clash-verge-draft = { workspace = true }
|
||||||
@@ -55,10 +55,11 @@ serde = { workspace = true, features = ["derive"] }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_yaml_ng = { workspace = true }
|
serde_yaml_ng = { workspace = true }
|
||||||
smartstring = { workspace = true, features = ["serde"] }
|
smartstring = { workspace = true, features = ["serde"] }
|
||||||
|
bitflags = { workspace = true }
|
||||||
socket2 = { workspace = true }
|
socket2 = { workspace = true }
|
||||||
warp = { version = "0.4.3", features = ["server"] }
|
warp = { version = "0.4.3", features = ["server"] }
|
||||||
open = "5.4.5"
|
open = "5.4.4"
|
||||||
rfd = { version = "0.17.2", default-features = false, features = ["common-controls-v6"] }
|
rfd = { version = "0.16.0", default-features = false, features = ["common-controls-v6"] }
|
||||||
dirs = "7.0.0"
|
dirs = "7.0.0"
|
||||||
dunce = "1.0.5"
|
dunce = "1.0.5"
|
||||||
nanoid = "0.5"
|
nanoid = "0.5"
|
||||||
@@ -66,46 +67,50 @@ chrono = { workspace = true }
|
|||||||
boa_engine = "0.22.0"
|
boa_engine = "0.22.0"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
|
regex = { workspace = true }
|
||||||
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch = "main", features = [
|
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch = "main", features = [
|
||||||
"guard",
|
"guard",
|
||||||
] }
|
] }
|
||||||
network-interface = { version = "2.0.5", features = ["serde"] }
|
network-interface = { version = "2.0.5", features = ["serde"] }
|
||||||
tauri-plugin-shell = "2.6.0"
|
tauri-plugin-shell = "2.4.0"
|
||||||
tauri-plugin-opener = "2.7.2"
|
tauri-plugin-opener = "2.7.0"
|
||||||
tauri-plugin-dialog = "2.8.4"
|
tauri-plugin-dialog = "2.8.1"
|
||||||
tauri-plugin-fs = "2.7.1"
|
tauri-plugin-fs = "2.6.0"
|
||||||
tauri-plugin-deep-link = "2.6.3"
|
tauri-plugin-process = "2.4.0"
|
||||||
|
tauri-plugin-deep-link = "2.6.1"
|
||||||
tauri-plugin-window-state = "2.5.0"
|
tauri-plugin-window-state = "2.5.0"
|
||||||
zip = "9.3.0"
|
zip = "9.0.4"
|
||||||
flate2 = "1.1.10"
|
flate2 = "1.1.10"
|
||||||
reqwest_dav = "0.3.3"
|
reqwest_dav = "0.3.3"
|
||||||
aes-gcm = { version = "0.11.1" }
|
aes-gcm = { version = "0.11.1" }
|
||||||
base64 = "0.23.1"
|
base64 = "0.23.1"
|
||||||
|
minisign-verify = "0.3.0"
|
||||||
sha2 = "0.11.1"
|
sha2 = "0.11.1"
|
||||||
getrandom = "0.4.3"
|
getrandom = "0.4.3"
|
||||||
futures = "0.3.34"
|
futures = "0.3.34"
|
||||||
gethostname = "1.1.0"
|
gethostname = "1.1.0"
|
||||||
scopeguard = "1.2.0"
|
scopeguard = "1.2.0"
|
||||||
tauri-plugin-notification = "2.8.0"
|
tauri-plugin-notification = "2.5.1"
|
||||||
|
tokio-stream = "0.1.19"
|
||||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||||
tauri-plugin-http = "2.8.3"
|
tauri-plugin-http = "2.8.0"
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tauri-plugin-devtools = { version = "2.2.3", optional = true }
|
tauri-plugin-devtools = { version = "2.2.2", optional = true }
|
||||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
|
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
|
||||||
async-trait = "0.1.92"
|
async-trait = "0.1.92"
|
||||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.7", version = "2.7.7", features = [
|
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",
|
"client",
|
||||||
] }
|
] }
|
||||||
arc-swap = "1.9.3"
|
arc-swap = "1.9.2"
|
||||||
tokio-rustls = "0.26.6"
|
tokio-rustls = "0.26.6"
|
||||||
rustls = { version = "0.23.46", features = ["ring"] }
|
rustls = { version = "0.23.46", features = ["ring"] }
|
||||||
webpki-roots = "1.0.11"
|
webpki-roots = "1.0.9"
|
||||||
dark-light = "3.0.0"
|
dark-light = "3.0.0"
|
||||||
bytes = "1.12.1"
|
bytes = "1.12.1"
|
||||||
tokio-util = { version = "0.7.20", features = ["time"] }
|
tokio-util = { version = "0.7.20", features = ["time"] }
|
||||||
specta = { version = "=2.0.0-rc.26", features = ["derive", "function", "serde_json"] }
|
specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_json"] }
|
||||||
tauri-specta = { version = "=2.0.0-rc.26", features = ["derive", "typescript"] }
|
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
|
||||||
specta-typescript = "=0.0.13"
|
specta-typescript = "=0.0.12"
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2.191"
|
libc = "0.2.191"
|
||||||
@@ -115,7 +120,7 @@ libc = "0.2.191"
|
|||||||
clash-verge-signal = { workspace = true }
|
clash-verge-signal = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
tauri-plugin-wdio-webdriver = { version = "=1.5.1", optional = true }
|
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
|
||||||
objc2 = "0.6.5"
|
objc2 = "0.6.5"
|
||||||
objc2-foundation = { version = "0.3.2", features = [
|
objc2-foundation = { version = "0.3.2", features = [
|
||||||
"NSString",
|
"NSString",
|
||||||
@@ -148,10 +153,11 @@ windows-sys.workspace = true
|
|||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-autostart = "2.7.0"
|
tauri-plugin-autostart = "2.7.0"
|
||||||
tauri-plugin-global-shortcut = "2.4.1"
|
tauri-plugin-global-shortcut = "2.4.0"
|
||||||
tauri-plugin-updater = "2.14.1"
|
tauri-plugin-updater = "2.13.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
criterion = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["test-util"] }
|
tokio = { workspace = true, features = ["test-util"] }
|
||||||
warp = { version = "0.4.3", features = ["test"] }
|
warp = { version = "0.4.3", features = ["test"] }
|
||||||
|
|
||||||
|
|||||||
+5
-10
@@ -1,9 +1,10 @@
|
|||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
|
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("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 or examples.
|
// Tauri's resource is linked to the app binary, but not to Rust unit-test harnesses.
|
||||||
// Give non-app targets their own manifest so tests and examples can resolve TaskDialogIndirect.
|
// Give mock-context builds their own manifest so tests 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 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");
|
let manifest_path = std::path::PathBuf::from(out_dir).join("windows-test-manifest.xml");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
@@ -24,14 +25,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
</assembly>
|
</assembly>
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
let manifest_display = manifest_path.display();
|
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
|
||||||
if std::env::var_os("CARGO_FEATURE_CLIPPY").is_some() {
|
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest_path.display());
|
||||||
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")]
|
#[cfg(feature = "clippy")]
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"global-shortcut:allow-register-all",
|
"global-shortcut:allow-register-all",
|
||||||
"global-shortcut:allow-unregister",
|
"global-shortcut:allow-unregister",
|
||||||
"global-shortcut:allow-unregister-all",
|
"global-shortcut:allow-unregister-all",
|
||||||
|
"process:allow-exit",
|
||||||
"clipboard-manager:allow-read-text",
|
"clipboard-manager:allow-read-text",
|
||||||
"clipboard-manager:allow-write-text",
|
"clipboard-manager:allow-write-text",
|
||||||
"dialog:default"
|
"dialog:default"
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ ManifestDPIAwareness PerMonitorV2
|
|||||||
!include "FileAssociation.nsh"
|
!include "FileAssociation.nsh"
|
||||||
!include "Win\COM.nsh"
|
!include "Win\COM.nsh"
|
||||||
!include "Win\Propkey.nsh"
|
!include "Win\Propkey.nsh"
|
||||||
!include "Win\RestartManager.nsh"
|
|
||||||
!include "WinVer.nsh"
|
!include "WinVer.nsh"
|
||||||
!include "LogicLib.nsh"
|
!include "LogicLib.nsh"
|
||||||
!include "StrFunc.nsh"
|
!include "StrFunc.nsh"
|
||||||
@@ -333,8 +332,6 @@ Function PageLeaveReinstall
|
|||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
reinst_uninstall:
|
reinst_uninstall:
|
||||||
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
|
||||||
|
|
||||||
HideWindow
|
HideWindow
|
||||||
ClearErrors
|
ClearErrors
|
||||||
|
|
||||||
@@ -371,10 +368,7 @@ Function PageLeaveReinstall
|
|||||||
MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)"
|
MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)"
|
||||||
Abort
|
Abort
|
||||||
${EndIf}
|
${EndIf}
|
||||||
Return
|
|
||||||
|
|
||||||
reinst_done:
|
reinst_done:
|
||||||
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
|
||||||
FunctionEnd
|
FunctionEnd
|
||||||
|
|
||||||
; 5. Choose install directory page
|
; 5. Choose install directory page
|
||||||
@@ -907,7 +901,8 @@ Section Install
|
|||||||
|
|
||||||
nsExec::Exec 'netsh int tcp res'
|
nsExec::Exec 'netsh int tcp res'
|
||||||
|
|
||||||
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
!include "Win\RestartManager.nsh"
|
||||||
|
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
||||||
!insertmacro CheckAllVergeProcesses
|
!insertmacro CheckAllVergeProcesses
|
||||||
|
|
||||||
; Ensure startup folders exist
|
; Ensure startup folders exist
|
||||||
@@ -1106,7 +1101,7 @@ Section Uninstall
|
|||||||
!insertmacro NSIS_HOOK_PREUNINSTALL
|
!insertmacro NSIS_HOOK_PREUNINSTALL
|
||||||
!endif
|
!endif
|
||||||
|
|
||||||
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
||||||
!insertmacro CheckAllVergeProcesses
|
!insertmacro CheckAllVergeProcesses
|
||||||
!insertmacro RemoveVergeService
|
!insertmacro RemoveVergeService
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
use super::CmdResult;
|
use super::CmdResult;
|
||||||
use crate::{
|
use crate::{
|
||||||
cmd::StringifyErr as _,
|
cmd::StringifyErr as _,
|
||||||
core::{
|
core::{SilentUpdater, updater::DownloadEvent},
|
||||||
SilentUpdater,
|
|
||||||
updater::{DownloadEvent, InstallUpdateOutcome},
|
|
||||||
},
|
|
||||||
feat,
|
feat,
|
||||||
utils::dirs,
|
utils::dirs,
|
||||||
};
|
};
|
||||||
@@ -64,7 +61,8 @@ pub async fn install_update(
|
|||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
version: std::string::String,
|
version: std::string::String,
|
||||||
on_event: Channel<DownloadEvent>,
|
on_event: Channel<DownloadEvent>,
|
||||||
) -> CmdResult<InstallUpdateOutcome> {
|
) -> CmdResult<bool> {
|
||||||
|
let version: String = version.into();
|
||||||
SilentUpdater::global()
|
SilentUpdater::global()
|
||||||
.install_update(&app_handle, &version, |event| {
|
.install_update(&app_handle, &version, |event| {
|
||||||
let _ = on_event.send(event);
|
let _ = on_event.send(event);
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
|
|||||||
logging!(info, Type::Config, "Config regenerated successfully");
|
logging!(info, Type::Config, "Config regenerated successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logging_error!(Type::Config, Config::sync_dns_override().await);
|
||||||
handle::Handle::refresh_clash();
|
handle::Handle::refresh_clash();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,10 @@ pub async fn delete_profile(index: std::string::String) -> CmdResult {
|
|||||||
drop(config_update_guard);
|
drop(config_update_guard);
|
||||||
drop(profile_write_guard);
|
drop(profile_write_guard);
|
||||||
|
|
||||||
|
if should_update {
|
||||||
|
logging_error!(Type::Config, Config::sync_dns_override().await);
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(e) = Tray::global().update_tooltip().await {
|
if let Err(e) = Tray::global().update_tooltip().await {
|
||||||
logging!(warn, Type::Cmd, "异步更新托盘提示失败: {e:#}");
|
logging!(warn, Type::Cmd, "异步更新托盘提示失败: {e:#}");
|
||||||
}
|
}
|
||||||
@@ -260,6 +264,7 @@ async fn handle_success(current_value: Option<&String>) -> CmdResult<ValidationO
|
|||||||
handle::Handle::notify_profile_changed(current);
|
handle::Handle::notify_profile_changed(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logging_error!(Type::Config, Config::sync_dns_override().await);
|
||||||
Ok(ValidationOutcome::Valid)
|
Ok(ValidationOutcome::Valid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ use crate::{
|
|||||||
core::{
|
core::{
|
||||||
handle::Handle,
|
handle::Handle,
|
||||||
proxy_view::{GroupScope, ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
|
proxy_view::{GroupScope, ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
|
||||||
|
tray::Tray,
|
||||||
},
|
},
|
||||||
|
process::AsyncHandler,
|
||||||
};
|
};
|
||||||
|
use clash_verge_logging::{Type, logging};
|
||||||
use serde_yaml_ng::Mapping;
|
use serde_yaml_ng::Mapping;
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::{
|
||||||
|
collections::{BTreeMap, HashSet},
|
||||||
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
/// Merges one selection pair into fresh backend state to avoid stale-list overwrites.
|
/// Merges one selection pair into fresh backend state to avoid stale-list overwrites.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -21,12 +27,15 @@ pub async fn record_selected_node(group_name: String, node: String) -> CmdResult
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn switch_proxy_node(group_name: String, proxy_name: String, unfix: bool) -> CmdResult<()> {
|
pub async fn forget_selected_node(group_name: String) -> CmdResult<()> {
|
||||||
crate::feat::switch_proxy_node(&group_name, &proxy_name, unfix)
|
crate::config::profiles::forget_selected_node(&group_name)
|
||||||
.await
|
.await
|
||||||
.stringify_err()
|
.stringify_err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static TRAY_SYNC_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||||
|
static TRAY_SYNC_PENDING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
|
fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
|
|
||||||
@@ -115,3 +124,48 @@ pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
|
|||||||
providers: providers.ok(),
|
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)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
AsyncHandler::spawn(move || async move {
|
||||||
|
run_tray_sync_loop().await;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
TRAY_SYNC_PENDING.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_tray_sync_loop() {
|
||||||
|
// Permanent watcher: keeps syncing until a pass finds no pending sync request.
|
||||||
|
loop {
|
||||||
|
match Tray::global().update_menu().await {
|
||||||
|
Ok(_) => {
|
||||||
|
logging!(debug, Type::Cmd, "Tray proxy selection synced successfully");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Cmd, "Failed to sync tray proxy selection: {e:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !TRAY_SYNC_PENDING.swap(false, Ordering::AcqRel) {
|
||||||
|
TRAY_SYNC_RUNNING.store(false, Ordering::Release);
|
||||||
|
|
||||||
|
if TRAY_SYNC_PENDING.swap(false, Ordering::AcqRel)
|
||||||
|
&& TRAY_SYNC_RUNNING
|
||||||
|
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
utils::dirs,
|
utils::dirs,
|
||||||
};
|
};
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging, logging_error};
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
@@ -168,6 +168,7 @@ async fn handle_saved_profile_file(
|
|||||||
);
|
);
|
||||||
match CoreManager::global().update_config_forced().await {
|
match CoreManager::global().update_config_forced().await {
|
||||||
Ok(outcome) if outcome.is_valid() => {
|
Ok(outcome) if outcome.is_valid() => {
|
||||||
|
logging_error!(Type::Config, Config::sync_dns_override().await);
|
||||||
handle::Handle::refresh_clash();
|
handle::Handle::refresh_clash();
|
||||||
Ok(ValidationOutcome::Valid)
|
Ok(ValidationOutcome::Valid)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,41 +184,84 @@ impl IClashTemp {
|
|||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
fn guard_port_value(config: &Mapping, key: &str, default: u16) -> u16 {
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn guard_redir_port(config: &Mapping) -> u16 {
|
||||||
let mut port = config
|
let mut port = config
|
||||||
.get(key)
|
.get("redir-port")
|
||||||
.and_then(|value| match value {
|
.and_then(|value| match value {
|
||||||
Value::String(val_str) => val_str.parse().ok(),
|
Value::String(val_str) => val_str.parse().ok(),
|
||||||
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
|
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.unwrap_or(default);
|
.unwrap_or(7895);
|
||||||
if port == 0 {
|
if port == 0 {
|
||||||
port = default;
|
port = 7895;
|
||||||
}
|
}
|
||||||
port
|
port
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
fn guard_redir_port(config: &Mapping) -> u16 {
|
|
||||||
Self::guard_port_value(config, "redir-port", network::ports::DEFAULT_REDIR)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn guard_tproxy_port(config: &Mapping) -> u16 {
|
fn guard_tproxy_port(config: &Mapping) -> u16 {
|
||||||
Self::guard_port_value(config, "tproxy-port", network::ports::DEFAULT_TPROXY)
|
let mut port = config
|
||||||
|
.get("tproxy-port")
|
||||||
|
.and_then(|value| match value {
|
||||||
|
Value::String(val_str) => val_str.parse().ok(),
|
||||||
|
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or(network::ports::DEFAULT_TPROXY);
|
||||||
|
if port == 0 {
|
||||||
|
port = network::ports::DEFAULT_TPROXY;
|
||||||
|
}
|
||||||
|
port
|
||||||
}
|
}
|
||||||
|
|
||||||
fn guard_mixed_port(config: &Mapping) -> u16 {
|
fn guard_mixed_port(config: &Mapping) -> u16 {
|
||||||
Self::guard_port_value(config, "mixed-port", network::ports::DEFAULT_MIXED)
|
let raw_value = config.get("mixed-port");
|
||||||
|
|
||||||
|
let mut port = raw_value
|
||||||
|
.and_then(|value| match value {
|
||||||
|
Value::String(val_str) => val_str.parse().ok(),
|
||||||
|
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or(network::ports::DEFAULT_MIXED);
|
||||||
|
|
||||||
|
if port == 0 {
|
||||||
|
port = network::ports::DEFAULT_MIXED;
|
||||||
|
}
|
||||||
|
|
||||||
|
port
|
||||||
}
|
}
|
||||||
|
|
||||||
fn guard_socks_port(config: &Mapping) -> u16 {
|
fn guard_socks_port(config: &Mapping) -> u16 {
|
||||||
Self::guard_port_value(config, "socks-port", network::ports::DEFAULT_SOCKS)
|
let mut port = config
|
||||||
|
.get("socks-port")
|
||||||
|
.and_then(|value| match value {
|
||||||
|
Value::String(val_str) => val_str.parse().ok(),
|
||||||
|
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or(7898);
|
||||||
|
if port == 0 {
|
||||||
|
port = 7898;
|
||||||
|
}
|
||||||
|
port
|
||||||
}
|
}
|
||||||
|
|
||||||
fn guard_port(config: &Mapping) -> u16 {
|
fn guard_port(config: &Mapping) -> u16 {
|
||||||
Self::guard_port_value(config, "port", network::ports::DEFAULT_HTTP)
|
let mut port = config
|
||||||
|
.get("port")
|
||||||
|
.and_then(|value| match value {
|
||||||
|
Value::String(val_str) => val_str.parse().ok(),
|
||||||
|
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or(7899);
|
||||||
|
if port == 0 {
|
||||||
|
port = 7899;
|
||||||
|
}
|
||||||
|
port
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn guard_server_ctrl(config: &Mapping) -> String {
|
pub(super) fn guard_server_ctrl(config: &Mapping) -> String {
|
||||||
|
|||||||
@@ -14,15 +14,6 @@ mod verge;
|
|||||||
pub(crate) use self::config::Config;
|
pub(crate) use self::config::Config;
|
||||||
pub use self::{clash::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*};
|
pub use self::{clash::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*};
|
||||||
|
|
||||||
macro_rules! patch_opt_field {
|
|
||||||
($lv:expr, $rv:expr, $key:tt) => {
|
|
||||||
if ($rv.$key).is_some() {
|
|
||||||
$lv.$key = $rv.$key.clone();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
pub(crate) use patch_opt_field;
|
|
||||||
|
|
||||||
pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) {
|
pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) {
|
||||||
return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
|
return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-71
@@ -1,5 +1,3 @@
|
|||||||
use super::patch_opt_field;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::profiles,
|
config::profiles,
|
||||||
utils::{
|
utils::{
|
||||||
@@ -133,18 +131,19 @@ impl PrfOption {
|
|||||||
match (one, other) {
|
match (one, other) {
|
||||||
(Some(a_ref), Some(b_ref)) => {
|
(Some(a_ref), Some(b_ref)) => {
|
||||||
let mut result = a_ref.clone();
|
let mut result = a_ref.clone();
|
||||||
patch_opt_field!(result, b_ref, user_agent);
|
result.user_agent = b_ref.user_agent.clone().or(result.user_agent);
|
||||||
patch_opt_field!(result, b_ref, with_proxy);
|
result.with_proxy = b_ref.with_proxy.or(result.with_proxy);
|
||||||
patch_opt_field!(result, b_ref, self_proxy);
|
result.self_proxy = b_ref.self_proxy.or(result.self_proxy);
|
||||||
patch_opt_field!(result, b_ref, danger_accept_invalid_certs);
|
result.danger_accept_invalid_certs =
|
||||||
patch_opt_field!(result, b_ref, allow_auto_update);
|
b_ref.danger_accept_invalid_certs.or(result.danger_accept_invalid_certs);
|
||||||
patch_opt_field!(result, b_ref, update_interval);
|
result.allow_auto_update = b_ref.allow_auto_update.or(result.allow_auto_update);
|
||||||
patch_opt_field!(result, b_ref, merge);
|
result.update_interval = b_ref.update_interval.or(result.update_interval);
|
||||||
patch_opt_field!(result, b_ref, script);
|
result.merge = b_ref.merge.clone().or(result.merge);
|
||||||
patch_opt_field!(result, b_ref, rules);
|
result.script = b_ref.script.clone().or(result.script);
|
||||||
patch_opt_field!(result, b_ref, proxies);
|
result.rules = b_ref.rules.clone().or(result.rules);
|
||||||
patch_opt_field!(result, b_ref, groups);
|
result.proxies = b_ref.proxies.clone().or(result.proxies);
|
||||||
patch_opt_field!(result, b_ref, timeout_seconds);
|
result.groups = b_ref.groups.clone().or(result.groups);
|
||||||
|
result.timeout_seconds = b_ref.timeout_seconds.or(result.timeout_seconds);
|
||||||
Some(result)
|
Some(result)
|
||||||
}
|
}
|
||||||
(Some(a_ref), None) => Some(a_ref.clone()),
|
(Some(a_ref), None) => Some(a_ref.clone()),
|
||||||
@@ -182,38 +181,6 @@ impl PrfItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn append_companion_item(item: &mut Self) -> Result<String> {
|
|
||||||
profiles::profiles_append_item_safe(item).await?;
|
|
||||||
item.uid
|
|
||||||
.clone()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("appended companion item has no uid"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_companion_items(
|
|
||||||
merge: &mut Option<String>,
|
|
||||||
script: &mut Option<String>,
|
|
||||||
rules: &mut Option<String>,
|
|
||||||
proxies: &mut Option<String>,
|
|
||||||
groups: &mut Option<String>,
|
|
||||||
) -> Result<()> {
|
|
||||||
if merge.is_none() {
|
|
||||||
*merge = Some(Self::append_companion_item(&mut Self::from_merge(None)).await?);
|
|
||||||
}
|
|
||||||
if script.is_none() {
|
|
||||||
*script = Some(Self::append_companion_item(&mut Self::from_script(None)).await?);
|
|
||||||
}
|
|
||||||
if rules.is_none() {
|
|
||||||
*rules = Some(Self::append_companion_item(&mut Self::from_rules()).await?);
|
|
||||||
}
|
|
||||||
if proxies.is_none() {
|
|
||||||
*proxies = Some(Self::append_companion_item(&mut Self::from_proxies()).await?);
|
|
||||||
}
|
|
||||||
if groups.is_none() {
|
|
||||||
*groups = Some(Self::append_companion_item(&mut Self::from_groups()).await?);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn from_local(
|
async fn from_local(
|
||||||
name: String,
|
name: String,
|
||||||
desc: String,
|
desc: String,
|
||||||
@@ -230,7 +197,31 @@ impl PrfItem {
|
|||||||
let mut proxies = opt_ref.and_then(|o| o.proxies.clone());
|
let mut proxies = opt_ref.and_then(|o| o.proxies.clone());
|
||||||
let mut groups = opt_ref.and_then(|o| o.groups.clone());
|
let mut groups = opt_ref.and_then(|o| o.groups.clone());
|
||||||
|
|
||||||
Self::ensure_companion_items(&mut merge, &mut script, &mut rules, &mut proxies, &mut groups).await?;
|
if merge.is_none() {
|
||||||
|
let merge_item = &mut Self::from_merge(None);
|
||||||
|
profiles::profiles_append_item_safe(merge_item).await?;
|
||||||
|
merge = merge_item.uid.clone();
|
||||||
|
}
|
||||||
|
if script.is_none() {
|
||||||
|
let script_item = &mut Self::from_script(None);
|
||||||
|
profiles::profiles_append_item_safe(script_item).await?;
|
||||||
|
script = script_item.uid.clone();
|
||||||
|
}
|
||||||
|
if rules.is_none() {
|
||||||
|
let rules_item = &mut Self::from_rules();
|
||||||
|
profiles::profiles_append_item_safe(rules_item).await?;
|
||||||
|
rules = rules_item.uid.clone();
|
||||||
|
}
|
||||||
|
if proxies.is_none() {
|
||||||
|
let proxies_item = &mut Self::from_proxies();
|
||||||
|
profiles::profiles_append_item_safe(proxies_item).await?;
|
||||||
|
proxies = proxies_item.uid.clone();
|
||||||
|
}
|
||||||
|
if groups.is_none() {
|
||||||
|
let groups_item = &mut Self::from_groups();
|
||||||
|
profiles::profiles_append_item_safe(groups_item).await?;
|
||||||
|
groups = groups_item.uid.clone();
|
||||||
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
uid: Some(uid),
|
uid: Some(uid),
|
||||||
itype: Some("local".into()),
|
itype: Some("local".into()),
|
||||||
@@ -384,7 +375,31 @@ impl PrfItem {
|
|||||||
bail!("profile does not contain `proxies` or `proxy-providers`");
|
bail!("profile does not contain `proxies` or `proxy-providers`");
|
||||||
}
|
}
|
||||||
|
|
||||||
Self::ensure_companion_items(&mut merge, &mut script, &mut rules, &mut proxies, &mut groups).await?;
|
if merge.is_none() {
|
||||||
|
let merge_item = &mut Self::from_merge(None);
|
||||||
|
profiles::profiles_append_item_safe(merge_item).await?;
|
||||||
|
merge = merge_item.uid.clone();
|
||||||
|
}
|
||||||
|
if script.is_none() {
|
||||||
|
let script_item = &mut Self::from_script(None);
|
||||||
|
profiles::profiles_append_item_safe(script_item).await?;
|
||||||
|
script = script_item.uid.clone();
|
||||||
|
}
|
||||||
|
if rules.is_none() {
|
||||||
|
let rules_item = &mut Self::from_rules();
|
||||||
|
profiles::profiles_append_item_safe(rules_item).await?;
|
||||||
|
rules = rules_item.uid.clone();
|
||||||
|
}
|
||||||
|
if proxies.is_none() {
|
||||||
|
let proxies_item = &mut Self::from_proxies();
|
||||||
|
profiles::profiles_append_item_safe(proxies_item).await?;
|
||||||
|
proxies = proxies_item.uid.clone();
|
||||||
|
}
|
||||||
|
if groups.is_none() {
|
||||||
|
let groups_item = &mut Self::from_groups();
|
||||||
|
profiles::profiles_append_item_safe(groups_item).await?;
|
||||||
|
groups = groups_item.uid.clone();
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
uid: Some(uid),
|
uid: Some(uid),
|
||||||
@@ -446,30 +461,46 @@ impl PrfItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_seq_item(prefix: &str, itype: &str, template: &str) -> Self {
|
fn from_rules() -> Self {
|
||||||
let uid = help::get_uid(prefix).into();
|
let uid = help::get_uid("r").into();
|
||||||
let file = format!("{uid}.yaml").into(); // yaml ext
|
let file = format!("{uid}.yaml").into(); // yaml ext
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
uid: Some(uid),
|
uid: Some(uid),
|
||||||
itype: Some(itype.into()),
|
itype: Some("rules".into()),
|
||||||
file: Some(file),
|
file: Some(file),
|
||||||
updated: Some(chrono::Local::now().timestamp() as usize),
|
updated: Some(chrono::Local::now().timestamp() as usize),
|
||||||
file_data: Some(template.into()),
|
file_data: Some(tmpl::ITEM_RULES.into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_rules() -> Self {
|
|
||||||
Self::new_seq_item("r", "rules", tmpl::ITEM_RULES)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_proxies() -> Self {
|
fn from_proxies() -> Self {
|
||||||
Self::new_seq_item("p", "proxies", tmpl::ITEM_PROXIES)
|
let uid = help::get_uid("p").into();
|
||||||
|
let file = format!("{uid}.yaml").into(); // yaml ext
|
||||||
|
|
||||||
|
Self {
|
||||||
|
uid: Some(uid),
|
||||||
|
itype: Some("proxies".into()),
|
||||||
|
file: Some(file),
|
||||||
|
updated: Some(chrono::Local::now().timestamp() as usize),
|
||||||
|
file_data: Some(tmpl::ITEM_PROXIES.into()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_groups() -> Self {
|
fn from_groups() -> Self {
|
||||||
Self::new_seq_item("g", "groups", tmpl::ITEM_GROUPS)
|
let uid = help::get_uid("g").into();
|
||||||
|
let file = format!("{uid}.yaml").into(); // yaml ext
|
||||||
|
|
||||||
|
Self {
|
||||||
|
uid: Some(uid),
|
||||||
|
itype: Some("groups".into()),
|
||||||
|
file: Some(file),
|
||||||
|
updated: Some(chrono::Local::now().timestamp() as usize),
|
||||||
|
file_data: Some(tmpl::ITEM_GROUPS.into()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn read_file(&self) -> Result<String> {
|
pub(crate) async fn read_file(&self) -> Result<String> {
|
||||||
@@ -485,20 +516,26 @@ impl PrfItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! current_field {
|
|
||||||
($name:ident, $field:ident) => {
|
|
||||||
pub(crate) fn $name(&self) -> Option<&String> {
|
|
||||||
self.option.as_ref().and_then(|o| o.$field.as_ref())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PrfItem {
|
impl PrfItem {
|
||||||
current_field!(current_merge, merge);
|
pub(crate) fn current_merge(&self) -> Option<&String> {
|
||||||
current_field!(current_script, script);
|
self.option.as_ref().and_then(|o| o.merge.as_ref())
|
||||||
current_field!(current_rules, rules);
|
}
|
||||||
current_field!(current_proxies, proxies);
|
|
||||||
current_field!(current_groups, groups);
|
pub(crate) fn current_script(&self) -> Option<&String> {
|
||||||
|
self.option.as_ref().and_then(|o| o.script.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn current_rules(&self) -> Option<&String> {
|
||||||
|
self.option.as_ref().and_then(|o| o.rules.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn current_proxies(&self) -> Option<&String> {
|
||||||
|
self.option.as_ref().and_then(|o| o.proxies.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn current_groups(&self) -> Option<&String> {
|
||||||
|
self.option.as_ref().and_then(|o| o.groups.as_ref())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn allow_auto_update_enabled(option: Option<&PrfOption>) -> bool {
|
fn allow_auto_update_enabled(option: Option<&PrfOption>) -> bool {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::{
|
use super::{
|
||||||
PrfOption, patch_opt_field,
|
PrfOption,
|
||||||
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
|
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
|
||||||
};
|
};
|
||||||
use crate::core::notify::{Refresh, announce};
|
use crate::core::notify::{Refresh, announce};
|
||||||
@@ -86,6 +86,14 @@ impl ProfileDeletePlan {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
macro_rules! patch {
|
||||||
|
($lv: expr, $rv: expr, $key: tt) => {
|
||||||
|
if ($rv.$key).is_some() {
|
||||||
|
$lv.$key = $rv.$key.to_owned();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
impl IProfiles {
|
impl IProfiles {
|
||||||
fn take_item_file_by_uid(items: &mut Vec<PrfItem>, target_uid: Option<&str>) -> Option<String> {
|
fn take_item_file_by_uid(items: &mut Vec<PrfItem>, target_uid: Option<&str>) -> Option<String> {
|
||||||
let index = items.iter().position(|item| item.uid.as_deref() == target_uid)?;
|
let index = items.iter().position(|item| item.uid.as_deref() == target_uid)?;
|
||||||
@@ -236,15 +244,15 @@ impl IProfiles {
|
|||||||
|
|
||||||
for each in items.iter_mut() {
|
for each in items.iter_mut() {
|
||||||
if each.uid.as_deref() == Some(uid) {
|
if each.uid.as_deref() == Some(uid) {
|
||||||
patch_opt_field!(each, item, itype);
|
patch!(each, item, itype);
|
||||||
patch_opt_field!(each, item, name);
|
patch!(each, item, name);
|
||||||
patch_opt_field!(each, item, desc);
|
patch!(each, item, desc);
|
||||||
patch_opt_field!(each, item, file);
|
patch!(each, item, file);
|
||||||
patch_opt_field!(each, item, url);
|
patch!(each, item, url);
|
||||||
patch_opt_field!(each, item, selected);
|
patch!(each, item, selected);
|
||||||
patch_opt_field!(each, item, extra);
|
patch!(each, item, extra);
|
||||||
patch_opt_field!(each, item, updated);
|
patch!(each, item, updated);
|
||||||
patch_opt_field!(each, item, option);
|
patch!(each, item, option);
|
||||||
|
|
||||||
self.items = Some(items);
|
self.items = Some(items);
|
||||||
return self.save_file().await;
|
return self.save_file().await;
|
||||||
@@ -412,21 +420,6 @@ pub(crate) async fn profiles_append_item_with_filedata_safe(item: &PrfItem, file
|
|||||||
profiles_append_item_safe(item).await
|
profiles_append_item_safe(item).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies `edit` to the committed profiles; commits only when the edit succeeds.
|
|
||||||
async fn with_profiles_edit<F, Fut>(edit: F) -> Result<()>
|
|
||||||
where
|
|
||||||
F: FnOnce(IProfiles) -> Fut + Send,
|
|
||||||
Fut: std::future::Future<Output = Result<IProfiles>> + Send,
|
|
||||||
{
|
|
||||||
Config::profiles()
|
|
||||||
.await
|
|
||||||
.with_data_modify(|profiles| async move {
|
|
||||||
let profiles = edit(profiles).await?;
|
|
||||||
Ok((profiles, ()))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> {
|
pub(crate) async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> {
|
||||||
let profiles = Config::profiles().await;
|
let profiles = Config::profiles().await;
|
||||||
profiles_append_item_to_safe(&profiles, item).await
|
profiles_append_item_to_safe(&profiles, item).await
|
||||||
@@ -442,19 +435,43 @@ pub(super) async fn profiles_append_item_to_safe(profiles: &Draft<IProfiles>, it
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn profiles_patch_item_safe(index: &str, item: &PrfItem) -> Result<()> {
|
pub(crate) async fn profiles_patch_item_safe(index: &str, item: &PrfItem) -> Result<()> {
|
||||||
with_profiles_edit(|mut profiles| async move { profiles.patch_item(index, item).await.map(|_| profiles) }).await
|
Config::profiles()
|
||||||
|
.await
|
||||||
|
.with_data_modify(|mut profiles| async move {
|
||||||
|
profiles.patch_item(index, item).await?;
|
||||||
|
Ok((profiles, ()))
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn profiles_reorder_safe(active_id: &str, over_id: &str) -> Result<()> {
|
pub(crate) async fn profiles_reorder_safe(active_id: &str, over_id: &str) -> Result<()> {
|
||||||
with_profiles_edit(|mut profiles| async move { profiles.reorder(active_id, over_id).await.map(|_| profiles) }).await
|
Config::profiles()
|
||||||
|
.await
|
||||||
|
.with_data_modify(|mut profiles| async move {
|
||||||
|
profiles.reorder(active_id, over_id).await?;
|
||||||
|
Ok((profiles, ()))
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn profiles_save_file_safe() -> Result<()> {
|
pub(crate) async fn profiles_save_file_safe() -> Result<()> {
|
||||||
with_profiles_edit(|profiles| async move { profiles.save_file().await.map(|_| profiles) }).await
|
Config::profiles()
|
||||||
|
.await
|
||||||
|
.with_data_modify(|profiles| async move {
|
||||||
|
profiles.save_file().await?;
|
||||||
|
Ok((profiles, ()))
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn profiles_update_item_safe(index: &str, item: &mut PrfItem) -> Result<()> {
|
pub(crate) async fn profiles_update_item_safe(index: &str, item: &mut PrfItem) -> Result<()> {
|
||||||
with_profiles_edit(|mut profiles| async move { profiles.update_item(index, item).await.map(|_| profiles) }).await
|
Config::profiles()
|
||||||
|
.await
|
||||||
|
.with_data_modify(|mut profiles| async move {
|
||||||
|
profiles.update_item(index, item).await?;
|
||||||
|
Ok((profiles, ()))
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
@@ -614,53 +631,6 @@ async fn select_node_with_timeout(group_name: &String, node: &String) -> Result<
|
|||||||
.with_context(|| format!("failed to select node [{node}] for group [{group_name}]"))
|
.with_context(|| format!("failed to select node [{node}] for group [{group_name}]"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// mihomo lists these first in GLOBAL, so an unpicked GLOBAL starts on DIRECT.
|
|
||||||
const GLOBAL_BUILTINS: [&str; 2] = ["DIRECT", "REJECT"];
|
|
||||||
|
|
||||||
/// GLOBAL's first visible group, else its first node, while GLOBAL is still on a built-in.
|
|
||||||
fn global_exit_target(proxies: &Proxies) -> Option<&str> {
|
|
||||||
let global = proxies.proxies.get("GLOBAL")?;
|
|
||||||
if !global.now.as_deref().is_some_and(|now| GLOBAL_BUILTINS.contains(&now)) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let members = global.all.as_deref()?;
|
|
||||||
let is_visible_group = |name: &str| {
|
|
||||||
proxies.proxies.get(name).is_some_and(|proxy| {
|
|
||||||
proxy.hidden != Some(true)
|
|
||||||
&& matches!(
|
|
||||||
proxy.proxy_type,
|
|
||||||
ProxyType::Selector | ProxyType::URLTest | ProxyType::Fallback | ProxyType::LoadBalance
|
|
||||||
)
|
|
||||||
})
|
|
||||||
};
|
|
||||||
members
|
|
||||||
.iter()
|
|
||||||
.find(|name| is_visible_group(name))
|
|
||||||
.or_else(|| members.iter().find(|name| !GLOBAL_BUILTINS.contains(&name.as_str())))
|
|
||||||
.map(|name| name.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Moves GLOBAL off a built-in unless the profile records any GLOBAL choice.
|
|
||||||
pub(crate) async fn leave_default_global_exit() -> Result<()> {
|
|
||||||
let profiles = Config::profiles().await.data_arc();
|
|
||||||
let has_record = profiles
|
|
||||||
.current
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|current| profiles.get_item(current).ok())
|
|
||||||
.and_then(|item| item.selected.as_deref())
|
|
||||||
.is_some_and(|selected| selected.iter().any(|item| item.name.as_deref() == Some("GLOBAL")));
|
|
||||||
if has_record {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let snapshot = fetch_proxies_with_timeout().await?;
|
|
||||||
let Some(target) = global_exit_target(&snapshot) else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
select_node_with_timeout(&String::from("GLOBAL"), &String::from(target)).await?;
|
|
||||||
logging!(info, Type::Config, "moved GLOBAL off its built-in default to {target}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remaining_activations(
|
fn remaining_activations(
|
||||||
activations: &[(String, String)],
|
activations: &[(String, String)],
|
||||||
completed: &HashMap<String, String>,
|
completed: &HashMap<String, String>,
|
||||||
@@ -743,21 +713,19 @@ where
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_item_mut(profiles: &mut IProfiles) -> Option<&mut PrfItem> {
|
|
||||||
let current = profiles.current.clone()?;
|
|
||||||
profiles
|
|
||||||
.items
|
|
||||||
.as_mut()?
|
|
||||||
.iter_mut()
|
|
||||||
.find(|item| item.uid.as_ref() == Some(¤t))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a backend-made selection so the next core start restores it.
|
/// Records a backend-made selection so the next core start restores it.
|
||||||
pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
|
pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
|
||||||
let group_name = String::from(group_name);
|
let group_name = String::from(group_name);
|
||||||
let node = String::from(node);
|
let node = String::from(node);
|
||||||
persist_selected_update(true, move |mut profiles| async move {
|
persist_selected_update(true, move |mut profiles| async move {
|
||||||
let Some(item) = current_item_mut(&mut profiles) else {
|
let Some(current) = profiles.current.clone() else {
|
||||||
|
return Ok((profiles, false));
|
||||||
|
};
|
||||||
|
let Some(item) = profiles
|
||||||
|
.items
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t)))
|
||||||
|
else {
|
||||||
return Ok((profiles, false));
|
return Ok((profiles, false));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -792,7 +760,14 @@ fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bo
|
|||||||
|
|
||||||
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
|
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
|
||||||
persist_selected_update(true, move |mut profiles| async move {
|
persist_selected_update(true, move |mut profiles| async move {
|
||||||
let Some(item) = current_item_mut(&mut profiles) else {
|
let Some(current) = profiles.current.clone() else {
|
||||||
|
return Ok((profiles, false));
|
||||||
|
};
|
||||||
|
let Some(item) = profiles
|
||||||
|
.items
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t)))
|
||||||
|
else {
|
||||||
return Ok((profiles, false));
|
return Ok((profiles, false));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1066,12 +1041,6 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
if Config::clash().await.latest_arc().get_mode().as_deref() == Some("global")
|
|
||||||
&& let Err(err) = leave_default_global_exit().await
|
|
||||||
{
|
|
||||||
logging!(warn, Type::Config, "failed to move GLOBAL off its default: {err:#}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if selected.is_empty() {
|
if selected.is_empty() {
|
||||||
if is_activation_current(generation) {
|
if is_activation_current(generation) {
|
||||||
announce(Refresh::Clash);
|
announce(Refresh::Clash);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{DEFAULT_PAC, deserialize_encrypted, patch_opt_field, serialize_encrypted},
|
config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted},
|
||||||
constants::network,
|
constants::network,
|
||||||
utils::{dirs, help},
|
utils::{dirs, help},
|
||||||
};
|
};
|
||||||
@@ -39,7 +39,7 @@ pub struct IVerge {
|
|||||||
|
|
||||||
pub traffic_graph: Option<bool>,
|
pub traffic_graph: Option<bool>,
|
||||||
|
|
||||||
/// show memory info (only for Mihomo)
|
/// show memory info (only for Clash Meta)
|
||||||
pub enable_memory_usage: Option<bool>,
|
pub enable_memory_usage: Option<bool>,
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -432,90 +432,98 @@ impl IVerge {
|
|||||||
|
|
||||||
#[allow(clippy::cognitive_complexity)]
|
#[allow(clippy::cognitive_complexity)]
|
||||||
pub fn patch_config(&mut self, patch: &Self) {
|
pub fn patch_config(&mut self, patch: &Self) {
|
||||||
patch_opt_field!(self, patch, app_log_level);
|
macro_rules! patch {
|
||||||
patch_opt_field!(self, patch, app_log_max_size);
|
($key: tt) => {
|
||||||
patch_opt_field!(self, patch, app_log_max_count);
|
if patch.$key.is_some() {
|
||||||
|
self.$key = patch.$key.clone();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
patch_opt_field!(self, patch, language);
|
patch!(app_log_level);
|
||||||
patch_opt_field!(self, patch, theme_mode);
|
patch!(app_log_max_size);
|
||||||
patch_opt_field!(self, patch, tray_event);
|
patch!(app_log_max_count);
|
||||||
patch_opt_field!(self, patch, env_type);
|
|
||||||
patch_opt_field!(self, patch, start_page);
|
patch!(language);
|
||||||
patch_opt_field!(self, patch, startup_script);
|
patch!(theme_mode);
|
||||||
patch_opt_field!(self, patch, traffic_graph);
|
patch!(tray_event);
|
||||||
patch_opt_field!(self, patch, enable_memory_usage);
|
patch!(env_type);
|
||||||
patch_opt_field!(self, patch, enable_group_icon);
|
patch!(start_page);
|
||||||
patch_opt_field!(self, patch, pause_render_traffic_stats_on_blur);
|
patch!(startup_script);
|
||||||
|
patch!(traffic_graph);
|
||||||
|
patch!(enable_memory_usage);
|
||||||
|
patch!(enable_group_icon);
|
||||||
|
patch!(pause_render_traffic_stats_on_blur);
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
patch_opt_field!(self, patch, tray_icon);
|
patch!(tray_icon);
|
||||||
patch_opt_field!(self, patch, menu_icon);
|
patch!(menu_icon);
|
||||||
patch_opt_field!(self, patch, menu_order);
|
patch!(menu_order);
|
||||||
patch_opt_field!(self, patch, proxy_group_tools_position);
|
patch!(proxy_group_tools_position);
|
||||||
patch_opt_field!(self, patch, notice_position);
|
patch!(notice_position);
|
||||||
patch_opt_field!(self, patch, collapse_navbar);
|
patch!(collapse_navbar);
|
||||||
patch_opt_field!(self, patch, common_tray_icon);
|
patch!(common_tray_icon);
|
||||||
patch_opt_field!(self, patch, sysproxy_tray_icon);
|
patch!(sysproxy_tray_icon);
|
||||||
patch_opt_field!(self, patch, tun_tray_icon);
|
patch!(tun_tray_icon);
|
||||||
|
|
||||||
patch_opt_field!(self, patch, enable_tun_mode);
|
patch!(enable_tun_mode);
|
||||||
patch_opt_field!(self, patch, enable_auto_launch);
|
patch!(enable_auto_launch);
|
||||||
patch_opt_field!(self, patch, enable_silent_start);
|
patch!(enable_silent_start);
|
||||||
patch_opt_field!(self, patch, enable_hover_jump_navigator);
|
patch!(enable_hover_jump_navigator);
|
||||||
patch_opt_field!(self, patch, hover_jump_navigator_delay);
|
patch!(hover_jump_navigator_delay);
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
patch_opt_field!(self, patch, verge_redir_port);
|
patch!(verge_redir_port);
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
patch_opt_field!(self, patch, verge_redir_enabled);
|
patch!(verge_redir_enabled);
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
patch_opt_field!(self, patch, verge_tproxy_port);
|
patch!(verge_tproxy_port);
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
patch_opt_field!(self, patch, verge_tproxy_enabled);
|
patch!(verge_tproxy_enabled);
|
||||||
patch_opt_field!(self, patch, verge_mixed_port);
|
patch!(verge_mixed_port);
|
||||||
patch_opt_field!(self, patch, verge_socks_port);
|
patch!(verge_socks_port);
|
||||||
patch_opt_field!(self, patch, verge_socks_enabled);
|
patch!(verge_socks_enabled);
|
||||||
patch_opt_field!(self, patch, verge_port);
|
patch!(verge_port);
|
||||||
patch_opt_field!(self, patch, verge_http_enabled);
|
patch!(verge_http_enabled);
|
||||||
patch_opt_field!(self, patch, enable_system_proxy);
|
patch!(enable_system_proxy);
|
||||||
patch_opt_field!(self, patch, enable_proxy_guard);
|
patch!(enable_proxy_guard);
|
||||||
patch_opt_field!(self, patch, enable_bypass_check);
|
patch!(enable_bypass_check);
|
||||||
patch_opt_field!(self, patch, use_default_bypass);
|
patch!(use_default_bypass);
|
||||||
patch_opt_field!(self, patch, system_proxy_bypass);
|
patch!(system_proxy_bypass);
|
||||||
patch_opt_field!(self, patch, proxy_guard_duration);
|
patch!(proxy_guard_duration);
|
||||||
patch_opt_field!(self, patch, proxy_auto_config);
|
patch!(proxy_auto_config);
|
||||||
patch_opt_field!(self, patch, pac_file_content);
|
patch!(pac_file_content);
|
||||||
patch_opt_field!(self, patch, proxy_host);
|
patch!(proxy_host);
|
||||||
patch_opt_field!(self, patch, theme_setting);
|
patch!(theme_setting);
|
||||||
patch_opt_field!(self, patch, web_ui_list);
|
patch!(web_ui_list);
|
||||||
patch_opt_field!(self, patch, clash_core);
|
patch!(clash_core);
|
||||||
patch_opt_field!(self, patch, hotkeys);
|
patch!(hotkeys);
|
||||||
patch_opt_field!(self, patch, enable_global_hotkey);
|
patch!(enable_global_hotkey);
|
||||||
|
|
||||||
patch_opt_field!(self, patch, auto_close_connection);
|
patch!(auto_close_connection);
|
||||||
patch_opt_field!(self, patch, auto_check_update);
|
patch!(auto_check_update);
|
||||||
patch_opt_field!(self, patch, default_latency_test);
|
patch!(default_latency_test);
|
||||||
patch_opt_field!(self, patch, default_latency_timeout);
|
patch!(default_latency_timeout);
|
||||||
patch_opt_field!(self, patch, enable_auto_delay_detection);
|
patch!(enable_auto_delay_detection);
|
||||||
patch_opt_field!(self, patch, auto_delay_detection_interval_minutes);
|
patch!(auto_delay_detection_interval_minutes);
|
||||||
patch_opt_field!(self, patch, enable_builtin_enhanced);
|
patch!(enable_builtin_enhanced);
|
||||||
patch_opt_field!(self, patch, proxy_layout_column);
|
patch!(proxy_layout_column);
|
||||||
patch_opt_field!(self, patch, test_list);
|
patch!(test_list);
|
||||||
patch_opt_field!(self, patch, auto_log_clean);
|
patch!(auto_log_clean);
|
||||||
patch_opt_field!(self, patch, enable_auto_backup_schedule);
|
patch!(enable_auto_backup_schedule);
|
||||||
patch_opt_field!(self, patch, auto_backup_interval_hours);
|
patch!(auto_backup_interval_hours);
|
||||||
patch_opt_field!(self, patch, auto_backup_on_change);
|
patch!(auto_backup_on_change);
|
||||||
|
|
||||||
patch_opt_field!(self, patch, webdav_url);
|
patch!(webdav_url);
|
||||||
patch_opt_field!(self, patch, webdav_username);
|
patch!(webdav_username);
|
||||||
patch_opt_field!(self, patch, webdav_password);
|
patch!(webdav_password);
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
patch_opt_field!(self, patch, enable_tray_speed);
|
patch!(enable_tray_speed);
|
||||||
patch_opt_field!(self, patch, tray_proxy_groups_display_mode);
|
patch!(tray_proxy_groups_display_mode);
|
||||||
patch_opt_field!(self, patch, tray_inline_outbound_modes);
|
patch!(tray_inline_outbound_modes);
|
||||||
patch_opt_field!(self, patch, enable_auto_light_weight_mode);
|
patch!(enable_auto_light_weight_mode);
|
||||||
patch_opt_field!(self, patch, auto_light_weight_minutes);
|
patch!(auto_light_weight_minutes);
|
||||||
patch_opt_field!(self, patch, enable_dns_settings);
|
patch!(enable_dns_settings);
|
||||||
patch_opt_field!(self, patch, home_cards);
|
patch!(home_cards);
|
||||||
patch_opt_field!(self, patch, enable_external_controller);
|
patch!(enable_external_controller);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_log_level(&self) -> LevelFilter {
|
pub fn get_log_level(&self) -> LevelFilter {
|
||||||
|
|||||||
@@ -14,13 +14,6 @@ pub mod network {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod sysproxy {
|
|
||||||
/// Single source of the macOS default bypass list; both proxy backends compose from it.
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub const MACOS_DEFAULT_BYPASS: &str =
|
|
||||||
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod timing {
|
pub mod timing {
|
||||||
use super::Duration;
|
use super::Duration;
|
||||||
|
|
||||||
@@ -82,7 +75,6 @@ pub mod tun {
|
|||||||
"auto-redirect",
|
"auto-redirect",
|
||||||
"auto-detect-interface",
|
"auto-detect-interface",
|
||||||
"dns-hijack",
|
"dns-hijack",
|
||||||
"dns-mode",
|
|
||||||
"strict-route",
|
"strict-route",
|
||||||
"mtu",
|
"mtu",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ use crate::{
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
|
use parking_lot::RwLock;
|
||||||
use std::{
|
use std::{
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{
|
sync::{
|
||||||
OnceLock,
|
Arc, OnceLock,
|
||||||
atomic::{AtomicBool, AtomicI64, Ordering},
|
atomic::{AtomicBool, AtomicI64, Ordering},
|
||||||
},
|
},
|
||||||
time::{Duration, UNIX_EPOCH},
|
time::{Duration, UNIX_EPOCH},
|
||||||
@@ -81,9 +82,8 @@ impl Default for AutoBackupSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct AutoBackupManager {
|
pub struct AutoBackupManager {
|
||||||
|
settings: Arc<RwLock<AutoBackupSettings>>,
|
||||||
settings_tx: watch::Sender<AutoBackupSettings>,
|
settings_tx: watch::Sender<AutoBackupSettings>,
|
||||||
// The watch channel is the single settings store; no duplicate copies.
|
|
||||||
settings_rx: watch::Receiver<AutoBackupSettings>,
|
|
||||||
runner_started: AtomicBool,
|
runner_started: AtomicBool,
|
||||||
exec_lock: Mutex<()>,
|
exec_lock: Mutex<()>,
|
||||||
last_backup: AtomicI64,
|
last_backup: AtomicI64,
|
||||||
@@ -93,10 +93,10 @@ impl AutoBackupManager {
|
|||||||
pub fn global() -> &'static Self {
|
pub fn global() -> &'static Self {
|
||||||
static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new();
|
static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new();
|
||||||
INSTANCE.get_or_init(|| {
|
INSTANCE.get_or_init(|| {
|
||||||
let (settings_tx, settings_rx) = watch::channel(AutoBackupSettings::default());
|
let (tx, _rx) = watch::channel(AutoBackupSettings::default());
|
||||||
Self {
|
Self {
|
||||||
settings_tx,
|
settings: Arc::new(RwLock::new(AutoBackupSettings::default())),
|
||||||
settings_rx,
|
settings_tx: tx,
|
||||||
runner_started: AtomicBool::new(false),
|
runner_started: AtomicBool::new(false),
|
||||||
exec_lock: Mutex::new(()),
|
exec_lock: Mutex::new(()),
|
||||||
last_backup: AtomicI64::new(0),
|
last_backup: AtomicI64::new(0),
|
||||||
@@ -106,14 +106,20 @@ impl AutoBackupManager {
|
|||||||
|
|
||||||
pub async fn init(&self) -> Result<()> {
|
pub async fn init(&self) -> Result<()> {
|
||||||
let settings = Self::load_settings().await;
|
let settings = Self::load_settings().await;
|
||||||
let _ = self.settings_tx.send_replace(settings);
|
{
|
||||||
|
*self.settings.write() = settings;
|
||||||
|
}
|
||||||
|
let _ = self.settings_tx.send(settings);
|
||||||
self.maybe_start_runner(settings);
|
self.maybe_start_runner(settings);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn refresh_settings(&self) -> Result<()> {
|
pub async fn refresh_settings(&self) -> Result<()> {
|
||||||
let settings = Self::load_settings().await;
|
let settings = Self::load_settings().await;
|
||||||
let _ = self.settings_tx.send_replace(settings);
|
{
|
||||||
|
*self.settings.write() = settings;
|
||||||
|
}
|
||||||
|
let _ = self.settings_tx.send(settings);
|
||||||
self.maybe_start_runner(settings);
|
self.maybe_start_runner(settings);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -187,7 +193,7 @@ impl AutoBackupManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_trigger(&self, trigger: AutoBackupTrigger) -> Result<()> {
|
async fn execute_trigger(&self, trigger: AutoBackupTrigger) -> Result<()> {
|
||||||
let snapshot = *self.settings_rx.borrow();
|
let snapshot = *self.settings.read();
|
||||||
|
|
||||||
if trigger.is_schedule() && !snapshot.schedule_enabled {
|
if trigger.is_schedule() && !snapshot.schedule_enabled {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
@@ -466,3 +466,12 @@ impl Hotkey {
|
|||||||
(del_list, add_list)
|
(del_list, add_list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for Hotkey {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let app_handle = handle::Handle::app_handle();
|
||||||
|
if let Err(e) = app_handle.global_shortcut().unregister_all() {
|
||||||
|
logging!(error, Type::Hotkey, "Error unregistering all hotkeys: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,47 +40,8 @@ impl LightweightState {
|
|||||||
|
|
||||||
static LIGHTWEIGHT_STATE: AtomicU8 = AtomicU8::new(LightweightState::Normal as u8);
|
static LIGHTWEIGHT_STATE: AtomicU8 = AtomicU8::new(LightweightState::Normal as u8);
|
||||||
|
|
||||||
struct WindowListener {
|
static WINDOW_CLOSE_HANDLER_ID: AtomicU32 = AtomicU32::new(0);
|
||||||
handler_id: AtomicU32,
|
static WEBVIEW_FOCUS_HANDLER_ID: AtomicU32 = AtomicU32::new(0);
|
||||||
event: &'static str,
|
|
||||||
label: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WindowListener {
|
|
||||||
fn setup(&self, on_event: impl Fn() + Send + 'static) {
|
|
||||||
if let Some(window) = WindowManager::get_main_window() {
|
|
||||||
let previous_handler_id = self.handler_id.swap(0, Ordering::AcqRel);
|
|
||||||
if previous_handler_id != 0 {
|
|
||||||
window.unlisten(previous_handler_id);
|
|
||||||
logging!(debug, Type::Lightweight, "覆盖旧的{}监听", self.label);
|
|
||||||
}
|
|
||||||
let handler_id = window.listen(self.event, move |_event| on_event());
|
|
||||||
self.handler_id.store(handler_id, Ordering::Release);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cancel(&self) {
|
|
||||||
let id = self.handler_id.swap(0, Ordering::AcqRel);
|
|
||||||
if id != 0 {
|
|
||||||
if let Some(window) = WindowManager::get_main_window() {
|
|
||||||
window.unlisten(id);
|
|
||||||
}
|
|
||||||
logging!(debug, Type::Lightweight, "取消了{}监听", self.label);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static WINDOW_CLOSE_LISTENER: WindowListener = WindowListener {
|
|
||||||
handler_id: AtomicU32::new(0),
|
|
||||||
event: "tauri://close-requested",
|
|
||||||
label: "窗口关闭",
|
|
||||||
};
|
|
||||||
|
|
||||||
static WEBVIEW_FOCUS_LISTENER: WindowListener = WindowListener {
|
|
||||||
handler_id: AtomicU32::new(0),
|
|
||||||
event: "tauri://focus",
|
|
||||||
label: "窗口焦点",
|
|
||||||
};
|
|
||||||
|
|
||||||
static CANCEL_TX: Mutex<Option<oneshot::Sender<()>>> = Mutex::new(None);
|
static CANCEL_TX: Mutex<Option<oneshot::Sender<()>>> = Mutex::new(None);
|
||||||
|
|
||||||
@@ -193,27 +154,55 @@ pub async fn add_light_weight_timer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn setup_window_close_listener() {
|
fn setup_window_close_listener() {
|
||||||
WINDOW_CLOSE_LISTENER.setup(|| {
|
if let Some(window) = WindowManager::get_main_window() {
|
||||||
std::mem::drop(AsyncHandler::spawn(|| async {
|
let previous_handler_id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel);
|
||||||
setup_light_weight_timer().await;
|
if previous_handler_id != 0 {
|
||||||
}));
|
window.unlisten(previous_handler_id);
|
||||||
logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时");
|
logging!(debug, Type::Lightweight, "覆盖旧的窗口关闭监听");
|
||||||
});
|
}
|
||||||
|
let handler_id = window.listen("tauri://close-requested", move |_event| {
|
||||||
|
std::mem::drop(AsyncHandler::spawn(|| async {
|
||||||
|
setup_light_weight_timer().await;
|
||||||
|
}));
|
||||||
|
logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时");
|
||||||
|
});
|
||||||
|
WINDOW_CLOSE_HANDLER_ID.store(handler_id, Ordering::Release);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cancel_window_close_listener() {
|
fn cancel_window_close_listener() {
|
||||||
WINDOW_CLOSE_LISTENER.cancel();
|
let id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel);
|
||||||
|
if id != 0 {
|
||||||
|
if let Some(window) = WindowManager::get_main_window() {
|
||||||
|
window.unlisten(id);
|
||||||
|
}
|
||||||
|
logging!(debug, Type::Lightweight, "取消了窗口关闭监听");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup_webview_focus_listener() {
|
fn setup_webview_focus_listener() {
|
||||||
WEBVIEW_FOCUS_LISTENER.setup(|| {
|
if let Some(window) = WindowManager::get_main_window() {
|
||||||
cancel_light_weight_timer();
|
let previous_handler_id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel);
|
||||||
logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时");
|
if previous_handler_id != 0 {
|
||||||
});
|
window.unlisten(previous_handler_id);
|
||||||
|
logging!(debug, Type::Lightweight, "覆盖旧的窗口焦点监听");
|
||||||
|
}
|
||||||
|
let handler_id = window.listen("tauri://focus", move |_event| {
|
||||||
|
cancel_light_weight_timer();
|
||||||
|
logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时");
|
||||||
|
});
|
||||||
|
WEBVIEW_FOCUS_HANDLER_ID.store(handler_id, Ordering::Release);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cancel_webview_focus_listener() {
|
fn cancel_webview_focus_listener() {
|
||||||
WEBVIEW_FOCUS_LISTENER.cancel();
|
let id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel);
|
||||||
|
if id != 0 {
|
||||||
|
if let Some(window) = WindowManager::get_main_window() {
|
||||||
|
window.unlisten(id);
|
||||||
|
}
|
||||||
|
logging!(debug, Type::Lightweight, "取消了窗口焦点监听");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn setup_light_weight_timer() {
|
async fn setup_light_weight_timer() {
|
||||||
|
|||||||
@@ -435,10 +435,6 @@ impl CoreManager {
|
|||||||
{
|
{
|
||||||
anyhow::bail!("Sidecar did not become ready");
|
anyhow::bail!("Sidecar did not become ready");
|
||||||
}
|
}
|
||||||
// A Sidecar that was already running never loaded this generation.
|
|
||||||
if matches!(*mode, RunningMode::NotRunning) {
|
|
||||||
Config::runtime().await.apply();
|
|
||||||
}
|
|
||||||
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
|
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
|
||||||
self.apply_proxy_after_start().await
|
self.apply_proxy_after_start().await
|
||||||
}
|
}
|
||||||
@@ -496,7 +492,6 @@ impl CoreManager {
|
|||||||
{
|
{
|
||||||
anyhow::bail!("Sidecar did not become ready after service uninstall");
|
anyhow::bail!("Sidecar did not become ready after service uninstall");
|
||||||
}
|
}
|
||||||
Config::runtime().await.apply();
|
|
||||||
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
|
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
|
||||||
self.apply_proxy_after_start().await
|
self.apply_proxy_after_start().await
|
||||||
},
|
},
|
||||||
@@ -559,13 +554,12 @@ impl CoreManager {
|
|||||||
})?;
|
})?;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
crate::utils::resolve::dns::sync_public_dns().await;
|
crate::utils::resolve::dns::sync_public_dns().await;
|
||||||
// Only automatic restores can wait for the watcher; explicit requests must report failure.
|
// At login the app usually beats the network. Leave the write to the network watcher
|
||||||
|
// rather than fail — only if the watcher is actually live; a user toggling while
|
||||||
|
// offline still fails fast.
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let defer_until_network = crate::core::network_watch::is_armed()
|
if crate::core::network_watch::is_armed()
|
||||||
&& crate::core::notification::what_was_asked()
|
&& !crate::utils::resolve::is_resolve_done()
|
||||||
== crate::core::notification::FailedOperation::SystemProxyRestore;
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
if defer_until_network
|
|
||||||
&& Config::verge()
|
&& Config::verge()
|
||||||
.await
|
.await
|
||||||
.latest_arc()
|
.latest_arc()
|
||||||
@@ -580,20 +574,7 @@ impl CoreManager {
|
|||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
proxy_control::apply().await?;
|
proxy_control::apply().await?;
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
if let Err(error) = proxy_control::apply().await {
|
|
||||||
if defer_until_network && proxy_control::is_missing_network_service_failure(&error) {
|
|
||||||
logging!(
|
|
||||||
debug,
|
|
||||||
Type::Core,
|
|
||||||
"network service disappeared; the system proxy waits for its return"
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
if !expectation.is_valid(
|
if !expectation.is_valid(
|
||||||
self.get_running_mode().as_ref(),
|
self.get_running_mode().as_ref(),
|
||||||
self.current_core_readiness_generation(),
|
self.current_core_readiness_generation(),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ mod state;
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||||
use clash_verge_logging::{LogRing, Type, logging, logging_error};
|
use clash_verge_logging::{LogRing, Type, logging};
|
||||||
use std::{
|
use std::{
|
||||||
fmt,
|
fmt,
|
||||||
sync::{
|
sync::{
|
||||||
@@ -274,10 +274,6 @@ impl CoreManager {
|
|||||||
|
|
||||||
pub(crate) fn finish_config_update(&self) {
|
pub(crate) fn finish_config_update(&self) {
|
||||||
self.config_update_in_progress.store(false, Ordering::Release);
|
self.config_update_in_progress.store(false, Ordering::Release);
|
||||||
// Spawned because the caller may still hold the config write lock this waits for.
|
|
||||||
crate::process::AsyncHandler::spawn(|| async {
|
|
||||||
logging_error!(Type::Config, crate::config::Config::sync_dns_override().await);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all, level = "info", fields(retries = 0))]
|
#[tracing::instrument(skip_all, level = "info", fields(retries = 0))]
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ async fn reapply() {
|
|||||||
.enable_system_proxy
|
.enable_system_proxy
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
{
|
{
|
||||||
crate::utils::resolve::dns::sync_public_dns().await;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !proxy_control::has_network_service().await {
|
if !proxy_control::has_network_service().await {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::{
|
|||||||
notification::{self, FailedOperation},
|
notification::{self, FailedOperation},
|
||||||
runstate::RUN_STATE,
|
runstate::RUN_STATE,
|
||||||
service,
|
service,
|
||||||
sysopt::{Sysopt, format_bypass},
|
sysopt::Sysopt,
|
||||||
},
|
},
|
||||||
process::AsyncHandler,
|
process::AsyncHandler,
|
||||||
utils::{
|
utils::{
|
||||||
@@ -180,14 +180,6 @@ pub const fn is_missing_network_service(_error: &sysproxy::Error) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_missing_network_service_failure(error: &anyhow::Error) -> bool {
|
|
||||||
!SystemProxyStateUnknown::is_in(error)
|
|
||||||
&& SysproxyFailure::from_chain(error).is_none()
|
|
||||||
&& error
|
|
||||||
.downcast_ref::<sysproxy::Error>()
|
|
||||||
.is_some_and(is_missing_network_service)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Distinguish a misplaced sidecar from a missing privileged service.
|
/// Distinguish a misplaced sidecar from a missing privileged service.
|
||||||
async fn classify_local_apply_failure(error: anyhow::Error) -> anyhow::Error {
|
async fn classify_local_apply_failure(error: anyhow::Error) -> anyhow::Error {
|
||||||
if SysproxyFailure::from_chain(&error).is_some() || !was_refused_locally(&error) {
|
if SysproxyFailure::from_chain(&error).is_some() || !was_refused_locally(&error) {
|
||||||
@@ -212,6 +204,8 @@ const fn refusal_classification(service_usable: Option<bool>) -> SysproxyFailure
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LOOPBACK_HOST: &str = "127.0.0.1";
|
const LOOPBACK_HOST: &str = "127.0.0.1";
|
||||||
|
const MACOS_DEFAULT_BYPASS: &str =
|
||||||
|
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
|
||||||
const MAX_SERVICE_BYPASS_LEN: usize = 8192;
|
const MAX_SERVICE_BYPASS_LEN: usize = 8192;
|
||||||
|
|
||||||
static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new();
|
static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new();
|
||||||
@@ -333,7 +327,13 @@ fn service_bypass(verge: &IVerge) -> Result<String> {
|
|||||||
let custom = verge.system_proxy_bypass.as_deref().unwrap_or("");
|
let custom = verge.system_proxy_bypass.as_deref().unwrap_or("");
|
||||||
ensure!(!custom.contains('\0'), "system proxy bypass contains NUL");
|
ensure!(!custom.contains('\0'), "system proxy bypass contains NUL");
|
||||||
|
|
||||||
let mut bypass: String = format_bypass(verge.use_default_bypass.unwrap_or(true), custom).into();
|
let mut bypass = if custom.is_empty() {
|
||||||
|
MACOS_DEFAULT_BYPASS.to_owned()
|
||||||
|
} else if verge.use_default_bypass.unwrap_or(true) {
|
||||||
|
format!("{MACOS_DEFAULT_BYPASS},{custom}")
|
||||||
|
} else {
|
||||||
|
custom.to_owned()
|
||||||
|
};
|
||||||
truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN);
|
truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN);
|
||||||
Ok(bypass)
|
Ok(bypass)
|
||||||
}
|
}
|
||||||
@@ -367,13 +367,6 @@ fn service_apply_result(requested: &MacosProxyConfig, outcome: ProxyApplyOutcome
|
|||||||
ProxyApplyOutcome::Applied | ProxyApplyOutcome::NotRequested => Ok(()),
|
ProxyApplyOutcome::Applied | ProxyApplyOutcome::NotRequested => Ok(()),
|
||||||
// Direct fallback satisfies a disable request.
|
// Direct fallback satisfies a disable request.
|
||||||
ProxyApplyOutcome::DirectFallback { .. } if matches!(requested, MacosProxyConfig::Disabled) => Ok(()),
|
ProxyApplyOutcome::DirectFallback { .. } if matches!(requested, MacosProxyConfig::Disabled) => Ok(()),
|
||||||
// Older helpers encode a failed service lookup as fallback even when no OS write occurred.
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
ProxyApplyOutcome::DirectFallback { message }
|
|
||||||
if message == sysproxy::Error::NoActiveNetworkService.to_string() =>
|
|
||||||
{
|
|
||||||
Err(sysproxy::Error::NoActiveNetworkService.into())
|
|
||||||
}
|
|
||||||
ProxyApplyOutcome::DirectFallback { message } => {
|
ProxyApplyOutcome::DirectFallback { message } => {
|
||||||
Err(SysproxyFailure::DirectFallback { detail: message }.into())
|
Err(SysproxyFailure::DirectFallback { detail: message }.into())
|
||||||
}
|
}
|
||||||
@@ -624,10 +617,6 @@ pub async fn refresh_guard() -> Result<()> {
|
|||||||
logging!(debug, Type::Core, "proxy guard superseded (generation {generation})");
|
logging!(debug, Type::Core, "proxy guard superseded (generation {generation})");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(error) if is_missing_network_service_failure(&error) => {
|
|
||||||
consecutive_failures = 0;
|
|
||||||
logging!(debug, Type::Core, "system proxy guard waits for a network service");
|
|
||||||
}
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
consecutive_failures += 1;
|
consecutive_failures += 1;
|
||||||
logging!(
|
logging!(
|
||||||
@@ -1129,11 +1118,10 @@ mod tests {
|
|||||||
.err()
|
.err()
|
||||||
.unwrap_or_else(|| anyhow::anyhow!("an enable that never reached the OS must fail"));
|
.unwrap_or_else(|| anyhow::anyhow!("an enable that never reached the OS must fail"));
|
||||||
|
|
||||||
assert!(super::is_missing_network_service_failure(&error));
|
assert_eq!(
|
||||||
assert!(SysproxyFailure::from_chain(&error).is_none());
|
SysproxyFailure::from_chain(&error).map(SysproxyFailure::code),
|
||||||
assert!(!super::is_missing_network_service_failure(
|
Some("SYSPROXY_DIRECT_FALLBACK")
|
||||||
&error.context(SystemProxyStateUnknown)
|
);
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle};
|
|||||||
use serde_yaml_ng::{Mapping, Value};
|
use serde_yaml_ng::{Mapping, Value};
|
||||||
use sha2::{Digest as _, Sha256};
|
use sha2::{Digest as _, Sha256};
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, HashSet};
|
||||||
use std::path::{Component, Path, PathBuf, Prefix};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
pub(crate) const GEO_ASSETS: &[&str] = &[
|
pub(crate) const GEO_ASSETS: &[&str] = &[
|
||||||
"Country.mmdb",
|
"Country.mmdb",
|
||||||
@@ -165,7 +165,6 @@ fn collect_runtime_parts(mut config: Value, config_root: &Path) -> Result<Runtim
|
|||||||
&mut assets,
|
&mut assets,
|
||||||
&mut remote_providers,
|
&mut remote_providers,
|
||||||
)?;
|
)?;
|
||||||
collect_ssh_private_keys(&mut config, &config_root, &mut destinations, &mut assets);
|
|
||||||
for filename in GEO_ASSETS {
|
for filename in GEO_ASSETS {
|
||||||
let source = config_root.join(filename);
|
let source = config_root.join(filename);
|
||||||
if source.is_file() && destinations.insert((*filename).to_string()) {
|
if source.is_file() && destinations.insert((*filename).to_string()) {
|
||||||
@@ -271,75 +270,6 @@ fn collect_provider_assets(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stages file-based ssh keys, which mihomo resolves against the core home directory.
|
|
||||||
fn collect_ssh_private_keys(
|
|
||||||
config: &mut Value,
|
|
||||||
config_root: &Path,
|
|
||||||
destinations: &mut HashSet<String>,
|
|
||||||
assets: &mut Vec<RuntimeAsset>,
|
|
||||||
) {
|
|
||||||
let Some(config) = config.as_mapping_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
// mihomo parses `payload` for every provider type, eagerly as fallback proxies for file and http.
|
|
||||||
let mut proxy_lists = Vec::new();
|
|
||||||
for (key, value) in config.iter_mut() {
|
|
||||||
match key.as_str() {
|
|
||||||
Some("proxies") => proxy_lists.extend(value.as_sequence_mut()),
|
|
||||||
Some("proxy-providers") => proxy_lists.extend(
|
|
||||||
value
|
|
||||||
.as_mapping_mut()
|
|
||||||
.into_iter()
|
|
||||||
.flat_map(Mapping::values_mut)
|
|
||||||
.filter_map(|provider| provider.get_mut("payload").and_then(Value::as_sequence_mut)),
|
|
||||||
),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for proxy in proxy_lists.into_iter().flatten().filter_map(Value::as_mapping_mut) {
|
|
||||||
if proxy.get("type").and_then(Value::as_str) != Some("ssh") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// mihomo reads any value containing "PRIVATE KEY" as inline key material.
|
|
||||||
let Some(raw_path) = proxy
|
|
||||||
.get("private-key")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|key| !key.is_empty() && !key.contains("PRIVATE KEY") && is_local_path(key))
|
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(source) = local_provider_source(config_root, raw_path)
|
|
||||||
.ok()
|
|
||||||
.filter(|source| source.is_file())
|
|
||||||
else {
|
|
||||||
let name = proxy.get("name").and_then(Value::as_str).unwrap_or_default();
|
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::Config,
|
|
||||||
"ssh proxy {name:?} private key is unavailable below the config root; leaving it to the core"
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// A root-level alias keeps the user's path away from service name rules and mihomo's inline-key test.
|
|
||||||
let source = source.to_string_lossy().into_owned();
|
|
||||||
let destination = allocate_provider_destination("ssh-private-key", &source, destinations);
|
|
||||||
assets.push(RuntimeAsset {
|
|
||||||
source,
|
|
||||||
destination: destination.clone(),
|
|
||||||
});
|
|
||||||
proxy.insert(Value::String("private-key".to_owned()), Value::String(destination));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolving a UNC or device path would authenticate to its server, so those stay with the core.
|
|
||||||
fn is_local_path(raw_path: &str) -> bool {
|
|
||||||
match Path::new(raw_path).components().next() {
|
|
||||||
Some(Component::Prefix(prefix)) => matches!(prefix.kind(), Prefix::Disk(_) | Prefix::VerbatimDisk(_)),
|
|
||||||
_ => true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn local_provider_source(config_root: &Path, raw_path: &str) -> Result<PathBuf> {
|
fn local_provider_source(config_root: &Path, raw_path: &str) -> Result<PathBuf> {
|
||||||
let path = Path::new(raw_path);
|
let path = Path::new(raw_path);
|
||||||
let source = if path.is_absolute() {
|
let source = if path.is_absolute() {
|
||||||
|
|||||||
+42
-112
@@ -13,7 +13,7 @@ use crate::{
|
|||||||
CORE_REJECTED_PREFIX, OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE,
|
CORE_REJECTED_PREFIX, OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE,
|
||||||
ReadyWaitError, RunState, RunStateEnv, RunStateStore, ServiceHealth,
|
ReadyWaitError, RunState, RunStateEnv, RunStateStore, ServiceHealth,
|
||||||
},
|
},
|
||||||
runtime_bundle::{GEO_ASSETS, RemoteProviderRef, collect_runtime_bundle, remote_providers_of},
|
runtime_bundle::{RemoteProviderRef, collect_runtime_bundle, remote_providers_of},
|
||||||
tray::Tray,
|
tray::Tray,
|
||||||
},
|
},
|
||||||
process::AsyncHandler,
|
process::AsyncHandler,
|
||||||
@@ -101,7 +101,6 @@ struct ActiveServiceSession {
|
|||||||
proof: OwnerSessionProof,
|
proof: OwnerSessionProof,
|
||||||
supports_runtime_staging: bool,
|
supports_runtime_staging: bool,
|
||||||
supports_runtime_file_read: bool,
|
supports_runtime_file_read: bool,
|
||||||
supports_runtime_geo_read: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_service_session_token() -> Result<String> {
|
fn generate_service_session_token() -> Result<String> {
|
||||||
@@ -133,13 +132,6 @@ pub(crate) fn active_service_supports_runtime_file_read() -> bool {
|
|||||||
.is_some_and(|session| session.supports_runtime_file_read)
|
.is_some_and(|session| session.supports_runtime_file_read)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn active_service_supports_runtime_geo_read() -> bool {
|
|
||||||
ACTIVE_SERVICE_SESSION
|
|
||||||
.lock()
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|session| session.supports_runtime_geo_read)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn clear_active_service_session() {
|
pub(crate) fn clear_active_service_session() {
|
||||||
ACTIVE_SERVICE_SESSION.lock().take();
|
ACTIVE_SERVICE_SESSION.lock().take();
|
||||||
}
|
}
|
||||||
@@ -148,7 +140,6 @@ pub(crate) fn clear_active_service_session() {
|
|||||||
struct ServiceCapabilities {
|
struct ServiceCapabilities {
|
||||||
runtime_staging: bool,
|
runtime_staging: bool,
|
||||||
runtime_file_read: bool,
|
runtime_file_read: bool,
|
||||||
runtime_geo_read: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServiceCapabilities {
|
impl ServiceCapabilities {
|
||||||
@@ -156,7 +147,6 @@ impl ServiceCapabilities {
|
|||||||
Self {
|
Self {
|
||||||
runtime_staging: info.supports_runtime_staging(),
|
runtime_staging: info.supports_runtime_staging(),
|
||||||
runtime_file_read: info.supports_runtime_file_read(),
|
runtime_file_read: info.supports_runtime_file_read(),
|
||||||
runtime_geo_read: info.supports_runtime_geo_read(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,71 +593,50 @@ fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> R
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(super) fn run_maybe_elevated(program: &Path, hide_window: bool) -> Result<std::process::ExitStatus> {
|
fn uninstall_service() -> Result<()> {
|
||||||
|
logging!(info, Type::Service, "uninstall service");
|
||||||
|
|
||||||
use deelevate::{PrivilegeLevel, Token};
|
use deelevate::{PrivilegeLevel, Token};
|
||||||
use runas::Command as RunasCommand;
|
use runas::Command as RunasCommand;
|
||||||
use std::os::windows::process::CommandExt as _;
|
use std::os::windows::process::CommandExt as _;
|
||||||
|
|
||||||
let token = Token::with_current_process()?;
|
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall.exe", || {
|
||||||
let level = token.privilege_level()?;
|
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe"))
|
||||||
let status = match level {
|
})?;
|
||||||
PrivilegeLevel::NotPrivileged => {
|
|
||||||
let mut command = RunasCommand::new(program);
|
|
||||||
if hide_window {
|
|
||||||
command.show(false);
|
|
||||||
}
|
|
||||||
command.status()?
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let mut command = StdCommand::new(program);
|
|
||||||
if hide_window {
|
|
||||||
// 0x08000000 is CREATE_NO_WINDOW, the direct counterpart of runas's show(false).
|
|
||||||
command.creation_flags(0x08000000);
|
|
||||||
}
|
|
||||||
command.status()?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uninstaller_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result<PathBuf>) -> Result<PathBuf> {
|
|
||||||
let uninstall_path = packaged_service_tool_path(file_name, packaged_path)?;
|
|
||||||
if !uninstall_path.exists() {
|
if !uninstall_path.exists() {
|
||||||
bail!(format!("uninstaller not found: {uninstall_path:?}"));
|
bail!(format!("uninstaller not found: {uninstall_path:?}"));
|
||||||
}
|
}
|
||||||
Ok(uninstall_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_uninstall_success(status: &std::process::ExitStatus) -> Result<()> {
|
let token = Token::with_current_process()?;
|
||||||
|
let level = token.privilege_level()?;
|
||||||
|
let status = match level {
|
||||||
|
PrivilegeLevel::NotPrivileged => RunasCommand::new(uninstall_path).show(false).status()?,
|
||||||
|
_ => StdCommand::new(uninstall_path).creation_flags(0x08000000).status()?,
|
||||||
|
};
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
bail!(
|
bail!(
|
||||||
"failed to uninstall service with status {}",
|
"failed to uninstall service with status {}",
|
||||||
status.code().unwrap_or(-1)
|
status.code().unwrap_or(-1)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn uninstall_service() -> Result<()> {
|
|
||||||
logging!(info, Type::Service, "uninstall service");
|
|
||||||
|
|
||||||
let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall.exe", || {
|
|
||||||
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let status = run_maybe_elevated(&uninstall_path, true)?;
|
|
||||||
ensure_uninstall_success(&status)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn uninstall_service() -> Result<()> {
|
fn uninstall_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, "uninstall service");
|
logging!(info, Type::Service, "uninstall service");
|
||||||
|
|
||||||
let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall", || {
|
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || {
|
||||||
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall"))
|
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
if !uninstall_path.exists() {
|
||||||
|
bail!(format!("uninstaller not found: {uninstall_path:?}"));
|
||||||
|
}
|
||||||
|
|
||||||
let elevator = crate::utils::help::linux_elevator();
|
let elevator = crate::utils::help::linux_elevator();
|
||||||
let status = if linux_running_as_root() {
|
let status = if linux_running_as_root() {
|
||||||
StdCommand::new(&uninstall_path).status()?
|
StdCommand::new(&uninstall_path).status()?
|
||||||
@@ -697,7 +666,14 @@ fn uninstall_service() -> Result<()> {
|
|||||||
status.code().unwrap_or(-1)
|
status.code().unwrap_or(-1)
|
||||||
);
|
);
|
||||||
|
|
||||||
ensure_uninstall_success(&status)
|
if !status.success() {
|
||||||
|
bail!(
|
||||||
|
"failed to uninstall service with status {}",
|
||||||
|
status.code().unwrap_or(-1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -712,10 +688,14 @@ fn linux_running_as_root() -> bool {
|
|||||||
fn uninstall_service() -> Result<()> {
|
fn uninstall_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, "uninstall service");
|
logging!(info, Type::Service, "uninstall service");
|
||||||
|
|
||||||
let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall", || {
|
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || {
|
||||||
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall"))
|
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
if !uninstall_path.exists() {
|
||||||
|
bail!(format!("uninstaller not found: {uninstall_path:?}"));
|
||||||
|
}
|
||||||
|
|
||||||
let uninstall_path = macos_service_tool_path(&uninstall_path)?;
|
let uninstall_path = macos_service_tool_path(&uninstall_path)?;
|
||||||
let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned();
|
let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned();
|
||||||
|
|
||||||
@@ -731,7 +711,14 @@ fn uninstall_service() -> Result<()> {
|
|||||||
|
|
||||||
let status = StdCommand::new("osascript").args(vec!["-e", &command]).status()?;
|
let status = StdCommand::new("osascript").args(vec!["-e", &command]).status()?;
|
||||||
|
|
||||||
ensure_uninstall_success(&status)
|
if !status.success() {
|
||||||
|
bail!(
|
||||||
|
"failed to uninstall service with status {}",
|
||||||
|
status.code().unwrap_or(-1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_service() -> Result<()> {
|
fn install_service() -> Result<()> {
|
||||||
@@ -969,7 +956,6 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
|||||||
},
|
},
|
||||||
supports_runtime_staging: capabilities.runtime_staging,
|
supports_runtime_staging: capabilities.runtime_staging,
|
||||||
supports_runtime_file_read: capabilities.runtime_file_read,
|
supports_runtime_file_read: capabilities.runtime_file_read,
|
||||||
supports_runtime_geo_read: capabilities.runtime_geo_read,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
|
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
|
||||||
@@ -1220,62 +1206,6 @@ async fn sync_runtime_providers_by_service() -> Result<ProviderSync> {
|
|||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pulls geo databases the service core rewrote, because validation reads the app dir.
|
|
||||||
pub(crate) async fn sync_runtime_geo_by_service() {
|
|
||||||
if !active_service_supports_runtime_geo_read() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let (Ok(credentials), Ok(session), Ok(app_dir)) = (
|
|
||||||
current_owner_credentials(),
|
|
||||||
active_service_session(),
|
|
||||||
dirs::app_home_dir(),
|
|
||||||
) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for asset in GEO_ASSETS {
|
|
||||||
if let Err(error) = sync_runtime_geo(&credentials, &session, asset, &app_dir.join(asset)).await {
|
|
||||||
logging!(warn, Type::Service, "failed to sync geo database {asset}: {error:#}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sync_runtime_geo(
|
|
||||||
credentials: &OwnerCredentials,
|
|
||||||
session: &OwnerSessionProof,
|
|
||||||
asset: &str,
|
|
||||||
target: &Path,
|
|
||||||
) -> Result<()> {
|
|
||||||
let Ok(local) = tokio::fs::metadata(target).await else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let Some((Some(_), _, Some(runtime_mtime_ns))) = read_chunk(credentials, session, asset, u64::MAX).await? else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
// A newer local file is a change the next staging still has to copy into the runtime.
|
|
||||||
if UNIX_EPOCH + Duration::from_nanos(runtime_mtime_ns) <= local.modified()? {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let (temp, file) = create_sync_temp(target).await?;
|
|
||||||
let result = async {
|
|
||||||
if let Fetch::Complete {
|
|
||||||
mtime_ns: Some(mtime_ns),
|
|
||||||
..
|
|
||||||
} = fetch_runtime_file(credentials, session, asset, file).await?
|
|
||||||
{
|
|
||||||
// Matching the runtime copy's time lets the service skip copying these bytes straight back.
|
|
||||||
std::fs::File::options()
|
|
||||||
.write(true)
|
|
||||||
.open(&temp)?
|
|
||||||
.set_modified(UNIX_EPOCH + Duration::from_nanos(mtime_ns))?;
|
|
||||||
tokio::fs::rename(&temp, target).await?;
|
|
||||||
}
|
|
||||||
anyhow::Ok(())
|
|
||||||
}
|
|
||||||
.await;
|
|
||||||
remove_temp(&temp).await;
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
// The on-disk YAML may describe a rejected apply; use the committed runtime.
|
// The on-disk YAML may describe a rejected apply; use the committed runtime.
|
||||||
fn applied_remote_providers(runtime: &Draft<IRuntime>, app_dir: &Path) -> Result<Vec<RemoteProviderRef>> {
|
fn applied_remote_providers(runtime: &Draft<IRuntime>, app_dir: &Path) -> Result<Vec<RemoteProviderRef>> {
|
||||||
let applied = runtime.data_arc();
|
let applied = runtime.data_arc();
|
||||||
@@ -1970,7 +1900,7 @@ impl ServiceManager {
|
|||||||
|
|
||||||
async fn run_operation(&self, operation: impl Future<Output = Result<()>>) -> Result<()> {
|
async fn run_operation(&self, operation: impl Future<Output = Result<()>>) -> Result<()> {
|
||||||
run_operation_and_then(&RUN_STATE, operation, || async {
|
run_operation_and_then(&RUN_STATE, operation, || async {
|
||||||
if let Err(error) = Tray::global().update_part().await {
|
if let Err(error) = Tray::global().update_menu().await {
|
||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::Service,
|
Type::Service,
|
||||||
|
|||||||
@@ -241,19 +241,20 @@ async fn current_os_proxy_state(sys: Sysproxy, auto: Autoproxy) -> OsProxyState
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct Sysopt {
|
pub(crate) struct Sysopt {
|
||||||
// Owns the proxy target pair; every reader and writer goes through this lock.
|
update_lock: TokioMutex<()>,
|
||||||
proxy_state_lock: TokioMutex<(Sysproxy, Autoproxy)>,
|
|
||||||
guard_operation_lock: TokioMutex<()>,
|
guard_operation_lock: TokioMutex<()>,
|
||||||
reset_sysproxy: AtomicBool,
|
reset_sysproxy: AtomicBool,
|
||||||
|
inner_proxy: Arc<RwLock<(Sysproxy, Autoproxy)>>,
|
||||||
guard: Arc<RwLock<GuardMonitor>>,
|
guard: Arc<RwLock<GuardMonitor>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Sysopt {
|
impl Default for Sysopt {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
proxy_state_lock: TokioMutex::new((Sysproxy::default(), Autoproxy::default())),
|
update_lock: TokioMutex::new(()),
|
||||||
guard_operation_lock: TokioMutex::new(()),
|
guard_operation_lock: TokioMutex::new(()),
|
||||||
reset_sysproxy: AtomicBool::new(false),
|
reset_sysproxy: AtomicBool::new(false),
|
||||||
|
inner_proxy: Arc::new(RwLock::new((Sysproxy::default(), Autoproxy::default()))),
|
||||||
guard: Arc::new(RwLock::new(GuardMonitor::new(GuardType::None, Duration::from_secs(30)))),
|
guard: Arc::new(RwLock::new(GuardMonitor::new(GuardType::None, Duration::from_secs(30)))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,9 +269,10 @@ static DEFAULT_BYPASS: &str = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172
|
|||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
static BYPASS_SEPARATOR: &str = ",";
|
static BYPASS_SEPARATOR: &str = ",";
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
static DEFAULT_BYPASS: &str = crate::constants::sysproxy::MACOS_DEFAULT_BYPASS;
|
static DEFAULT_BYPASS: &str =
|
||||||
|
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
|
||||||
|
|
||||||
pub(super) fn format_bypass(use_default: bool, custom_bypass: &str) -> String {
|
fn format_bypass(use_default: bool, custom_bypass: &str) -> String {
|
||||||
if custom_bypass.is_empty() {
|
if custom_bypass.is_empty() {
|
||||||
DEFAULT_BYPASS.into()
|
DEFAULT_BYPASS.into()
|
||||||
} else if use_default {
|
} else if use_default {
|
||||||
@@ -359,7 +361,7 @@ impl Sysopt {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
while self.access_guard().read().get_state().is_pendding() {
|
while self.access_guard().read().get_state().is_pendding() {
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
tokio::task::yield_now().await;
|
||||||
}
|
}
|
||||||
// `start()` false may also mean a guard was already running.
|
// `start()` false may also mean a guard was already running.
|
||||||
!self.access_guard().read().get_state().is_stopped()
|
!self.access_guard().read().get_state().is_stopped()
|
||||||
@@ -407,21 +409,18 @@ impl Sysopt {
|
|||||||
/// configuration instead of a partially-applied one (e.g. SOCKS already
|
/// configuration instead of a partially-applied one (e.g. SOCKS already
|
||||||
/// disabled but HTTP still enabled mid-transition).
|
/// disabled but HTTP still enabled mid-transition).
|
||||||
pub(crate) async fn wait_idle(&self) {
|
pub(crate) async fn wait_idle(&self) {
|
||||||
let _ = self.proxy_state_lock.lock().await;
|
let _ = self.update_lock.lock().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// init the sysproxy
|
/// init the sysproxy
|
||||||
#[allow(
|
|
||||||
clippy::significant_drop_tightening,
|
|
||||||
reason = "the proxy state lock must stay held until the OS write finishes; wait_idle relies on it"
|
|
||||||
)]
|
|
||||||
pub(super) async fn update_sysproxy(&self) -> Result<()> {
|
pub(super) async fn update_sysproxy(&self) -> Result<()> {
|
||||||
let mut proxy_state = self.proxy_state_lock.lock().await;
|
let _lock = self.update_lock.lock().await;
|
||||||
let verge = Config::verge().await.latest_arc();
|
let verge = Config::verge().await.latest_arc();
|
||||||
// Configured, not live: this runs while the Core is being started or restarted, and
|
// Configured, not live: this runs while the Core is being started or restarted, and
|
||||||
// asking a Core that is not up yet would only fall back here anyway.
|
// asking a Core that is not up yet would only fall back here anyway.
|
||||||
let port = MixedPort::desired().await;
|
let port = MixedPort::desired().await;
|
||||||
let pac_port = server::embedded_server_port()?;
|
let pac_port = server::embedded_server_port()?;
|
||||||
|
// 先 await, 避免持有锁导致的 Send 问题
|
||||||
let bypass = get_bypass().await;
|
let bypass = get_bypass().await;
|
||||||
|
|
||||||
let (sys_enable, pac_enable, proxy_host, proxy_guard) = (
|
let (sys_enable, pac_enable, proxy_host, proxy_guard) = (
|
||||||
@@ -432,7 +431,7 @@ impl Sysopt {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let (sys, auto, guard_type) = {
|
let (sys, auto, guard_type) = {
|
||||||
let (sys, auto) = &mut *proxy_state;
|
let (sys, auto) = &mut *self.inner_proxy.write();
|
||||||
sys.host = proxy_host.into();
|
sys.host = proxy_host.into();
|
||||||
sys.port = port;
|
sys.port = port;
|
||||||
sys.bypass = bypass.into();
|
sys.bypass = bypass.into();
|
||||||
@@ -529,10 +528,6 @@ impl Sysopt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// reset the sysproxy
|
/// reset the sysproxy
|
||||||
#[allow(
|
|
||||||
clippy::significant_drop_tightening,
|
|
||||||
reason = "the proxy state lock must stay held until the disable finishes; wait_idle relies on it"
|
|
||||||
)]
|
|
||||||
pub(super) async fn reset_sysproxy(&self) -> Result<()> {
|
pub(super) async fn reset_sysproxy(&self) -> Result<()> {
|
||||||
if self
|
if self
|
||||||
.reset_sysproxy
|
.reset_sysproxy
|
||||||
@@ -544,13 +539,13 @@ impl Sysopt {
|
|||||||
defer! {
|
defer! {
|
||||||
self.reset_sysproxy.store(false, Ordering::SeqCst);
|
self.reset_sysproxy.store(false, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
let mut proxy_state = self.proxy_state_lock.lock().await;
|
let _lock = self.update_lock.lock().await;
|
||||||
let _guard_operation = self.guard_operation_lock.lock().await;
|
let _guard_operation = self.guard_operation_lock.lock().await;
|
||||||
let drained = self.stop_proxy_guard_locked().await;
|
let drained = self.stop_proxy_guard_locked().await;
|
||||||
|
|
||||||
// 直接关闭所有代理
|
// 直接关闭所有代理
|
||||||
let (sys, auto) = {
|
let (sys, auto) = {
|
||||||
let (sys, auto) = &mut *proxy_state;
|
let (sys, auto) = &mut *self.inner_proxy.write();
|
||||||
sys.enable = false;
|
sys.enable = false;
|
||||||
auto.enable = false;
|
auto.enable = false;
|
||||||
(sys.clone(), auto.clone())
|
(sys.clone(), auto.clone())
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clash_verge_logging::{Type, logging, logging_error};
|
use clash_verge_logging::{Type, logging, logging_error};
|
||||||
use futures::StreamExt as _;
|
|
||||||
use parking_lot::{Mutex, RwLock};
|
use parking_lot::{Mutex, RwLock};
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::{
|
use std::{
|
||||||
@@ -22,6 +21,7 @@ use tokio::{
|
|||||||
sync::mpsc,
|
sync::mpsc,
|
||||||
time::{sleep, timeout},
|
time::{sleep, timeout},
|
||||||
};
|
};
|
||||||
|
use tokio_stream::StreamExt as _;
|
||||||
use tokio_util::time::{DelayQueue, delay_queue::Key};
|
use tokio_util::time::{DelayQueue, delay_queue::Key};
|
||||||
|
|
||||||
enum TimerCommand {
|
enum TimerCommand {
|
||||||
@@ -501,7 +501,6 @@ impl Timer {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{TaskSchedule, Timer, run_timer_profile_update_transition};
|
use super::{TaskSchedule, Timer, run_timer_profile_update_transition};
|
||||||
use crate::config::{PrfItem, PrfOption};
|
use crate::config::{PrfItem, PrfOption};
|
||||||
use futures::StreamExt as _;
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -514,6 +513,7 @@ mod tests {
|
|||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
use tokio::sync::Barrier;
|
use tokio::sync::Barrier;
|
||||||
|
use tokio_stream::StreamExt as _;
|
||||||
use tokio_util::time::DelayQueue;
|
use tokio_util::time::DelayQueue;
|
||||||
|
|
||||||
struct CancellationProbe {
|
struct CancellationProbe {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::config::{IProfilePreview, IVerge};
|
use crate::config::{IProfilePreview, IProfiles, IVerge};
|
||||||
use crate::core::lightweight;
|
use crate::core::lightweight;
|
||||||
use crate::core::tray::menu_def::TrayAction;
|
use crate::core::tray::menu_def::TrayAction;
|
||||||
use crate::process::AsyncHandler;
|
use crate::process::AsyncHandler;
|
||||||
@@ -41,7 +41,10 @@ const TRAY_CLICK_DEBOUNCE_MS: u64 = 300;
|
|||||||
pub const TRAY_ID: &str = "clash-verge-rev-tray";
|
pub const TRAY_ID: &str = "clash-verge-rev-tray";
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct TrayMenuOptions {
|
struct TrayMenuOptions<'a> {
|
||||||
|
mode: Option<&'a str>,
|
||||||
|
system_proxy_enabled: bool,
|
||||||
|
tun_mode_enabled: bool,
|
||||||
is_lightweight_mode: bool,
|
is_lightweight_mode: bool,
|
||||||
include_proxy_groups: bool,
|
include_proxy_groups: bool,
|
||||||
}
|
}
|
||||||
@@ -201,9 +204,8 @@ impl Tray {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let verge = Config::verge().await.latest_arc();
|
let verge = Config::verge().await.latest_arc();
|
||||||
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
let system_proxy = verge.enable_system_proxy.unwrap_or(false);
|
||||||
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
|
let tun_mode = verge.enable_tun_mode.unwrap_or(false);
|
||||||
let tun_mode_available = crate::core::runstate::RUN_STATE.state().tun_capable();
|
|
||||||
let mode = {
|
let mode = {
|
||||||
Config::clash()
|
Config::clash()
|
||||||
.await
|
.await
|
||||||
@@ -214,29 +216,73 @@ impl Tray {
|
|||||||
.unwrap_or("rule")
|
.unwrap_or("rule")
|
||||||
.to_owned()
|
.to_owned()
|
||||||
};
|
};
|
||||||
let profiles_config = Config::profiles().await;
|
|
||||||
let profiles_arc = profiles_config.latest_arc();
|
|
||||||
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
|
|
||||||
let is_lightweight_mode = is_in_lightweight_mode();
|
let is_lightweight_mode = is_in_lightweight_mode();
|
||||||
|
|
||||||
logging_error!(
|
let fetch_proxy_groups =
|
||||||
Type::Tray,
|
include_proxy_groups && verge.tray_proxy_groups_display_mode.as_deref() != Some("disable");
|
||||||
tray.set_menu(Some(
|
|
||||||
create_tray_menu(
|
// TODO: should update tray menu again when it was timeout error
|
||||||
app_handle,
|
let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups {
|
||||||
Some(mode.as_str()),
|
let proxy_nodes_data =
|
||||||
*system_proxy,
|
tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies())
|
||||||
*tun_mode,
|
.await
|
||||||
tun_mode_available,
|
.map_or(None, |res| res.ok());
|
||||||
profiles_preview,
|
|
||||||
TrayMenuOptions {
|
let runtime = Config::runtime().await.latest_arc();
|
||||||
is_lightweight_mode,
|
let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| {
|
||||||
include_proxy_groups,
|
config
|
||||||
},
|
.get("proxy-groups")
|
||||||
)
|
.and_then(|groups| groups.as_sequence())
|
||||||
.await?,
|
.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)
|
||||||
|
};
|
||||||
|
|
||||||
|
let verge = if fetch_proxy_groups {
|
||||||
|
Config::verge().await.latest_arc()
|
||||||
|
} else {
|
||||||
|
verge
|
||||||
|
};
|
||||||
|
let profiles = Config::profiles().await.latest_arc();
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
let dispatch_handle = app_handle.clone();
|
||||||
|
app_handle
|
||||||
|
.run_on_main_thread(move || {
|
||||||
|
let _ = tx.send((|| -> Result<()> {
|
||||||
|
let menu = build_tray_menu(
|
||||||
|
&dispatch_handle,
|
||||||
|
TrayMenuOptions {
|
||||||
|
mode: Some(mode.as_str()),
|
||||||
|
system_proxy_enabled: system_proxy,
|
||||||
|
tun_mode_enabled: tun_mode,
|
||||||
|
is_lightweight_mode,
|
||||||
|
include_proxy_groups,
|
||||||
|
},
|
||||||
|
&verge,
|
||||||
|
&profiles,
|
||||||
|
runtime_proxy_groups_order,
|
||||||
|
proxy_nodes_data,
|
||||||
|
)?;
|
||||||
|
logging_error!(Type::Tray, tray.set_menu(Some(menu)));
|
||||||
|
Ok(())
|
||||||
|
})());
|
||||||
|
})
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to dispatch tray menu update: {e}"))?;
|
||||||
|
rx.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("Tray menu update returned no result"))??;
|
||||||
|
|
||||||
logging!(debug, Type::Tray, "托盘菜单更新成功");
|
logging!(debug, Type::Tray, "托盘菜单更新成功");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -422,11 +468,12 @@ fn create_hotkeys(hotkeys: &Option<Vec<String>>) -> HashMap<&str, &str> {
|
|||||||
let mut parts = item.split(',');
|
let mut parts = item.split(',');
|
||||||
match (parts.next(), parts.next()) {
|
match (parts.next(), parts.next()) {
|
||||||
(Some(func), Some(key)) => {
|
(Some(func), Some(key)) => {
|
||||||
// 托盘菜单的 `accelerator` 在 Linux/Windows 下不支持小键盘按键,Windows 下也不支持 Win 键(前端上报为 `CMD`)
|
// 托盘菜单中的 `accelerator` 属性,在 Linux/Windows 中都不支持小键盘按键的解析
|
||||||
let uppercase_key = key.to_uppercase();
|
if key.to_uppercase().contains("NUMPAD") {
|
||||||
let unsupported =
|
None
|
||||||
uppercase_key.contains("NUMPAD") || (cfg!(windows) && uppercase_key.contains("CMD"));
|
} else {
|
||||||
if unsupported { None } else { Some((func, key)) }
|
Some((func, key))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -584,62 +631,22 @@ fn create_proxy_menu_item(
|
|||||||
Ok((proxies_submenu, inline_proxy_items))
|
Ok((proxies_submenu, inline_proxy_items))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_tray_menu(
|
fn build_tray_menu(
|
||||||
app_handle: &AppHandle,
|
app_handle: &AppHandle,
|
||||||
mode: Option<&str>,
|
options: TrayMenuOptions<'_>,
|
||||||
system_proxy_enabled: bool,
|
verge_settings: &IVerge,
|
||||||
tun_mode_enabled: bool,
|
profiles_config: &IProfiles,
|
||||||
tun_mode_available: bool,
|
proxy_group_order_map: Option<HashMap<String, usize>>,
|
||||||
profiles_preview: Vec<IProfilePreview<'_>>,
|
proxy_nodes_data: Option<Proxies>,
|
||||||
options: TrayMenuOptions,
|
|
||||||
) -> Result<tauri::menu::Menu<Wry>> {
|
) -> Result<tauri::menu::Menu<Wry>> {
|
||||||
let current_proxy_mode = mode.unwrap_or("");
|
let current_proxy_mode = options.mode.unwrap_or("");
|
||||||
|
let tun_mode_available = crate::core::runstate::RUN_STATE.state().tun_capable();
|
||||||
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");
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
};
|
|
||||||
|
|
||||||
if fetch_proxy_groups {
|
|
||||||
verge_settings = Config::verge().await.latest_arc();
|
|
||||||
}
|
|
||||||
|
|
||||||
let tray_proxy_groups_display_mode = verge_settings
|
let tray_proxy_groups_display_mode = verge_settings
|
||||||
.tray_proxy_groups_display_mode
|
.tray_proxy_groups_display_mode
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("default");
|
.unwrap_or("default");
|
||||||
let include_proxy_groups =
|
let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable";
|
||||||
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("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 show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false);
|
||||||
|
|
||||||
@@ -647,7 +654,8 @@ async fn create_tray_menu(
|
|||||||
|
|
||||||
let hotkeys = create_hotkeys(&verge_settings.hotkeys);
|
let hotkeys = create_hotkeys(&verge_settings.hotkeys);
|
||||||
|
|
||||||
let profile_menu_items: Vec<CheckMenuItem<Wry>> = create_profile_menu_item(app_handle, profiles_preview)?;
|
let profile_menu_items: Vec<CheckMenuItem<Wry>> =
|
||||||
|
create_profile_menu_item(app_handle, profiles_config.profiles_preview().unwrap_or_default())?;
|
||||||
|
|
||||||
let texts = MenuTexts::new();
|
let texts = MenuTexts::new();
|
||||||
let profile_menu_items_refs: Vec<&dyn IsMenuItem<Wry>> = profile_menu_items
|
let profile_menu_items_refs: Vec<&dyn IsMenuItem<Wry>> = profile_menu_items
|
||||||
@@ -738,7 +746,7 @@ async fn create_tray_menu(
|
|||||||
MenuIds::SYSTEM_PROXY,
|
MenuIds::SYSTEM_PROXY,
|
||||||
&texts.system_proxy,
|
&texts.system_proxy,
|
||||||
true,
|
true,
|
||||||
system_proxy_enabled,
|
options.system_proxy_enabled,
|
||||||
hotkeys.get("toggle_system_proxy").copied(),
|
hotkeys.get("toggle_system_proxy").copied(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -747,7 +755,7 @@ async fn create_tray_menu(
|
|||||||
MenuIds::TUN_MODE,
|
MenuIds::TUN_MODE,
|
||||||
&texts.tun_mode,
|
&texts.tun_mode,
|
||||||
tun_mode_available,
|
tun_mode_available,
|
||||||
tun_mode_enabled,
|
options.tun_mode_enabled,
|
||||||
hotkeys.get("toggle_tun_mode").copied(),
|
hotkeys.get("toggle_tun_mode").copied(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -1000,9 +1008,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
|||||||
Some((g, p)) => (g, p),
|
Some((g, p)) => (g, p),
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
if let Err(err) = feat::switch_proxy_node(group_name, proxy_name, false).await {
|
feat::switch_proxy_node(group_name, proxy_name).await;
|
||||||
logging!(error, Type::Tray, "Failed to switch proxy: {err:#}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
logging!(debug, Type::Tray, "Unhandled tray menu event: {:?}", event.id);
|
logging!(debug, Type::Tray, "Unhandled tray menu event: {:?}", event.id);
|
||||||
|
|||||||
@@ -18,14 +18,6 @@ pub struct SilentUpdater {
|
|||||||
background_cancel: Mutex<Option<CancellationToken>>,
|
background_cancel: Mutex<Option<CancellationToken>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, specta::Type)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum InstallUpdateOutcome {
|
|
||||||
Installed,
|
|
||||||
Cancelled,
|
|
||||||
UpdateChanged,
|
|
||||||
}
|
|
||||||
|
|
||||||
singleton!(SilentUpdater, SILENT_UPDATER);
|
singleton!(SilentUpdater, SILENT_UPDATER);
|
||||||
|
|
||||||
impl SilentUpdater {
|
impl SilentUpdater {
|
||||||
@@ -42,7 +34,7 @@ impl SilentUpdater {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize, specta::Type)]
|
||||||
struct UpdateCacheMeta {
|
struct UpdateCacheMeta {
|
||||||
version: String,
|
version: String,
|
||||||
downloaded_at: String,
|
downloaded_at: String,
|
||||||
@@ -109,11 +101,11 @@ impl SilentUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the cached installer for `update`, deleting a cache that does not match it.
|
/// Returns the cached installer for `update`, deleting a cache that does not match it.
|
||||||
fn verified_cache(update: &Update) -> Option<Vec<u8>> {
|
fn verified_cache(app_handle: &tauri::AppHandle, update: &Update) -> Option<Vec<u8>> {
|
||||||
let meta = Self::read_cache_meta().ok()?;
|
let meta = Self::read_cache_meta().ok()?;
|
||||||
let bytes = if meta.version == update.version {
|
let bytes = if meta.version == update.version {
|
||||||
Self::read_cache_bytes().and_then(|bytes| {
|
Self::read_cache_bytes().and_then(|bytes| {
|
||||||
update.verify(&bytes)?;
|
verify_signature(app_handle, &bytes, &update.signature, &update.version)?;
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -135,6 +127,46 @@ impl SilentUpdater {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The cache directory is user-writable and feeds an elevated installer, so it is re-verified
|
||||||
|
/// with the same rules `Update::download` applies.
|
||||||
|
fn verify_signature(app_handle: &tauri::AppHandle, bytes: &[u8], signature: &str, version: &str) -> Result<()> {
|
||||||
|
use base64::Engine as _;
|
||||||
|
|
||||||
|
let decode = |value: &str| -> Result<String> {
|
||||||
|
Ok(String::from_utf8(
|
||||||
|
base64::engine::general_purpose::STANDARD.decode(value)?,
|
||||||
|
)?)
|
||||||
|
};
|
||||||
|
let config: tauri_plugin_updater::Config = serde_json::from_value(
|
||||||
|
app_handle
|
||||||
|
.config()
|
||||||
|
.plugins
|
||||||
|
.0
|
||||||
|
.get("updater")
|
||||||
|
.cloned()
|
||||||
|
.context("updater is not configured")?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let public_key = minisign_verify::PublicKey::decode(&decode(&config.pubkey)?)?;
|
||||||
|
let signature = minisign_verify::Signature::decode(&decode(signature)?)?;
|
||||||
|
public_key
|
||||||
|
.verify(bytes, &signature, true)
|
||||||
|
.context("signature mismatch")?;
|
||||||
|
|
||||||
|
match signature
|
||||||
|
.trusted_comment()
|
||||||
|
.split('\t')
|
||||||
|
.find_map(|field| field.strip_prefix("version:"))
|
||||||
|
{
|
||||||
|
Some(signed) => anyhow::ensure!(
|
||||||
|
signed.trim_start_matches('v') == version,
|
||||||
|
"signed for v{signed}, not v{version}"
|
||||||
|
),
|
||||||
|
None => anyhow::ensure!(!config.require_signed_version, "signature carries no version"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_build_to_stable(current: &str, remote: &str) -> bool {
|
pub fn is_build_to_stable(current: &str, remote: &str) -> bool {
|
||||||
let Some((base, build)) = current.trim_start_matches('v').split_once('+') else {
|
let Some((base, build)) = current.trim_start_matches('v').split_once('+') else {
|
||||||
return false;
|
return false;
|
||||||
@@ -359,7 +391,7 @@ impl SilentUpdater {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(bytes) = Self::verified_cache(&update) else {
|
let Some(bytes) = Self::verified_cache(app_handle, &update) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -392,13 +424,13 @@ impl SilentUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Installs `version` for the update dialog, reusing the cache when it holds that version;
|
/// Installs `version` for the update dialog, reusing the cache when it holds that version;
|
||||||
/// A changed offer must be confirmed again before downloading or installing it.
|
/// `false` when cancelled before the installer starts.
|
||||||
pub async fn install_update(
|
pub async fn install_update(
|
||||||
&self,
|
&self,
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
version: &str,
|
version: &str,
|
||||||
on_event: impl Fn(DownloadEvent) + Sync,
|
on_event: impl Fn(DownloadEvent) + Sync,
|
||||||
) -> Result<InstallUpdateOutcome> {
|
) -> Result<bool> {
|
||||||
let cancel = CancellationToken::new();
|
let cancel = CancellationToken::new();
|
||||||
*self.manual_cancel.lock() = Some(cancel.clone());
|
*self.manual_cancel.lock() = Some(cancel.clone());
|
||||||
let prepared = tokio::select! {
|
let prepared = tokio::select! {
|
||||||
@@ -408,36 +440,31 @@ impl SilentUpdater {
|
|||||||
// `cancel_download` cancels under the lock, so once the token is taken back its state is
|
// `cancel_download` cancels under the lock, so once the token is taken back its state is
|
||||||
// final; a cancel during the last synchronous poll of `prepare_install` still wins.
|
// final; a cancel during the last synchronous poll of `prepare_install` still wins.
|
||||||
self.manual_cancel.lock().take();
|
self.manual_cancel.lock().take();
|
||||||
let Some(prepared) = prepared.filter(|_| !cancel.is_cancelled()).transpose()? else {
|
let Some((update, bytes)) = prepared.filter(|_| !cancel.is_cancelled()).transpose()? else {
|
||||||
logging!(info, Type::System, "Update v{version} cancelled");
|
logging!(info, Type::System, "Update v{version} cancelled");
|
||||||
return Ok(InstallUpdateOutcome::Cancelled);
|
return Ok(false);
|
||||||
};
|
|
||||||
let Some((update, bytes)) = prepared else {
|
|
||||||
return Ok(InstallUpdateOutcome::UpdateChanged);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
logging!(info, Type::System, "Installing update v{version}...");
|
logging!(info, Type::System, "Installing update v{version}...");
|
||||||
Self::install(update, bytes).await?;
|
Self::install(update, bytes).await?;
|
||||||
Self::delete_cache();
|
Self::delete_cache();
|
||||||
Ok(InstallUpdateOutcome::Installed)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn prepare_install(
|
async fn prepare_install(
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
version: &str,
|
version: &str,
|
||||||
on_event: impl Fn(DownloadEvent) + Sync,
|
on_event: impl Fn(DownloadEvent) + Sync,
|
||||||
) -> Result<Option<(Update, Vec<u8>)>> {
|
) -> Result<(Update, Vec<u8>)> {
|
||||||
let Some(update) = Self::check(app_handle)
|
let update = Self::check(app_handle)
|
||||||
.await?
|
.await?
|
||||||
.filter(|update| update.version == version)
|
.filter(|update| update.version == version)
|
||||||
else {
|
.with_context(|| format!("v{version} is no longer offered"))?;
|
||||||
return Ok(None);
|
let bytes = match Self::verified_cache(app_handle, &update) {
|
||||||
};
|
|
||||||
let bytes = match Self::verified_cache(&update) {
|
|
||||||
Some(bytes) => bytes,
|
Some(bytes) => bytes,
|
||||||
None => download(&update, on_event).await?,
|
None => download(&update, on_event).await?,
|
||||||
};
|
};
|
||||||
Ok(Some((update, bytes)))
|
Ok((update, bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cancel_download(&self) {
|
pub fn cancel_download(&self) {
|
||||||
@@ -614,7 +641,7 @@ impl SilentUpdater {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if Self::verified_cache(&update).is_some() {
|
if Self::verified_cache(app_handle, &update).is_some() {
|
||||||
logging!(info, Type::System, "Silent updater: v{version} already cached");
|
logging!(info, Type::System, "Silent updater: v{version} already cached");
|
||||||
} else {
|
} else {
|
||||||
logging!(info, Type::System, "Silent updater: downloading v{version}...");
|
logging!(info, Type::System, "Silent updater: downloading v{version}...");
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use nanoid::nanoid;
|
|
||||||
use scopeguard::defer;
|
use scopeguard::defer;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::{
|
use std::{
|
||||||
borrow::Cow,
|
|
||||||
fmt,
|
fmt,
|
||||||
hash::{DefaultHasher, Hash as _, Hasher as _},
|
hash::{DefaultHasher, Hash as _, Hasher as _},
|
||||||
path::Path,
|
|
||||||
sync::LazyLock,
|
sync::LazyLock,
|
||||||
sync::atomic::{AtomicBool, Ordering},
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
};
|
};
|
||||||
@@ -327,12 +324,11 @@ impl CoreConfigValidator {
|
|||||||
return Self::validate_file_syntax_outcome(config_path).await;
|
return Self::validate_file_syntax_outcome(config_path).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::core::service::sync_runtime_geo_by_service().await;
|
|
||||||
let is_script = match Self::is_script_file(config_path).await {
|
let is_script = match Self::is_script_file(config_path).await {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
logging!(warn, Type::Validate, "无法确定文件类型: {}, 错误: {}", config_path, err);
|
logging!(warn, Type::Validate, "无法确定文件类型: {}, 错误: {}", config_path, err);
|
||||||
return Self::validate_profile_yaml_outcome(config_path).await;
|
return Self::validate_config_internal_outcome(config_path).await;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -347,33 +343,7 @@ impl CoreConfigValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logging!(info, Type::Validate, "使用Clash内核验证配置文件: {}", config_path);
|
logging!(info, Type::Validate, "使用Clash内核验证配置文件: {}", config_path);
|
||||||
Self::validate_profile_yaml_outcome(config_path).await
|
Self::validate_config_internal_outcome(config_path).await
|
||||||
}
|
|
||||||
|
|
||||||
/// The user file is saved as written, so the core checks a copy carrying the tolerated short-ids.
|
|
||||||
async fn validate_profile_yaml_outcome(config_path: &str) -> Result<ValidationOutcome> {
|
|
||||||
let Some(tolerant) = Self::short_id_tolerant_text(config_path).await else {
|
|
||||||
return Self::validate_config_internal_outcome(config_path).await;
|
|
||||||
};
|
|
||||||
let Ok(app_dir) = dirs::app_home_dir() else {
|
|
||||||
return Self::validate_config_internal_outcome(config_path).await;
|
|
||||||
};
|
|
||||||
let check_path = app_dir.join(format!("short-id-check-{}.yaml", nanoid!()));
|
|
||||||
defer! {
|
|
||||||
let _ = std::fs::remove_file(&check_path);
|
|
||||||
}
|
|
||||||
if help::save_yaml_str(&check_path, &tolerant).await.is_err() {
|
|
||||||
return Self::validate_config_internal_outcome(config_path).await;
|
|
||||||
}
|
|
||||||
Self::validate_config_internal_outcome(dirs::path_to_str(&check_path)?).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn short_id_tolerant_text(config_path: &str) -> Option<std::string::String> {
|
|
||||||
let yaml = fs::read_to_string(config_path).await.ok()?;
|
|
||||||
match help::short_id_tolerant(&yaml, Path::new(config_path)) {
|
|
||||||
Ok((Cow::Owned(tolerant), _)) => Some(tolerant),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn validate_config_internal_outcome(config_path: &str) -> Result<ValidationOutcome> {
|
async fn validate_config_internal_outcome(config_path: &str) -> Result<ValidationOutcome> {
|
||||||
@@ -455,8 +425,6 @@ impl CoreConfigValidator {
|
|||||||
}
|
}
|
||||||
logging!(info, Type::Validate, "生成临时配置文件用于验证");
|
logging!(info, Type::Validate, "生成临时配置文件用于验证");
|
||||||
|
|
||||||
// The test run reads the app dir, which never sees updates made by a service core.
|
|
||||||
crate::core::service::sync_runtime_geo_by_service().await;
|
|
||||||
let fingerprint = validation_fingerprint(yaml).await;
|
let fingerprint = validation_fingerprint(yaml).await;
|
||||||
if *LAST_VALIDATED.lock() == Some(fingerprint) {
|
if *LAST_VALIDATED.lock() == Some(fingerprint) {
|
||||||
logging!(info, Type::Validate, "配置与上次通过验证的字节一致,跳过内核检查");
|
logging!(info, Type::Validate, "配置与上次通过验证的字节一致,跳过内核检查");
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
#![cfg(target_os = "windows")]
|
#![cfg(target_os = "windows")]
|
||||||
|
|
||||||
use crate::{core::service, utils::dirs};
|
use crate::utils::dirs;
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
|
use deelevate::{PrivilegeLevel, Token};
|
||||||
|
use runas::Command as RunasCommand;
|
||||||
|
use std::process::Command as StdCommand;
|
||||||
|
|
||||||
pub fn invoke_uwptools() -> Result<()> {
|
pub fn invoke_uwptools() -> Result<()> {
|
||||||
let resource_dir = dirs::app_resources_dir()?;
|
let resource_dir = dirs::app_resources_dir()?;
|
||||||
@@ -11,7 +14,13 @@ pub fn invoke_uwptools() -> Result<()> {
|
|||||||
bail!("enableLoopback exe not found");
|
bail!("enableLoopback exe not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
service::run_maybe_elevated(&tool_path, false)?;
|
let token = Token::with_current_process()?;
|
||||||
|
let level = token.privilege_level()?;
|
||||||
|
|
||||||
|
match level {
|
||||||
|
PrivilegeLevel::NotPrivileged => RunasCommand::new(tool_path).status()?,
|
||||||
|
_ => StdCommand::new(tool_path).status()?,
|
||||||
|
};
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// This function is exported for use by the Clash core
|
||||||
|
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||||
|
function main(config, _name) {
|
||||||
|
if (config.mode === 'script') {
|
||||||
|
config.mode = 'rule'
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// This function is exported for use by the Clash core
|
||||||
|
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||||
|
function main(config, _name) {
|
||||||
|
if (Array.isArray(config.proxies)) {
|
||||||
|
config.proxies.forEach((p, i) => {
|
||||||
|
if (p.type === 'hysteria' && typeof p.alpn === 'string') {
|
||||||
|
config.proxies[i].alpn = [p.alpn]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
@@ -3,10 +3,8 @@ use crate::{
|
|||||||
config::PrfItem,
|
config::PrfItem,
|
||||||
utils::{dirs, help},
|
utils::{dirs, help},
|
||||||
};
|
};
|
||||||
use clash_verge_logging::{Type, logging};
|
|
||||||
use serde_yaml_ng::Mapping;
|
use serde_yaml_ng::Mapping;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::{borrow::Cow, path::Path};
|
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -24,6 +22,12 @@ pub enum ChainType {
|
|||||||
Groups(SeqMap),
|
Groups(SeqMap),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ChainSupport {
|
||||||
|
ClashMeta,
|
||||||
|
ClashMetaAlpha,
|
||||||
|
}
|
||||||
|
|
||||||
// Helper trait to allow async conversion
|
// Helper trait to allow async conversion
|
||||||
pub trait AsyncChainItemFrom {
|
pub trait AsyncChainItemFrom {
|
||||||
async fn from_async(item: &PrfItem) -> Option<ChainItem>;
|
async fn from_async(item: &PrfItem) -> Option<ChainItem>;
|
||||||
@@ -49,65 +53,70 @@ impl AsyncChainItemFrom for Option<ChainItem> {
|
|||||||
uid,
|
uid,
|
||||||
data: ChainType::Merge(help::read_mapping(&path).await.ok()?),
|
data: ChainType::Merge(help::read_mapping(&path).await.ok()?),
|
||||||
}),
|
}),
|
||||||
"rules" | "proxies" | "groups" => {
|
"rules" => {
|
||||||
let yaml = fs::read_to_string(&path).await.ok()?;
|
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
|
||||||
let seq_map = parse_seq_map(&yaml, &path)?;
|
Some(ChainItem {
|
||||||
let data = match itype {
|
uid,
|
||||||
"rules" => ChainType::Rules(seq_map),
|
data: ChainType::Rules(seq_map),
|
||||||
"proxies" => ChainType::Proxies(seq_map),
|
})
|
||||||
_ => ChainType::Groups(seq_map),
|
}
|
||||||
};
|
"proxies" => {
|
||||||
Some(ChainItem { uid, data })
|
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
|
||||||
|
Some(ChainItem {
|
||||||
|
uid,
|
||||||
|
data: ChainType::Proxies(seq_map),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"groups" => {
|
||||||
|
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
|
||||||
|
Some(ChainItem {
|
||||||
|
uid,
|
||||||
|
data: ChainType::Groups(seq_map),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
impl ChainItem {
|
||||||
|
/// 内建支持一些脚本
|
||||||
|
pub fn builtin() -> Vec<(ChainSupport, Self)> {
|
||||||
|
// meta 的一些处理
|
||||||
|
let meta_guard = Self::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js"));
|
||||||
|
|
||||||
/// Typed parsing reads `delete: [1234]` as strings where a `Value` round trip would reject it.
|
// meta 1.13.2 alpn string 转 数组
|
||||||
fn parse_seq_map(yaml: &str, path: &Path) -> Option<SeqMap> {
|
let hy_alpn = Self::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js"));
|
||||||
let original = serde_yaml_ng::from_str::<SeqMap>(yaml).ok()?;
|
|
||||||
let Ok((Cow::Owned(tolerant), _)) = help::short_id_tolerant(yaml, path) else {
|
// meta 的一些处理
|
||||||
return Some(original);
|
let meta_guard_alpha = Self::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js"));
|
||||||
};
|
|
||||||
match serde_yaml_ng::from_str::<SeqMap>(&tolerant) {
|
// meta 1.13.2 alpn string 转 数组
|
||||||
Ok(seq_map) => Some(seq_map),
|
let hy_alpn_alpha = Self::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js"));
|
||||||
Err(_) => {
|
|
||||||
logging!(
|
vec![
|
||||||
warn,
|
(ChainSupport::ClashMeta, hy_alpn),
|
||||||
Type::Config,
|
(ChainSupport::ClashMeta, meta_guard),
|
||||||
"kept numeric short-id in {}: quoted text does not load",
|
(ChainSupport::ClashMetaAlpha, hy_alpn_alpha),
|
||||||
path.display()
|
(ChainSupport::ClashMetaAlpha, meta_guard_alpha),
|
||||||
);
|
]
|
||||||
Some(original)
|
}
|
||||||
|
|
||||||
|
pub fn to_script<U: Into<String>, D: Into<String>>(uid: U, data: D) -> Self {
|
||||||
|
Self {
|
||||||
|
uid: uid.into(),
|
||||||
|
data: ChainType::Script(data.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
impl ChainSupport {
|
||||||
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
|
pub fn is_support(&self, core: Option<&String>) -> bool {
|
||||||
mod tests {
|
match core {
|
||||||
use super::parse_seq_map;
|
Some(core) => matches!(
|
||||||
use serde_yaml_ng::Value;
|
(self, core.as_str()),
|
||||||
use std::path::Path;
|
(Self::ClashMeta, "verge-mihomo") | (Self::ClashMetaAlpha, "verge-mihomo-alpha")
|
||||||
|
),
|
||||||
#[test]
|
None => true,
|
||||||
fn seq_short_id_keeps_typed_parse() {
|
|
||||||
for (input, delete) in [
|
|
||||||
("prepend: []\nappend: []\ndelete: [1234]\n", "1234"),
|
|
||||||
("prepend: []\nappend: []\ndelete: [true]\n", "true"),
|
|
||||||
] {
|
|
||||||
let seq_map = parse_seq_map(input, Path::new("b8.yaml")).expect("seq file should load");
|
|
||||||
assert_eq!(seq_map.delete, [delete]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let input = "prepend:\n - name: r\n reality-opts:\n short-id: 1234e123\nappend: []\ndelete: []\n";
|
|
||||||
let seq_map = parse_seq_map(input, Path::new("seq.yaml")).expect("seq file should load");
|
|
||||||
let short_id = seq_map
|
|
||||||
.prepend
|
|
||||||
.first()
|
|
||||||
.and_then(|proxy| proxy.get("reality-opts"))
|
|
||||||
.and_then(|opts| opts.get("short-id"));
|
|
||||||
assert_eq!(short_id, Some(&Value::String("1234e123".into())));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ fn lowercase_key(key: &str) -> Value {
|
|||||||
pub fn use_lowercase(config: &Mapping) -> Mapping {
|
pub fn use_lowercase(config: &Mapping) -> Mapping {
|
||||||
let mut lowercased = Mapping::new();
|
let mut lowercased = Mapping::new();
|
||||||
|
|
||||||
for (key, value) in config.iter() {
|
for (key, value) in config.into_iter() {
|
||||||
if let Some(key_str) = key.as_str() {
|
if let Some(key_str) = key.as_str() {
|
||||||
lowercased.insert(lowercase_key(key_str), value.clone());
|
lowercased.insert(lowercase_key(key_str), value.clone());
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,17 @@ pub fn use_lowercase(config: &Mapping) -> Mapping {
|
|||||||
lowercased
|
lowercased
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn use_lowercase_owned(config: Mapping) -> Mapping {
|
||||||
|
let mut lowercased = Mapping::new();
|
||||||
|
|
||||||
|
for (key, value) in config {
|
||||||
|
if let Some(key_str) = key.as_str() {
|
||||||
|
lowercased.insert(lowercase_key(key_str), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lowercased
|
||||||
|
}
|
||||||
|
|
||||||
pub fn use_sort(mut config: Mapping) -> Mapping {
|
pub fn use_sort(mut config: Mapping) -> Mapping {
|
||||||
let mut sorted = Mapping::new();
|
let mut sorted = Mapping::new();
|
||||||
HANDLE_FIELDS.into_iter().for_each(|key| {
|
HANDLE_FIELDS.into_iter().for_each(|key| {
|
||||||
|
|||||||
+127
-64
@@ -8,7 +8,7 @@ mod tun;
|
|||||||
|
|
||||||
use self::{
|
use self::{
|
||||||
chain::{AsyncChainItemFrom as _, ChainItem, ChainType},
|
chain::{AsyncChainItemFrom as _, ChainItem, ChainType},
|
||||||
field::{use_keys, use_lowercase, use_sort},
|
field::{use_keys, use_lowercase_owned, use_sort},
|
||||||
merge::use_merge,
|
merge::use_merge,
|
||||||
script::use_script,
|
script::use_script,
|
||||||
seq::{SeqMap, use_seq},
|
seq::{SeqMap, use_seq},
|
||||||
@@ -34,6 +34,7 @@ type ResultLog = Vec<(String, String)>;
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ConfigValues {
|
struct ConfigValues {
|
||||||
clash_config: Mapping,
|
clash_config: Mapping,
|
||||||
|
clash_core: Option<String>,
|
||||||
enable_tun: bool,
|
enable_tun: bool,
|
||||||
enable_builtin: bool,
|
enable_builtin: bool,
|
||||||
socks_enabled: bool,
|
socks_enabled: bool,
|
||||||
@@ -127,7 +128,8 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues {
|
|||||||
let dns_settings = verge_arc.dns_settings_for(profile_uid);
|
let dns_settings = verge_arc.dns_settings_for(profile_uid);
|
||||||
let dns_override_confirmation = dns_settings.confirmation;
|
let dns_override_confirmation = dns_settings.confirmation;
|
||||||
|
|
||||||
let (enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
|
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
|
||||||
|
Some(verge_arc.get_valid_clash_core()),
|
||||||
enable_tun_mode.unwrap_or(false),
|
enable_tun_mode.unwrap_or(false),
|
||||||
enable_builtin_enhanced.unwrap_or(true),
|
enable_builtin_enhanced.unwrap_or(true),
|
||||||
verge_socks_enabled.unwrap_or(false),
|
verge_socks_enabled.unwrap_or(false),
|
||||||
@@ -146,6 +148,7 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues {
|
|||||||
|
|
||||||
ConfigValues {
|
ConfigValues {
|
||||||
clash_config,
|
clash_config,
|
||||||
|
clash_core,
|
||||||
enable_tun,
|
enable_tun,
|
||||||
enable_builtin,
|
enable_builtin,
|
||||||
socks_enabled,
|
socks_enabled,
|
||||||
@@ -196,25 +199,35 @@ async fn collect_profile_items(profiles: &IProfiles) -> Result<ProfileItems> {
|
|||||||
|
|
||||||
let name = current_item.name.clone().unwrap_or_default();
|
let name = current_item.name.clone().unwrap_or_default();
|
||||||
|
|
||||||
let ProfileItems {
|
|
||||||
merge_item: default_merge,
|
|
||||||
script_item: default_script,
|
|
||||||
rules_item: default_rules,
|
|
||||||
proxies_item: default_proxies,
|
|
||||||
groups_item: default_groups,
|
|
||||||
global_merge: default_global_merge,
|
|
||||||
global_script: default_global_script,
|
|
||||||
..
|
|
||||||
} = ProfileItems::default();
|
|
||||||
|
|
||||||
let (merge_item, script_item, rules_item, proxies_item, groups_item, global_merge, global_script) = tokio::join!(
|
let (merge_item, script_item, rules_item, proxies_item, groups_item, global_merge, global_script) = tokio::join!(
|
||||||
chain_item_or_default(profiles.get_item(&merge_uid).ok(), || default_merge,),
|
chain_item_or_default(profiles.get_item(&merge_uid).ok(), || ChainItem {
|
||||||
chain_item_or_default(profiles.get_item(&script_uid).ok(), || default_script,),
|
uid: "".into(),
|
||||||
chain_item_or_default(profiles.get_item(&rules_uid).ok(), || default_rules,),
|
data: ChainType::Merge(Mapping::new()),
|
||||||
chain_item_or_default(profiles.get_item(&proxies_uid).ok(), || default_proxies,),
|
},),
|
||||||
chain_item_or_default(profiles.get_item(&groups_uid).ok(), || default_groups,),
|
chain_item_or_default(profiles.get_item(&script_uid).ok(), || ChainItem {
|
||||||
chain_item_or_default(profiles.get_item("Merge").ok(), || default_global_merge,),
|
uid: "".into(),
|
||||||
chain_item_or_default(profiles.get_item("Script").ok(), || default_global_script,),
|
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
|
||||||
|
},),
|
||||||
|
chain_item_or_default(profiles.get_item(&rules_uid).ok(), || ChainItem {
|
||||||
|
uid: "".into(),
|
||||||
|
data: ChainType::Rules(SeqMap::default()),
|
||||||
|
},),
|
||||||
|
chain_item_or_default(profiles.get_item(&proxies_uid).ok(), || ChainItem {
|
||||||
|
uid: "".into(),
|
||||||
|
data: ChainType::Proxies(SeqMap::default()),
|
||||||
|
},),
|
||||||
|
chain_item_or_default(profiles.get_item(&groups_uid).ok(), || ChainItem {
|
||||||
|
uid: "".into(),
|
||||||
|
data: ChainType::Groups(SeqMap::default()),
|
||||||
|
},),
|
||||||
|
chain_item_or_default(profiles.get_item("Merge").ok(), || ChainItem {
|
||||||
|
uid: "Merge".into(),
|
||||||
|
data: ChainType::Merge(Mapping::new()),
|
||||||
|
},),
|
||||||
|
chain_item_or_default(profiles.get_item("Script").ok(), || ChainItem {
|
||||||
|
uid: "Script".into(),
|
||||||
|
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
|
||||||
|
},),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(ProfileItems {
|
Ok(ProfileItems {
|
||||||
@@ -230,16 +243,16 @@ async fn collect_profile_items(profiles: &IProfiles) -> Result<ProfileItems> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn process_override_items(
|
async fn process_global_items(
|
||||||
mut config: Mapping,
|
mut config: Mapping,
|
||||||
mut exists_keys: Vec<String>,
|
mut exists_keys: Vec<String>,
|
||||||
mut result_map: HashMap<String, ResultLog>,
|
mut result_map: HashMap<String, ResultLog>,
|
||||||
merge_item: ChainItem,
|
global_merge: ChainItem,
|
||||||
script_item: ChainItem,
|
global_script: ChainItem,
|
||||||
profile_name: &String,
|
profile_name: &String,
|
||||||
authoritative: &AuthoritativeFields,
|
authoritative: &AuthoritativeFields,
|
||||||
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
||||||
if let ChainType::Merge(merge) = merge_item.data {
|
if let ChainType::Merge(merge) = global_merge.data {
|
||||||
exists_keys.extend(use_keys(&merge));
|
exists_keys.extend(use_keys(&merge));
|
||||||
let before = authoritative.current(&config);
|
let before = authoritative.current(&config);
|
||||||
config = use_merge(&merge, config);
|
config = use_merge(&merge, config);
|
||||||
@@ -249,11 +262,11 @@ async fn process_override_items(
|
|||||||
.map(discarded_note)
|
.map(discarded_note)
|
||||||
.collect();
|
.collect();
|
||||||
if !notes.is_empty() {
|
if !notes.is_empty() {
|
||||||
result_map.entry(merge_item.uid).or_default().extend(notes);
|
result_map.entry(global_merge.uid).or_default().extend(notes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let ChainType::Script(script) = script_item.data {
|
if let ChainType::Script(script) = global_script.data {
|
||||||
let before = authoritative.current(&config);
|
let before = authoritative.current(&config);
|
||||||
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
|
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
|
||||||
exists_keys.extend(changed_keys);
|
exists_keys.extend(changed_keys);
|
||||||
@@ -264,7 +277,7 @@ async fn process_override_items(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(discarded_note),
|
.map(discarded_note),
|
||||||
);
|
);
|
||||||
result_map.insert(script_item.uid, logs);
|
result_map.insert(global_script.uid, logs);
|
||||||
}
|
}
|
||||||
|
|
||||||
(config, exists_keys, result_map)
|
(config, exists_keys, result_map)
|
||||||
@@ -531,6 +544,46 @@ fn ensure_lan_bind_address(mut config: Mapping) -> Mapping {
|
|||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn process_profile_items(
|
||||||
|
mut config: Mapping,
|
||||||
|
mut exists_keys: Vec<String>,
|
||||||
|
mut result_map: HashMap<String, ResultLog>,
|
||||||
|
merge_item: ChainItem,
|
||||||
|
script_item: ChainItem,
|
||||||
|
profile_name: &String,
|
||||||
|
authoritative: &AuthoritativeFields,
|
||||||
|
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
||||||
|
if let ChainType::Merge(merge) = merge_item.data {
|
||||||
|
exists_keys.extend(use_keys(&merge));
|
||||||
|
let before = authoritative.current(&config);
|
||||||
|
config = use_merge(&merge, config);
|
||||||
|
let notes: ResultLog = authoritative
|
||||||
|
.overridden(&before, &authoritative.current(&config))
|
||||||
|
.into_iter()
|
||||||
|
.map(discarded_note)
|
||||||
|
.collect();
|
||||||
|
if !notes.is_empty() {
|
||||||
|
result_map.entry(merge_item.uid).or_default().extend(notes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let ChainType::Script(script) = script_item.data {
|
||||||
|
let before = authoritative.current(&config);
|
||||||
|
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
|
||||||
|
exists_keys.extend(changed_keys);
|
||||||
|
config = res_config;
|
||||||
|
logs.extend(
|
||||||
|
authoritative
|
||||||
|
.overridden(&before, &authoritative.current(&config))
|
||||||
|
.into_iter()
|
||||||
|
.map(discarded_note),
|
||||||
|
);
|
||||||
|
result_map.insert(script_item.uid, logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
(config, exists_keys, result_map)
|
||||||
|
}
|
||||||
|
|
||||||
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
|
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
|
||||||
fn merge_default_config(
|
fn merge_default_config(
|
||||||
mut config: Mapping,
|
mut config: Mapping,
|
||||||
@@ -602,12 +655,25 @@ fn merge_default_config(
|
|||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_builtin_scripts(mut config: Mapping, enable_builtin: bool) -> Mapping {
|
fn apply_builtin_scripts(mut config: Mapping, clash_core: Option<String>, enable_builtin: bool) -> Mapping {
|
||||||
if enable_builtin {
|
if enable_builtin {
|
||||||
// The JS path saw a lowercased view; lowercase once, only when the builtin runs.
|
let items: Vec<_> = ChainItem::builtin()
|
||||||
config = use_lowercase(&config);
|
.into_iter()
|
||||||
logging!(debug, Type::Core, "run builtin script verge_hy_alpn");
|
.filter(|(s, _)| s.is_support(clash_core.as_ref()))
|
||||||
config = builtin_hy_alpn(config);
|
.map(|(_, c)| c)
|
||||||
|
.collect();
|
||||||
|
if !items.is_empty() {
|
||||||
|
// The JS path saw a lowercased view; lowercase once, only when a builtin runs.
|
||||||
|
config = use_lowercase_owned(config);
|
||||||
|
for item in items {
|
||||||
|
logging!(debug, Type::Core, "run builtin script {}", item.uid);
|
||||||
|
config = match item.uid.as_str() {
|
||||||
|
"verge_hy_alpn" => builtin_hy_alpn(config),
|
||||||
|
"verge_meta_guard" => builtin_meta_guard(config),
|
||||||
|
_ => config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
config
|
config
|
||||||
@@ -627,6 +693,14 @@ fn builtin_hy_alpn(mut config: Mapping) -> Mapping {
|
|||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn builtin_meta_guard(mut config: Mapping) -> Mapping {
|
||||||
|
if config.get("mode").and_then(Value::as_str) == Some("script") {
|
||||||
|
config.insert(Value::from("mode"), Value::from("rule"));
|
||||||
|
}
|
||||||
|
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
|
fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
|
||||||
// built-in proxies docs: https://wiki.metacubex.one/config/proxies/built-in
|
// built-in proxies docs: https://wiki.metacubex.one/config/proxies/built-in
|
||||||
const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS", "PASS-RULE"];
|
const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS", "PASS-RULE"];
|
||||||
@@ -805,6 +879,7 @@ pub async fn enhance(
|
|||||||
let cfg_vals = get_config_values(profile_uid).await;
|
let cfg_vals = get_config_values(profile_uid).await;
|
||||||
let ConfigValues {
|
let ConfigValues {
|
||||||
clash_config,
|
clash_config,
|
||||||
|
clash_core,
|
||||||
enable_tun,
|
enable_tun,
|
||||||
enable_builtin,
|
enable_builtin,
|
||||||
socks_enabled,
|
socks_enabled,
|
||||||
@@ -854,13 +929,13 @@ pub async fn enhance(
|
|||||||
tproxy_enabled,
|
tproxy_enabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
let config = apply_builtin_scripts(config, enable_builtin);
|
let config = apply_builtin_scripts(config, clash_core, enable_builtin);
|
||||||
let config = use_tun(config, enable_tun);
|
let config = use_tun(config, enable_tun);
|
||||||
let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await;
|
let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await;
|
||||||
|
|
||||||
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings);
|
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings);
|
||||||
|
|
||||||
let (config, exists_keys, result_map) = process_override_items(
|
let (config, exists_keys, result_map) = process_global_items(
|
||||||
config,
|
config,
|
||||||
exists_keys,
|
exists_keys,
|
||||||
result_map,
|
result_map,
|
||||||
@@ -871,7 +946,7 @@ pub async fn enhance(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let (config, exists_keys, result_map) = process_override_items(
|
let (config, exists_keys, result_map) = process_profile_items(
|
||||||
config,
|
config,
|
||||||
exists_keys,
|
exists_keys,
|
||||||
result_map,
|
result_map,
|
||||||
@@ -1353,7 +1428,7 @@ mod authoritative_field_tests {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
AuthoritativeFields, ChainItem, ChainType, Mapping, cleanup_proxy_groups, ensure_lan_bind_address,
|
AuthoritativeFields, ChainItem, ChainType, Mapping, cleanup_proxy_groups, ensure_lan_bind_address,
|
||||||
process_override_items, use_keys,
|
process_global_items, process_profile_items, use_keys,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -1401,28 +1476,22 @@ mod tests {
|
|||||||
data: ChainType::Merge(merge.clone()),
|
data: ChainType::Merge(merge.clone()),
|
||||||
};
|
};
|
||||||
let profile_name = "test-profile".into();
|
let profile_name = "test-profile".into();
|
||||||
let (config, keys, logs) = process_override_items(
|
let (config, keys, logs) = process_global_items(
|
||||||
config,
|
config,
|
||||||
vec![],
|
vec![],
|
||||||
HashMap::new(),
|
HashMap::new(),
|
||||||
merge_item("global-merge"),
|
merge_item("global-merge"),
|
||||||
ChainItem {
|
ChainItem::to_script("global-script", script),
|
||||||
uid: "global-script".into(),
|
|
||||||
data: ChainType::Script(script.into()),
|
|
||||||
},
|
|
||||||
&profile_name,
|
&profile_name,
|
||||||
&authoritative,
|
&authoritative,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let (config, _, logs) = process_override_items(
|
let (config, _, logs) = process_profile_items(
|
||||||
config,
|
config,
|
||||||
keys,
|
keys,
|
||||||
logs,
|
logs,
|
||||||
merge_item("profile-merge"),
|
merge_item("profile-merge"),
|
||||||
ChainItem {
|
ChainItem::to_script("profile-script", script),
|
||||||
uid: "profile-script".into(),
|
|
||||||
data: ChainType::Script(script.into()),
|
|
||||||
},
|
|
||||||
&profile_name,
|
&profile_name,
|
||||||
&authoritative,
|
&authoritative,
|
||||||
)
|
)
|
||||||
@@ -1467,19 +1536,16 @@ mod tests {
|
|||||||
nested: {winner: global-merge}, dns: {enable: false}, tun: {enable: false}}",
|
nested: {winner: global-merge}, dns: {enable: false}, tun: {enable: false}}",
|
||||||
)),
|
)),
|
||||||
};
|
};
|
||||||
let global_script = ChainItem {
|
let global_script = ChainItem::to_script(
|
||||||
uid: "Script".into(),
|
"Script",
|
||||||
data: ChainType::Script(
|
r#"function main(config) {
|
||||||
r#"function main(config) {
|
|
||||||
config["global-script-wins"] = "global-script";
|
config["global-script-wins"] = "global-script";
|
||||||
config["profile-merge-wins"] = "global-script";
|
config["profile-merge-wins"] = "global-script";
|
||||||
config["profile-script-wins"] = "global-script";
|
config["profile-script-wins"] = "global-script";
|
||||||
config.nested.winner = "global-script";
|
config.nested.winner = "global-script";
|
||||||
return config;
|
return config;
|
||||||
}"#
|
}"#,
|
||||||
.into(),
|
);
|
||||||
),
|
|
||||||
};
|
|
||||||
let profile_merge = ChainItem {
|
let profile_merge = ChainItem {
|
||||||
uid: "profile-merge".into(),
|
uid: "profile-merge".into(),
|
||||||
data: ChainType::Merge(mapping(
|
data: ChainType::Merge(mapping(
|
||||||
@@ -1487,21 +1553,18 @@ mod tests {
|
|||||||
nested: {winner: profile-merge}}",
|
nested: {winner: profile-merge}}",
|
||||||
)),
|
)),
|
||||||
};
|
};
|
||||||
let profile_script = ChainItem {
|
let profile_script = ChainItem::to_script(
|
||||||
uid: "profile-script".into(),
|
"profile-script",
|
||||||
data: ChainType::Script(
|
r#"function main(config) {
|
||||||
r#"function main(config) {
|
|
||||||
config["profile-script-wins"] = "profile-script";
|
config["profile-script-wins"] = "profile-script";
|
||||||
config.nested.winner = "profile-script";
|
config.nested.winner = "profile-script";
|
||||||
return config;
|
return config;
|
||||||
}"#
|
}"#,
|
||||||
.into(),
|
);
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
let profile_name = "test-profile".into();
|
let profile_name = "test-profile".into();
|
||||||
let authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new());
|
let authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new());
|
||||||
let (config, exists_keys, result_map) = process_override_items(
|
let (config, exists_keys, result_map) = process_global_items(
|
||||||
config,
|
config,
|
||||||
exists_keys,
|
exists_keys,
|
||||||
HashMap::new(),
|
HashMap::new(),
|
||||||
@@ -1511,7 +1574,7 @@ mod tests {
|
|||||||
&authoritative,
|
&authoritative,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let (config, exists_keys, _) = process_override_items(
|
let (config, exists_keys, _) = process_profile_items(
|
||||||
config,
|
config,
|
||||||
exists_keys,
|
exists_keys,
|
||||||
result_map,
|
result_map,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{process::AsyncHandler, utils::tmpl};
|
use crate::{process::AsyncHandler, utils::tmpl};
|
||||||
|
|
||||||
use super::field::use_lowercase;
|
use super::field::{use_lowercase, use_lowercase_owned};
|
||||||
use anyhow::{Error, Result};
|
use anyhow::{Error, Result};
|
||||||
use boa_engine::{Context, JsString, JsValue, Source, js_string, native_function::NativeFunction, property::Attribute};
|
use boa_engine::{Context, JsString, JsValue, Source, js_string, native_function::NativeFunction, property::Attribute};
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
@@ -22,7 +22,7 @@ type EvalOutcome = (Result<Mapping, std::string::String>, Vec<(String, String)>)
|
|||||||
pub(super) async fn use_script(script: String, config: Mapping, name: String) -> ScriptResult {
|
pub(super) async fn use_script(script: String, config: Mapping, name: String) -> ScriptResult {
|
||||||
// The template is identity-plus-lowercasing; skip the Boa round-trip.
|
// The template is identity-plus-lowercasing; skip the Boa round-trip.
|
||||||
if script == tmpl::ITEM_SCRIPT {
|
if script == tmpl::ITEM_SCRIPT {
|
||||||
return (use_lowercase(&config), vec![], vec![]);
|
return (use_lowercase_owned(config), vec![], vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let json = match serde_json::to_string(&use_lowercase(&config)) {
|
let json = match serde_json::to_string(&use_lowercase(&config)) {
|
||||||
@@ -173,7 +173,7 @@ fn eval_script(script: &str, config_json: &str, name: &String) -> EvalOutcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match parse_json_safely(&result) {
|
match parse_json_safely(&result) {
|
||||||
Ok(config) => (Ok(use_lowercase(&config)), outputs.lock().to_vec()),
|
Ok(config) => (Ok(use_lowercase_owned(config)), outputs.lock().to_vec()),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
logging!(
|
logging!(
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("fake-ip");
|
.unwrap_or("fake-ip");
|
||||||
|
|
||||||
if current_mode == "fake-ip" {
|
if current_mode == "fake-ip" || !dns_val.contains_key(Value::from("enhanced-mode")) {
|
||||||
revise!(dns_val, "enable", true);
|
revise!(dns_val, "enable", true);
|
||||||
revise!(dns_val, "ipv6", ipv6_val);
|
revise!(dns_val, "ipv6", ipv6_val);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging, logging_error};
|
||||||
use reqwest_dav::list_cmd::ListFile;
|
use reqwest_dav::list_cmd::ListFile;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
@@ -33,8 +33,6 @@ async fn finalize_restored_verge_config(
|
|||||||
webdav_username: Option<String>,
|
webdav_username: Option<String>,
|
||||||
webdav_password: Option<String>,
|
webdav_password: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let config_write = Config::lock_config_write().await;
|
|
||||||
|
|
||||||
// A broken restored `verge.yaml` is a restore failure, not a reason to load defaults.
|
// A broken restored `verge.yaml` is a restore failure, not a reason to load defaults.
|
||||||
let mut restored = help::read_yaml::<IVerge>(&verge_path()?).await?;
|
let mut restored = help::read_yaml::<IVerge>(&verge_path()?).await?;
|
||||||
restored.webdav_url = webdav_url;
|
restored.webdav_url = webdav_url;
|
||||||
@@ -45,42 +43,43 @@ async fn finalize_restored_verge_config(
|
|||||||
let restored_clash = IClashTemp::new().await;
|
let restored_clash = IClashTemp::new().await;
|
||||||
let clash_draft = Config::clash().await;
|
let clash_draft = Config::clash().await;
|
||||||
clash_draft.edit_draft(|d| {
|
clash_draft.edit_draft(|d| {
|
||||||
*d = restored_clash;
|
*d = restored_clash.clone();
|
||||||
});
|
});
|
||||||
clash_draft.apply();
|
clash_draft.apply();
|
||||||
|
|
||||||
let restored_profiles = IProfiles::new().await;
|
let restored_profiles = IProfiles::new().await;
|
||||||
let profiles_draft = Config::profiles().await;
|
let profiles_draft = Config::profiles().await;
|
||||||
profiles_draft.edit_draft(|d| {
|
profiles_draft.edit_draft(|d| {
|
||||||
*d = restored_profiles;
|
*d = restored_profiles.clone();
|
||||||
});
|
});
|
||||||
profiles_draft.apply();
|
profiles_draft.apply();
|
||||||
|
|
||||||
let verge_draft = Config::verge().await;
|
let verge_draft = Config::verge().await;
|
||||||
verge_draft.edit_draft(|d| {
|
{
|
||||||
*d = restored.clone();
|
// Hold the write lock so a concurrent patch cannot stage a draft the proxy write reads.
|
||||||
});
|
let _config_write = Config::lock_config_write().await;
|
||||||
verge_draft.apply();
|
verge_draft.edit_draft(|d| {
|
||||||
|
*d = restored.clone();
|
||||||
|
});
|
||||||
|
verge_draft.apply();
|
||||||
|
|
||||||
// Turn it off here; a failing core side effect below would otherwise skip this step.
|
// Turn it off here; a failing core side effect below would otherwise skip this step.
|
||||||
if !restored.enable_system_proxy.unwrap_or_default() {
|
if !restored.enable_system_proxy.unwrap_or_default() {
|
||||||
let result = async {
|
let result = async {
|
||||||
let _lifecycle = CoreManager::global().lifecycle_lock.lock().await;
|
let _lifecycle = CoreManager::global().lifecycle_lock.lock().await;
|
||||||
proxy_control::apply().await?;
|
proxy_control::apply().await?;
|
||||||
proxy_control::refresh_guard().await
|
proxy_control::refresh_guard().await
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
if let Err(err) = result {
|
if let Err(err) = result {
|
||||||
logging!(error, Type::Backup, "Failed to turn the system proxy off: {err:#}");
|
logging!(error, Type::Backup, "Failed to turn the system proxy off: {err:#}");
|
||||||
if SystemProxyStateUnknown::is_in(&err) {
|
if SystemProxyStateUnknown::is_in(&err) {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// `patch_verge` re-acquires the configuration write lock.
|
|
||||||
drop(config_write);
|
|
||||||
|
|
||||||
// Run configuration side effects without rewriting the already-restored file.
|
// Run configuration side effects without rewriting the already-restored file.
|
||||||
if let Err(err) = super::patch_verge(&restored, true).await {
|
if let Err(err) = super::patch_verge(&restored, true).await {
|
||||||
logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#}");
|
logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#}");
|
||||||
@@ -89,6 +88,7 @@ async fn finalize_restored_verge_config(
|
|||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logging_error!(Type::Config, Config::sync_dns_override().await);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,34 +134,14 @@ pub async fn delete_webdav_backup(filename: String) -> Result<()> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backup creation strips the WebDAV credentials, so the live ones are kept.
|
|
||||||
async fn restore_backup_archive(path: PathBuf) -> Result<()> {
|
|
||||||
let (webdav_url, webdav_username, webdav_password) = {
|
|
||||||
let verge = Config::verge().await;
|
|
||||||
let verge = verge.latest_arc();
|
|
||||||
(
|
|
||||||
verge.webdav_url.clone(),
|
|
||||||
verge.webdav_username.clone(),
|
|
||||||
verge.webdav_password.clone(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let home_dir = app_home_dir()?;
|
|
||||||
AsyncHandler::spawn_blocking(move || -> Result<()> {
|
|
||||||
let file = std::fs::File::open(&path)?;
|
|
||||||
let mut zip = zip::ZipArchive::new(file)?;
|
|
||||||
zip.extract(&home_dir)?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|join| anyhow!("backup extraction task failed: {join}"))??;
|
|
||||||
|
|
||||||
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
|
|
||||||
finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tracing::instrument(skip_all, level = "info", fields(filename = %filename))]
|
#[tracing::instrument(skip_all, level = "info", fields(filename = %filename))]
|
||||||
pub async fn restore_webdav_backup(filename: String) -> Result<()> {
|
pub async fn restore_webdav_backup(filename: String) -> Result<()> {
|
||||||
|
let verge = Config::verge().await;
|
||||||
|
let verge_data = verge.latest_arc();
|
||||||
|
let webdav_url = verge_data.webdav_url.clone();
|
||||||
|
let webdav_username = verge_data.webdav_username.clone();
|
||||||
|
let webdav_password = verge_data.webdav_password.clone();
|
||||||
|
|
||||||
let backup_storage_path = app_home_dir()
|
let backup_storage_path = app_home_dir()
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to get app home dir: {e}"))?
|
.map_err(|e| anyhow::anyhow!("Failed to get app home dir: {e}"))?
|
||||||
.join(filename.as_str());
|
.join(filename.as_str());
|
||||||
@@ -178,7 +158,12 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> {
|
|||||||
err
|
err
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let res = restore_backup_archive(backup_storage_path.clone()).await;
|
let value = backup_storage_path.clone();
|
||||||
|
let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&value)).await??;
|
||||||
|
let mut zip = zip::ZipArchive::new(file)?;
|
||||||
|
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
|
||||||
|
zip.extract(app_home_dir()?)?;
|
||||||
|
let res = finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await;
|
||||||
let _ = backup_storage_path.remove_if_exists().await;
|
let _ = backup_storage_path.remove_if_exists().await;
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
@@ -347,7 +332,22 @@ pub async fn restore_local_backup(filename: String) -> Result<()> {
|
|||||||
return Err(anyhow!("Backup file not found: {}", filename));
|
return Err(anyhow!("Backup file not found: {}", filename));
|
||||||
}
|
}
|
||||||
|
|
||||||
restore_backup_archive(target_path).await
|
let (webdav_url, webdav_username, webdav_password) = {
|
||||||
|
let verge = Config::verge().await;
|
||||||
|
let verge = verge.latest_arc();
|
||||||
|
(
|
||||||
|
verge.webdav_url.clone(),
|
||||||
|
verge.webdav_username.clone(),
|
||||||
|
verge.webdav_password.clone(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&target_path)).await??;
|
||||||
|
let mut zip = zip::ZipArchive::new(file)?;
|
||||||
|
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
|
||||||
|
zip.extract(app_home_dir()?)?;
|
||||||
|
finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all, level = "info", fields(filename = %filename, destination = %destination))]
|
#[tracing::instrument(skip_all, level = "info", fields(filename = %filename, destination = %destination))]
|
||||||
|
|||||||
+45
-17
@@ -3,7 +3,7 @@ use crate::core::notify::{Refresh, announce};
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, MixedPort},
|
config::{Config, MixedPort},
|
||||||
core::{CoreManager, handle, tray},
|
core::{CoreManager, handle, tray},
|
||||||
feat::run_exit_cleanup_flow,
|
feat::clean_async,
|
||||||
process::AsyncHandler,
|
process::AsyncHandler,
|
||||||
utils,
|
utils,
|
||||||
};
|
};
|
||||||
@@ -14,8 +14,15 @@ use smartstring::alias::String;
|
|||||||
use std::sync::{Arc, LazyLock};
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> =
|
static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> = LazyLock::new(|| {
|
||||||
LazyLock::new(|| Arc::new(utils::network::build_webpki_rooted_tls_config().expect("Failed to set TLS versions")));
|
let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||||
|
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
||||||
|
.with_safe_default_protocol_versions()
|
||||||
|
.expect("Failed to set TLS versions")
|
||||||
|
.with_root_certificates(root_store)
|
||||||
|
.with_no_client_auth();
|
||||||
|
Arc::new(config)
|
||||||
|
});
|
||||||
|
|
||||||
pub async fn restart_clash_core() {
|
pub async fn restart_clash_core() {
|
||||||
match CoreManager::global().restart_core().await {
|
match CoreManager::global().restart_core().await {
|
||||||
@@ -31,9 +38,38 @@ pub async fn restart_clash_core() {
|
|||||||
|
|
||||||
pub async fn restart_app() {
|
pub async fn restart_app() {
|
||||||
logging!(debug, Type::System, "启动重启应用流程");
|
logging!(debug, Type::System, "启动重启应用流程");
|
||||||
let cleanup_result = run_exit_cleanup_flow(NoticeStatus::AppRestartCoreStopFailed).await;
|
handle::Handle::global().set_is_exiting();
|
||||||
|
|
||||||
|
Config::apply_all_and_save_file().await;
|
||||||
|
|
||||||
|
let cleanup_result = clean_async().await;
|
||||||
|
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::System,
|
||||||
|
"资源清理完成,退出代码: {}",
|
||||||
|
if cleanup_result.all_success { 0 } else { 1 }
|
||||||
|
);
|
||||||
|
|
||||||
if !cleanup_result.core_stopped {
|
if !cleanup_result.core_stopped {
|
||||||
|
handle::Handle::global().clear_is_exiting();
|
||||||
|
// A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore.
|
||||||
|
// A core that kept running keeps its DNS, whatever an unapplied config draft says.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let manager = CoreManager::global();
|
||||||
|
let _lifecycle = manager.lifecycle_lock.lock().await;
|
||||||
|
if matches!(
|
||||||
|
*manager.get_running_mode(),
|
||||||
|
crate::core::manager::RunningMode::NotRunning
|
||||||
|
) {
|
||||||
|
crate::utils::resolve::dns::sync_public_dns().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle::Handle::notice(
|
||||||
|
NoticeStatus::AppRestartCoreStopFailed,
|
||||||
|
cleanup_result.stop_error.unwrap_or_default(),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,20 +102,12 @@ pub async fn change_clash_mode(mode: String) -> Result<(), String> {
|
|||||||
return Err(err.to_string().into());
|
return Err(err.to_string().into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if mode.as_str() == "global"
|
|
||||||
&& let Err(err) = crate::config::profiles::leave_default_global_exit().await
|
|
||||||
{
|
|
||||||
logging!(warn, Type::Core, "failed to move GLOBAL off its default: {err:#}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let clash = Config::clash().await;
|
let clash = Config::clash().await;
|
||||||
let save_result = {
|
clash.edit_draft(|d| d.patch_config(&mapping));
|
||||||
let _config_write = Config::lock_config_write().await;
|
clash.apply();
|
||||||
clash.edit_draft(|d| d.patch_config(&mapping));
|
|
||||||
clash.apply();
|
let clash_data = clash.data_arc();
|
||||||
clash.data_arc().save_config().await
|
if clash_data.save_config().await.is_ok() {
|
||||||
};
|
|
||||||
if save_result.is_ok() {
|
|
||||||
announce(Refresh::Clash);
|
announce(Refresh::Clash);
|
||||||
tray::Tray::global().update_menu_and_icon().await;
|
tray::Tray::global().update_menu_and_icon().await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> {
|
|||||||
|
|
||||||
/// Apply a patch without post-update reconciliation.
|
/// Apply a patch without post-update reconciliation.
|
||||||
pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Result<()> {
|
pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Result<()> {
|
||||||
let config_write = Config::lock_config_write().await;
|
let config_write = Config::try_lock_config_write()?;
|
||||||
apply_verge_patch_locked(&config_write, patch, not_save_file).await
|
apply_verge_patch_locked(&config_write, patch, not_save_file).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ use crate::{
|
|||||||
cmd,
|
cmd,
|
||||||
config::{Config, PrfItem, PrfOption, profiles::profiles_update_item_safe},
|
config::{Config, PrfItem, PrfOption, profiles::profiles_update_item_safe},
|
||||||
core::{CoreManager, handle, tray, validate::ValidationOutcome},
|
core::{CoreManager, handle, tray, validate::ValidationOutcome},
|
||||||
process::AsyncHandler,
|
|
||||||
utils::help::{mask_err, mask_url},
|
utils::help::{mask_err, mask_url},
|
||||||
};
|
};
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging, logging_error};
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
|
|
||||||
/// Toggle proxy profile
|
/// Toggle proxy profile
|
||||||
@@ -22,85 +21,52 @@ pub async fn toggle_proxy_profile(profile_index: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn switch_proxy_node(group_name: &str, proxy_name: &str, unfix: bool) -> Result<()> {
|
/// Tell the profile which node this group is on now.
|
||||||
let mihomo = handle::Handle::mihomo();
|
///
|
||||||
let previous_proxy = if Config::verge().await.data_arc().auto_close_connection.unwrap_or(false) {
|
/// The core is already switched by the time this runs, so failing to record is not a reason to
|
||||||
match mihomo.get_proxy_by_name(group_name).await {
|
/// report the switch as failed — but it is worth a log line, because what the profile holds is
|
||||||
Ok(group) => group.now.filter(|name| !name.is_empty()),
|
/// what gets re-applied the next time a core starts.
|
||||||
Err(err) => {
|
async fn record_switched_node(group_name: &str, proxy_name: &str) {
|
||||||
logging!(
|
if let Err(error) = crate::config::profiles::record_selected_node(group_name, proxy_name).await {
|
||||||
warn,
|
|
||||||
Type::Core,
|
|
||||||
"Failed to read previous proxy selection for {group_name}: {err}"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
if unfix {
|
|
||||||
mihomo.unfixed_proxy(group_name).await?;
|
|
||||||
} else {
|
|
||||||
mihomo.select_node_for_group(group_name, proxy_name).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let save_result = if unfix {
|
|
||||||
crate::config::profiles::forget_selected_node(group_name).await
|
|
||||||
} else {
|
|
||||||
crate::config::profiles::record_selected_node(group_name, proxy_name).await
|
|
||||||
};
|
|
||||||
// The core selection is already applied; persistence failure must not report it as failed.
|
|
||||||
if let Err(error) = save_result {
|
|
||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::Config,
|
Type::Config,
|
||||||
"Failed to persist proxy selection for {group_name}: {error:#}"
|
"切换代理成功但未能记录到配置: {} -> {}, 错误: {error:#}",
|
||||||
|
group_name,
|
||||||
|
proxy_name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
announce(Refresh::Proxies);
|
}
|
||||||
|
|
||||||
if let Some(previous_proxy) = previous_proxy {
|
pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) {
|
||||||
AsyncHandler::spawn(move || async move {
|
match handle::Handle::mihomo()
|
||||||
let connections = match mihomo.get_connections().await {
|
.select_node_for_group(group_name, proxy_name)
|
||||||
Ok(connections) => connections,
|
.await
|
||||||
Err(err) => {
|
{
|
||||||
logging!(
|
Ok(_) => {
|
||||||
warn,
|
record_switched_node(group_name, proxy_name).await;
|
||||||
Type::Core,
|
announce(Refresh::Proxies);
|
||||||
"Failed to read connections after switching proxy: {err}"
|
let _ = tray::Tray::global().update_menu().await;
|
||||||
);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
Err(err) => {
|
||||||
};
|
logging!(error, Type::Tray, "切换代理失败: {err:?}");
|
||||||
futures::future::join_all(
|
}
|
||||||
connections
|
|
||||||
.connections
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.filter(|connection| connection.chains.contains(&previous_proxy))
|
|
||||||
.map(|connection| async move {
|
|
||||||
if let Err(err) = mihomo.close_connection(&connection.id).await {
|
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::Core,
|
|
||||||
"Failed to close connection {} after switching proxy: {err}",
|
|
||||||
connection.id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if let Err(err) = tray::Tray::global().update_menu().await {
|
|
||||||
logging!(
|
match handle::Handle::mihomo()
|
||||||
warn,
|
.select_node_for_group(group_name, proxy_name)
|
||||||
Type::Tray,
|
.await
|
||||||
"Failed to refresh tray after switching proxy: {err:#}"
|
{
|
||||||
);
|
Ok(_) => {
|
||||||
|
logging!(info, Type::Tray, "代理切换回退成功: {} -> {}", group_name, proxy_name);
|
||||||
|
record_switched_node(group_name, proxy_name).await;
|
||||||
|
let _ = tray::Tray::global().update_menu().await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
logging!(error, Type::Tray, "代理切换最终失败: {err:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result<Option<(String, Option<PrfOption>)>> {
|
async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result<Option<(String, Option<PrfOption>)>> {
|
||||||
@@ -251,7 +217,10 @@ pub async fn update_profile(uid: &String, option: Option<&PrfOption>, is_mannual
|
|||||||
if should_refresh {
|
if should_refresh {
|
||||||
logging!(debug, Type::Config, "[订阅更新] 更新内核配置");
|
logging!(debug, Type::Config, "[订阅更新] 更新内核配置");
|
||||||
match CoreManager::global().update_config_with_force(is_mannual_trigger).await {
|
match CoreManager::global().update_config_with_force(is_mannual_trigger).await {
|
||||||
Ok(outcome) if outcome.is_valid() => announce(Refresh::Clash),
|
Ok(outcome) if outcome.is_valid() => {
|
||||||
|
logging_error!(Type::Config, Config::sync_dns_override().await);
|
||||||
|
announce(Refresh::Clash);
|
||||||
|
}
|
||||||
Ok(outcome @ (ValidationOutcome::Skipped { .. } | ValidationOutcome::Busy)) if !is_mannual_trigger => {
|
Ok(outcome @ (ValidationOutcome::Skipped { .. } | ValidationOutcome::Busy)) if !is_mannual_trigger => {
|
||||||
logging!(info, Type::Config, "[订阅更新] 本次配置刷新已跳过: {}", outcome);
|
logging!(info, Type::Config, "[订阅更新] 本次配置刷新已跳过: {}", outcome);
|
||||||
}
|
}
|
||||||
@@ -278,5 +247,9 @@ pub async fn update_profile(uid: &String, option: Option<&PrfOption>, is_mannual
|
|||||||
|
|
||||||
/// 增强配置
|
/// 增强配置
|
||||||
pub async fn enhance_profiles() -> Result<ValidationOutcome> {
|
pub async fn enhance_profiles() -> Result<ValidationOutcome> {
|
||||||
CoreManager::global().update_config_forced().await
|
let outcome = CoreManager::global().update_config_forced().await?;
|
||||||
|
if outcome.is_valid() {
|
||||||
|
logging_error!(Type::Config, Config::sync_dns_override().await);
|
||||||
|
}
|
||||||
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,9 @@ pub async fn open_or_close_dashboard() {
|
|||||||
logging!(info, Type::Window, "Window toggle result: {result:?}");
|
logging!(info, Type::Window, "Window toggle result: {result:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn run_exit_cleanup_flow(stop_failed_notice: NoticeStatus) -> CleanupResult {
|
pub async fn quit() {
|
||||||
|
logging!(debug, Type::System, "启动退出流程");
|
||||||
|
// 设置退出标志
|
||||||
handle::Handle::global().set_is_exiting();
|
handle::Handle::global().set_is_exiting();
|
||||||
|
|
||||||
Config::apply_all_and_save_file().await;
|
Config::apply_all_and_save_file().await;
|
||||||
@@ -160,18 +162,11 @@ pub(crate) async fn run_exit_cleanup_flow(stop_failed_notice: NoticeStatus) -> C
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
handle::Handle::notice(
|
handle::Handle::notice(
|
||||||
stop_failed_notice,
|
NoticeStatus::AppQuitCoreStopFailed,
|
||||||
cleanup_result.stop_error.clone().unwrap_or_default(),
|
cleanup_result.stop_error.unwrap_or_default(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup_result
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn quit() {
|
|
||||||
logging!(debug, Type::System, "启动退出流程");
|
|
||||||
let cleanup_result = run_exit_cleanup_flow(NoticeStatus::AppQuitCoreStopFailed).await;
|
|
||||||
|
|
||||||
utils::server::shutdown_embedded_server();
|
utils::server::shutdown_embedded_server();
|
||||||
let app_handle = handle::Handle::app_handle();
|
let app_handle = handle::Handle::app_handle();
|
||||||
app_handle.exit(if cleanup_result.all_success { 0 } else { 1 });
|
app_handle.exit(if cleanup_result.all_success { 0 } else { 1 });
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
|||||||
cmd::update_proxy_chain_config_in_runtime,
|
cmd::update_proxy_chain_config_in_runtime,
|
||||||
cmd::invoke_uwp_tool,
|
cmd::invoke_uwp_tool,
|
||||||
cmd::copy_clash_env,
|
cmd::copy_clash_env,
|
||||||
|
cmd::sync_tray_proxy_selection,
|
||||||
cmd::record_selected_node,
|
cmd::record_selected_node,
|
||||||
cmd::switch_proxy_node,
|
cmd::forget_selected_node,
|
||||||
cmd::save_dns_config,
|
cmd::save_dns_config,
|
||||||
cmd::apply_dns_config,
|
cmd::apply_dns_config,
|
||||||
cmd::set_dns_override,
|
cmd::set_dns_override,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ mod app_init {
|
|||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
|||||||
+2
-285
@@ -7,8 +7,6 @@ use scopeguard::{ScopeGuard, guard};
|
|||||||
use serde::{Serialize, de::DeserializeOwned};
|
use serde::{Serialize, de::DeserializeOwned};
|
||||||
use serde_yaml_ng::{Mapping, Value};
|
use serde_yaml_ng::{Mapping, Value};
|
||||||
use std::{
|
use std::{
|
||||||
borrow::Cow,
|
|
||||||
ops::Range,
|
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
str::FromStr,
|
str::FromStr,
|
||||||
};
|
};
|
||||||
@@ -47,8 +45,8 @@ pub async fn read_mapping(path: &Path) -> Result<Mapping> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_mapping(yaml_str: String, path: PathBuf) -> Result<Mapping> {
|
fn parse_mapping(yaml_str: String, path: PathBuf) -> Result<Mapping> {
|
||||||
match short_id_tolerant(&yaml_str, &path) {
|
match serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&yaml_str) {
|
||||||
Ok((_, mut val)) => {
|
Ok(mut val) => {
|
||||||
val.apply_merge()
|
val.apply_merge()
|
||||||
.with_context(|| format!("failed to apply merge \"{}\"", path.display()))?;
|
.with_context(|| format!("failed to apply merge \"{}\"", path.display()))?;
|
||||||
|
|
||||||
@@ -68,150 +66,6 @@ fn parse_mapping(yaml_str: String, path: PathBuf) -> Result<Mapping> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHORT_ID_KEY: &str = "short-id:";
|
|
||||||
|
|
||||||
/// A float like `1234e123` loses its hex text, so quote short-ids before parsing and keep that only if nothing else changed.
|
|
||||||
pub(crate) fn short_id_tolerant<'a>(yaml: &'a str, path: &Path) -> serde_yaml_ng::Result<(Cow<'a, str>, Value)> {
|
|
||||||
let original = serde_yaml_ng::from_str::<Value>(yaml)?;
|
|
||||||
if !has_numeric_short_id(&original) {
|
|
||||||
return Ok((Cow::Borrowed(yaml), original));
|
|
||||||
}
|
|
||||||
let quoted = quote_plain_short_ids(yaml);
|
|
||||||
if matches!(quoted, Cow::Borrowed(_)) {
|
|
||||||
return Ok((quoted, original));
|
|
||||||
}
|
|
||||||
|
|
||||||
match serde_yaml_ng::from_str::<Value>("ed) {
|
|
||||||
Ok(tolerant) if same_except_short_id_text(&original, &tolerant) => Ok((quoted, tolerant)),
|
|
||||||
_ => {
|
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::Config,
|
|
||||||
"kept numeric short-id in {}: quoting it would change other values",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
Ok((Cow::Borrowed(yaml), original))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_numeric_short_id(value: &Value) -> bool {
|
|
||||||
match value {
|
|
||||||
Value::Mapping(map) => map
|
|
||||||
.iter()
|
|
||||||
.any(|(key, value)| (is_short_id_key(key) && value.is_number()) || has_numeric_short_id(value)),
|
|
||||||
Value::Sequence(seq) => seq.iter().any(has_numeric_short_id),
|
|
||||||
Value::Tagged(tagged) => has_numeric_short_id(&tagged.value),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn same_except_short_id_text(original: &Value, tolerant: &Value) -> bool {
|
|
||||||
match (original, tolerant) {
|
|
||||||
(Value::Mapping(original), Value::Mapping(tolerant)) => {
|
|
||||||
original.len() == tolerant.len()
|
|
||||||
&& original
|
|
||||||
.iter()
|
|
||||||
.zip(tolerant.iter())
|
|
||||||
.all(|((key, old), (new_key, new))| {
|
|
||||||
key == new_key && (same_except_short_id_text(old, new) || is_short_id_text(key, old, new))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
(Value::Sequence(original), Value::Sequence(tolerant)) => {
|
|
||||||
original.len() == tolerant.len()
|
|
||||||
&& original
|
|
||||||
.iter()
|
|
||||||
.zip(tolerant.iter())
|
|
||||||
.all(|(old, new)| same_except_short_id_text(old, new))
|
|
||||||
}
|
|
||||||
(Value::Tagged(original), Value::Tagged(tolerant)) => {
|
|
||||||
original.tag == tolerant.tag && same_except_short_id_text(&original.value, &tolerant.value)
|
|
||||||
}
|
|
||||||
_ => original == tolerant,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_short_id_text(key: &Value, old: &Value, new: &Value) -> bool {
|
|
||||||
is_short_id_key(key)
|
|
||||||
&& old.is_number()
|
|
||||||
&& matches!(new, Value::String(text) if serde_yaml_ng::from_str::<Value>(text).ok().as_ref() == Some(old))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_short_id_key(key: &Value) -> bool {
|
|
||||||
key.as_str() == Some("short-id")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn quote_plain_short_ids(yaml: &str) -> Cow<'_, str> {
|
|
||||||
let mut quoted = String::new();
|
|
||||||
let mut copied = 0;
|
|
||||||
for (key, _) in yaml.match_indices(SHORT_ID_KEY) {
|
|
||||||
let Some(value) = plain_short_id_at(yaml, key) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let (Some(head), Some(id)) = (yaml.get(copied..value.start), yaml.get(value.clone())) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
quoted.push_str(head);
|
|
||||||
quoted.push('"');
|
|
||||||
quoted.push_str(id);
|
|
||||||
quoted.push('"');
|
|
||||||
copied = value.end;
|
|
||||||
}
|
|
||||||
|
|
||||||
if copied == 0 {
|
|
||||||
return Cow::Borrowed(yaml);
|
|
||||||
}
|
|
||||||
quoted.push_str(yaml.get(copied..).unwrap_or_default());
|
|
||||||
Cow::Owned(quoted)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn plain_short_id_at(yaml: &str, key: usize) -> Option<Range<usize>> {
|
|
||||||
let bytes = yaml.as_bytes();
|
|
||||||
let before = bytes.get(..key)?;
|
|
||||||
let flow = matches!(
|
|
||||||
before.iter().rev().find(|byte| !byte.is_ascii_whitespace()),
|
|
||||||
Some(b'{' | b',')
|
|
||||||
);
|
|
||||||
let line_start = before
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.take_while(|&&byte| byte != b'\n')
|
|
||||||
.all(|&byte| is_blank(byte));
|
|
||||||
if !flow && !line_start {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let start = key + SHORT_ID_KEY.len();
|
|
||||||
let gap = bytes.get(start..)?.iter().take_while(|&&byte| is_blank(byte)).count();
|
|
||||||
if gap == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let start = start + gap;
|
|
||||||
let len = bytes
|
|
||||||
.get(start..)?
|
|
||||||
.iter()
|
|
||||||
.take_while(|byte| byte.is_ascii_hexdigit())
|
|
||||||
.count();
|
|
||||||
if len == 0 || !ends_plain_short_id(bytes.get(start + len..)?, flow) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(start..start + len)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ends_plain_short_id(rest: &[u8], flow: bool) -> bool {
|
|
||||||
let blanks = rest.iter().take_while(|&&byte| is_blank(byte)).count();
|
|
||||||
match rest.get(blanks..) {
|
|
||||||
Some([] | [b'\n', ..] | [b'\r', b'\n', ..]) => true,
|
|
||||||
Some([b'#', ..]) => blanks > 0,
|
|
||||||
Some([b',' | b'}', ..]) => flow,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn is_blank(byte: u8) -> bool {
|
|
||||||
matches!(byte, b' ' | b'\t')
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn save_yaml<T: Serialize + Sync>(path: &Path, data: &T, prefix: Option<&str>) -> Result<()> {
|
pub async fn save_yaml<T: Serialize + Sync>(path: &Path, data: &T, prefix: Option<&str>) -> Result<()> {
|
||||||
let data_str = with_encryption(|| async { serde_yaml_ng::to_string(data) }).await?;
|
let data_str = with_encryption(|| async { serde_yaml_ng::to_string(data) }).await?;
|
||||||
|
|
||||||
@@ -465,140 +319,3 @@ pub fn snapshot_path(original_path: &Path) -> Result<PathBuf> {
|
|||||||
|
|
||||||
Ok(temp_path)
|
Ok(temp_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
|
|
||||||
mod tests {
|
|
||||||
use super::{has_numeric_short_id, parse_mapping, quote_plain_short_ids, short_id_tolerant};
|
|
||||||
use serde_yaml_ng::Value;
|
|
||||||
use std::{
|
|
||||||
borrow::Cow,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn quotes_plain_short_ids() {
|
|
||||||
let cases = [
|
|
||||||
("short-id: 1234e123", "short-id: \"1234e123\""),
|
|
||||||
("short-id: 0e5", "short-id: \"0e5\""),
|
|
||||||
("short-id: 12345678", "short-id: \"12345678\""),
|
|
||||||
("short-id: 6ba85179", "short-id: \"6ba85179\""),
|
|
||||||
("short-id: \"1234e123\"", "short-id: \"1234e123\""),
|
|
||||||
("short-id: '1234e123'", "short-id: '1234e123'"),
|
|
||||||
("short-id: \"\"", "short-id: \"\""),
|
|
||||||
("short-id:\npublic-key: k", "short-id:\npublic-key: k"),
|
|
||||||
("short-id: null", "short-id: null"),
|
|
||||||
("short-id: ~", "short-id: ~"),
|
|
||||||
("short-id: 1234e123 # note", "short-id: \"1234e123\" # note"),
|
|
||||||
("# short-id: 1234e123", "# short-id: 1234e123"),
|
|
||||||
(
|
|
||||||
"reality-opts: {public-key: k, short-id: 1234e123}",
|
|
||||||
"reality-opts: {public-key: k, short-id: \"1234e123\"}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"reality-opts: {short-id: 1234e123, public-key: k}",
|
|
||||||
"reality-opts: {short-id: \"1234e123\", public-key: k}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"{\n public-key: k,\n short-id: 1234e123\n}",
|
|
||||||
"{\n public-key: k,\n short-id: \"1234e123\"\n}",
|
|
||||||
),
|
|
||||||
("short-id: &sid 1234e123", "short-id: &sid 1234e123"),
|
|
||||||
("short-id: !!str 1234e123", "short-id: !!str 1234e123"),
|
|
||||||
("short-id: abc,def", "short-id: abc,def"),
|
|
||||||
("short-id: 1234e123\r\n", "short-id: \"1234e123\"\r\n"),
|
|
||||||
("my-short-id: 1234e123", "my-short-id: 1234e123"),
|
|
||||||
("xshort-id: 1", "xshort-id: 1"),
|
|
||||||
("short-id:1234e123", "short-id:1234e123"),
|
|
||||||
("short-id: 1.5e3", "short-id: 1.5e3"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (input, expected) in cases {
|
|
||||||
let output = quote_plain_short_ids(input);
|
|
||||||
assert_eq!(output, expected, "input: {input:?}");
|
|
||||||
assert_eq!(
|
|
||||||
matches!(output, Cow::Borrowed(_)),
|
|
||||||
input == expected,
|
|
||||||
"input: {input:?}"
|
|
||||||
);
|
|
||||||
assert_eq!(quote_plain_short_ids(&output), expected, "rerun: {input:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn plain_short_id_survives_profile_parse() {
|
|
||||||
let profile = "\
|
|
||||||
proxies:
|
|
||||||
- name: r
|
|
||||||
type: vless
|
|
||||||
reality-opts:
|
|
||||||
public-key: SFv3pZQdZ1fpnNg9yI2RwXKqAOz4mZHRbT2Wq4kz8Ws
|
|
||||||
short-id: 1234e123
|
|
||||||
";
|
|
||||||
let mapping = parse_mapping(profile.into(), PathBuf::from("profile.yaml")).expect("profile should parse");
|
|
||||||
let short_id = mapping
|
|
||||||
.get("proxies")
|
|
||||||
.and_then(|proxies| proxies.get(0))
|
|
||||||
.and_then(|proxy| proxy.get("reality-opts"))
|
|
||||||
.and_then(|opts| opts.get("short-id"));
|
|
||||||
assert_eq!(short_id, Some(&Value::String("1234e123".into())));
|
|
||||||
|
|
||||||
let output = serde_yaml_ng::to_string(&mapping).expect("mapping should serialize");
|
|
||||||
assert!(output.contains("short-id: '1234e123'"), "{output}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn short_id_rewrite_falls_back_to_original_parse() {
|
|
||||||
// (case, input, has a numeric short-id today)
|
|
||||||
let cases = [
|
|
||||||
("B2", "proxies:\n - name: \"node, short-id: 1234e123}\"\n", false),
|
|
||||||
(
|
|
||||||
"B3",
|
|
||||||
"proxies:\n - name: \"node, short-id: 1234e123}\"\n - name: r\n reality-opts:\n short-id: 1234e123\n",
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"B4",
|
|
||||||
"payload: |\n short-id: 1234e123\nreality-opts:\n short-id: 1234e123\n",
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
("B5", "reality-opts:\n short-id: 1234e123\n more\n", false),
|
|
||||||
("B6", "reality-opts: {public-key: k, # c\n short-id: 1234e123}\n", true),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (case, input, numeric) in cases {
|
|
||||||
let today = serde_yaml_ng::from_str::<Value>(input).expect("input should parse");
|
|
||||||
assert_eq!(has_numeric_short_id(&today), numeric, "{case}");
|
|
||||||
let (text, value) = short_id_tolerant(input, Path::new("b.yaml")).expect("input should parse");
|
|
||||||
assert!(matches!(text, Cow::Borrowed(_)), "{case}");
|
|
||||||
assert_eq!(value, today, "{case}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn short_id_rewrite_keeps_every_numeric_short_id() {
|
|
||||||
let input = "\
|
|
||||||
proxies:
|
|
||||||
- name: a
|
|
||||||
reality-opts:
|
|
||||||
short-id: 1234e123
|
|
||||||
- {name: b, reality-opts: {short-id: 0e5}}
|
|
||||||
";
|
|
||||||
let (text, value) = short_id_tolerant(input, Path::new("b7.yaml")).expect("input should parse");
|
|
||||||
assert!(matches!(text, Cow::Owned(_)));
|
|
||||||
let short_ids: Vec<_> = value
|
|
||||||
.get("proxies")
|
|
||||||
.and_then(Value::as_sequence)
|
|
||||||
.expect("proxies should be a sequence")
|
|
||||||
.iter()
|
|
||||||
.map(|proxy| proxy.get("reality-opts").and_then(|opts| opts.get("short-id")).cloned())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
|
||||||
short_ids,
|
|
||||||
[
|
|
||||||
Some(Value::String("1234e123".into())),
|
|
||||||
Some(Value::String("0e5".into()))
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -354,6 +354,7 @@ pub(super) async fn init_dns_config() -> Result<()> {
|
|||||||
Value::String("*.arpa".into()),
|
Value::String("*.arpa".into()),
|
||||||
Value::String("time.*.com".into()),
|
Value::String("time.*.com".into()),
|
||||||
Value::String("ntp.*.com".into()),
|
Value::String("ntp.*.com".into()),
|
||||||
|
Value::String("time.*.com".into()),
|
||||||
Value::String("+.market.xiaomi.com".into()),
|
Value::String("+.market.xiaomi.com".into()),
|
||||||
Value::String("localhost.ptlogin2.qq.com".into()),
|
Value::String("localhost.ptlogin2.qq.com".into()),
|
||||||
Value::String("*.msftncsi.com".into()),
|
Value::String("*.msftncsi.com".into()),
|
||||||
@@ -508,9 +509,11 @@ pub async fn init_resources() -> Result<()> {
|
|||||||
let app_dir = dirs::app_home_dir()?;
|
let app_dir = dirs::app_home_dir()?;
|
||||||
let res_dir = dirs::app_resources_dir()?;
|
let res_dir = dirs::app_resources_dir()?;
|
||||||
|
|
||||||
// app_home is created by `ensure_directories` in `init_config`, which always runs first.
|
if !app_dir.exists() {
|
||||||
|
std::mem::drop(fs::create_dir_all(&app_dir).await);
|
||||||
|
}
|
||||||
if !res_dir.exists() {
|
if !res_dir.exists() {
|
||||||
fs::create_dir_all(&res_dir).await?;
|
std::mem::drop(fs::create_dir_all(&res_dir).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_list = ["Country.mmdb", "ASN.mmdb", "geoip.dat", "geosite.dat"];
|
let file_list = ["Country.mmdb", "ASN.mmdb", "geoip.dat", "geosite.dat"];
|
||||||
|
|||||||
@@ -48,17 +48,6 @@ enum TlsRootMode {
|
|||||||
StaticWebpkiRoots,
|
StaticWebpkiRoots,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ALPN stays unset: raw TLS connectors offer none, while reqwest needs the h2 offer.
|
|
||||||
pub(crate) fn build_webpki_rooted_tls_config() -> Result<rustls::ClientConfig> {
|
|
||||||
let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
|
||||||
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
|
||||||
.with_safe_default_protocol_versions()?
|
|
||||||
.with_root_certificates(root_store)
|
|
||||||
.with_no_client_auth();
|
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct NetworkManager;
|
pub struct NetworkManager;
|
||||||
|
|
||||||
impl NetworkManager {
|
impl NetworkManager {
|
||||||
@@ -113,7 +102,13 @@ impl NetworkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_static_webpki_tls_config() -> Result<rustls::ClientConfig> {
|
fn build_static_webpki_tls_config() -> Result<rustls::ClientConfig> {
|
||||||
let mut config = build_webpki_rooted_tls_config()?;
|
let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||||
|
let mut config =
|
||||||
|
rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
||||||
|
.with_safe_default_protocol_versions()?
|
||||||
|
.with_root_certificates(root_store)
|
||||||
|
.with_no_client_auth();
|
||||||
|
|
||||||
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
|
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
@@ -160,23 +155,6 @@ impl NetworkManager {
|
|||||||
detail.contains("protocolversion") || detail.contains("protocol version")
|
detail.contains("protocolversion") || detail.contains("protocol version")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `allow_fallback` stays false when the caller already disabled certificate verification.
|
|
||||||
async fn request_with_static_webpki_fallback<T, F, Fut>(allow_fallback: bool, mut request: F) -> Result<T>
|
|
||||||
where
|
|
||||||
F: FnMut(TlsRootMode) -> Fut,
|
|
||||||
Fut: std::future::Future<Output = Result<T>>,
|
|
||||||
{
|
|
||||||
match request(TlsRootMode::PlatformVerifier).await {
|
|
||||||
Ok(value) => Ok(value),
|
|
||||||
Err(err) if allow_fallback && Self::should_retry_with_static_webpki_roots(&err) => {
|
|
||||||
request(TlsRootMode::StaticWebpkiRoots).await.map_err(|fallback_err| {
|
|
||||||
fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(err) => Err(err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_with_tls_mode(
|
async fn get_with_tls_mode(
|
||||||
&self,
|
&self,
|
||||||
url: &str,
|
url: &str,
|
||||||
@@ -286,21 +264,34 @@ impl NetworkManager {
|
|||||||
user_agent: Option<String>,
|
user_agent: Option<String>,
|
||||||
accept_invalid_certs: bool,
|
accept_invalid_certs: bool,
|
||||||
) -> Result<HttpResponse> {
|
) -> Result<HttpResponse> {
|
||||||
Self::request_with_static_webpki_fallback(!accept_invalid_certs, |tls_root_mode| {
|
let platform_result = self
|
||||||
let user_agent = user_agent.clone();
|
.get_with_tls_mode(
|
||||||
async move {
|
url,
|
||||||
self.get_with_tls_mode(
|
proxy_type,
|
||||||
|
timeout_secs,
|
||||||
|
user_agent.clone(),
|
||||||
|
accept_invalid_certs,
|
||||||
|
TlsRootMode::PlatformVerifier,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match platform_result {
|
||||||
|
Ok(response) => Ok(response),
|
||||||
|
Err(err) if !accept_invalid_certs && Self::should_retry_with_static_webpki_roots(&err) => self
|
||||||
|
.get_with_tls_mode(
|
||||||
url,
|
url,
|
||||||
proxy_type,
|
proxy_type,
|
||||||
timeout_secs,
|
timeout_secs,
|
||||||
user_agent,
|
user_agent,
|
||||||
accept_invalid_certs,
|
accept_invalid_certs,
|
||||||
tls_root_mode,
|
TlsRootMode::StaticWebpkiRoots,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
.map_err(|fallback_err| {
|
||||||
})
|
fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed")
|
||||||
.await
|
}),
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_bytes_with_tls_mode(
|
async fn get_bytes_with_tls_mode(
|
||||||
@@ -349,10 +340,19 @@ impl NetworkManager {
|
|||||||
timeout_secs: Option<u64>,
|
timeout_secs: Option<u64>,
|
||||||
max_bytes: usize,
|
max_bytes: usize,
|
||||||
) -> Result<Vec<u8>> {
|
) -> Result<Vec<u8>> {
|
||||||
Self::request_with_static_webpki_fallback(true, |tls_root_mode| async move {
|
let platform_result = self
|
||||||
self.get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, tls_root_mode)
|
.get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, TlsRootMode::PlatformVerifier)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match platform_result {
|
||||||
|
Ok(body) => Ok(body),
|
||||||
|
Err(err) if Self::should_retry_with_static_webpki_roots(&err) => self
|
||||||
|
.get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, TlsRootMode::StaticWebpkiRoots)
|
||||||
.await
|
.await
|
||||||
})
|
.map_err(|fallback_err| {
|
||||||
.await
|
fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed")
|
||||||
|
}),
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ use serde_yaml_ng::{Mapping, Value};
|
|||||||
use std::{
|
use std::{
|
||||||
io::Read as _,
|
io::Read as _,
|
||||||
os::unix::process::CommandExt as _,
|
os::unix::process::CommandExt as _,
|
||||||
path::PathBuf,
|
path::{Path, PathBuf},
|
||||||
process::{Command, Stdio},
|
process::{Command, Stdio},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
const DNS_STATE_FILE: &str = ".original_dns.txt";
|
||||||
|
const DNS_BACKUP_DIR: &str = ".original_dns";
|
||||||
// Any address routed into TUN works, since dns-hijack answers it; the value only matters if left behind.
|
// Any address routed into TUN works, since dns-hijack answers it; the value only matters if left behind.
|
||||||
const PLACEHOLDER_DNS: &str = "114.114.114.114\n223.5.5.5\n1.1.1.1";
|
const PLACEHOLDER_DNS: &str = "114.114.114.114";
|
||||||
// Scripts run under lifecycle_lock, and networksetup waits for other preference writers without a limit.
|
// Scripts run under lifecycle_lock, and networksetup waits for other preference writers without a limit.
|
||||||
pub(crate) const DNS_SCRIPT_TIMEOUT: Duration = Duration::from_secs(5);
|
pub(crate) const DNS_SCRIPT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
@@ -24,6 +26,14 @@ fn dns_state_dir() -> anyhow::Result<PathBuf> {
|
|||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn restore_dns_state_dir(resource_dir: &Path, state_dir: PathBuf) -> PathBuf {
|
||||||
|
if resource_dir.join(DNS_STATE_FILE).exists() || resource_dir.join(DNS_BACKUP_DIR).exists() {
|
||||||
|
resource_dir.to_path_buf()
|
||||||
|
} else {
|
||||||
|
state_dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Without the placeholder, a LAN resolver is reached outside TUN and fake-ip is bypassed.
|
// Without the placeholder, a LAN resolver is reached outside TUN and fake-ip is bypassed.
|
||||||
fn needs_public_dns(config: &Mapping) -> bool {
|
fn needs_public_dns(config: &Mapping) -> bool {
|
||||||
let section = |name: &str| config.get(name).and_then(Value::as_mapping);
|
let section = |name: &str| config.get(name).and_then(Value::as_mapping);
|
||||||
@@ -69,19 +79,25 @@ pub async fn restore_public_dns() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
async fn set_public_dns() {
|
async fn set_public_dns() {
|
||||||
logging!(debug, Type::Config, "try to set system dns");
|
logging!(debug, Type::Config, "try to set system dns");
|
||||||
let result = run_dns_script("set_dns.sh", true).await;
|
let result = async {
|
||||||
|
let script = crate::utils::dirs::app_resources_dir()?.join("set_dns.sh");
|
||||||
|
run_dns_script(&script, dns_state_dir()?).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
match result {
|
match result {
|
||||||
Ok(DnsScriptOutcome::Complete) => logging!(info, Type::Config, "set system dns successfully"),
|
Ok(()) => logging!(info, Type::Config, "set system dns successfully"),
|
||||||
Ok(DnsScriptOutcome::Deferred) => {
|
|
||||||
logging!(debug, Type::Config, "system DNS waits for a primary network service")
|
|
||||||
}
|
|
||||||
Err(err) => logging!(error, Type::Config, "set system dns failed: {err:#}"),
|
Err(err) => logging!(error, Type::Config, "set system dns failed: {err:#}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn unset_public_dns() -> anyhow::Result<()> {
|
async fn unset_public_dns() -> anyhow::Result<()> {
|
||||||
logging!(debug, Type::Config, "try to unset system dns");
|
logging!(debug, Type::Config, "try to unset system dns");
|
||||||
let result = run_dns_script("unset_dns.sh", false).await.map(|_| ());
|
let result = async {
|
||||||
|
let resource_dir = crate::utils::dirs::app_resources_dir()?;
|
||||||
|
let state_dir = restore_dns_state_dir(&resource_dir, dns_state_dir()?);
|
||||||
|
run_dns_script(&resource_dir.join("unset_dns.sh"), state_dir).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
match &result {
|
match &result {
|
||||||
Ok(()) => logging!(info, Type::Config, "unset system dns successfully"),
|
Ok(()) => logging!(info, Type::Config, "unset system dns successfully"),
|
||||||
Err(err) => logging!(error, Type::Config, "unset system dns failed: {err:#}"),
|
Err(err) => logging!(error, Type::Config, "unset system dns failed: {err:#}"),
|
||||||
@@ -89,21 +105,12 @@ async fn unset_public_dns() -> anyhow::Result<()> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DnsScriptOutcome {
|
|
||||||
Complete,
|
|
||||||
Deferred,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// On timeout the script's whole process group is killed, so no networksetup it started writes late.
|
/// On timeout the script's whole process group is killed, so no networksetup it started writes late.
|
||||||
async fn run_dns_script(script_name: &str, allow_deferred: bool) -> anyhow::Result<DnsScriptOutcome> {
|
async fn run_dns_script(script: &Path, dir: PathBuf) -> anyhow::Result<()> {
|
||||||
let resource_dir = crate::utils::dirs::app_resources_dir()?;
|
|
||||||
let script = resource_dir.join(script_name);
|
|
||||||
let dir = dns_state_dir()?;
|
|
||||||
ensure!(script.exists(), "DNS script not found: {}", script.display());
|
ensure!(script.exists(), "DNS script not found: {}", script.display());
|
||||||
let mut child = Command::new("bash")
|
let mut child = Command::new("bash")
|
||||||
.arg(&script)
|
.arg(script)
|
||||||
.arg(PLACEHOLDER_DNS)
|
.arg(PLACEHOLDER_DNS)
|
||||||
.arg(resource_dir)
|
|
||||||
.current_dir(dir)
|
.current_dir(dir)
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
@@ -127,18 +134,41 @@ async fn run_dns_script(script_name: &str, allow_deferred: bool) -> anyhow::Resu
|
|||||||
if let Some(mut pipe) = child.stderr.take() {
|
if let Some(mut pipe) = child.stderr.take() {
|
||||||
pipe.read_to_string(&mut stderr)?;
|
pipe.read_to_string(&mut stderr)?;
|
||||||
}
|
}
|
||||||
// EX_TEMPFAIL is reserved by set_dns.sh for a network service that is not ready.
|
|
||||||
if allow_deferred && status.code() == Some(75) {
|
|
||||||
return Ok(DnsScriptOutcome::Deferred);
|
|
||||||
}
|
|
||||||
ensure!(
|
ensure!(
|
||||||
status.success(),
|
status.success(),
|
||||||
"exit code {}: {}",
|
"exit code {}: {}",
|
||||||
status.code().unwrap_or(-1),
|
status.code().unwrap_or(-1),
|
||||||
stderr.trim()
|
stderr.trim()
|
||||||
);
|
);
|
||||||
Ok(DnsScriptOutcome::Complete)
|
Ok(())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.context("DNS script task failed")?
|
.context("DNS script task failed")?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
|
fn restore_dns_state_dir_defaults_to_app_data_but_honors_legacy_file() {
|
||||||
|
use super::restore_dns_state_dir;
|
||||||
|
|
||||||
|
let root = std::env::temp_dir().join(format!("clash-verge-dns-{}", nanoid::nanoid!()));
|
||||||
|
let resource_dir = root.join("resources");
|
||||||
|
let state_dir = root.join("app-data");
|
||||||
|
std::fs::create_dir_all(&resource_dir).expect("create test resource directory");
|
||||||
|
|
||||||
|
assert_eq!(restore_dns_state_dir(&resource_dir, state_dir.clone()), state_dir);
|
||||||
|
|
||||||
|
std::fs::write(resource_dir.join(".original_dns.txt"), "empty").expect("create legacy DNS state");
|
||||||
|
assert_eq!(restore_dns_state_dir(&resource_dir, state_dir.clone()), resource_dir);
|
||||||
|
|
||||||
|
// A migrated legacy backup whose restore failed must still be retried there.
|
||||||
|
std::fs::remove_file(resource_dir.join(".original_dns.txt")).expect("remove legacy DNS state");
|
||||||
|
std::fs::create_dir(resource_dir.join(".original_dns")).expect("create migrated DNS state");
|
||||||
|
assert_eq!(restore_dns_state_dir(&resource_dir, state_dir), resource_dir);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).expect("remove test directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use crate::core::notify::NoticeStatus;
|
use crate::core::notify::NoticeStatus;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
core::{
|
core::{
|
||||||
@@ -95,6 +97,18 @@ async fn resolve_setup() {
|
|||||||
resolve_done();
|
resolve_done();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_reset_async() -> Result<(), anyhow::Error> {
|
||||||
|
CoreManager::global().stop_core().await?;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
use dns::restore_public_dns;
|
||||||
|
restore_public_dns().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn init_scheme() {
|
fn init_scheme() {
|
||||||
logging_error!(Type::Setup, init::init_scheme());
|
logging_error!(Type::Setup, init::init_scheme());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "2.6.0",
|
"version": "2.5.9",
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update.json",
|
|
||||||
"https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-proxy.json",
|
"https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-proxy.json",
|
||||||
"https://gh-proxy.org/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-proxy.json"
|
"https://gh-proxy.org/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-proxy.json",
|
||||||
|
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update.json"
|
||||||
],
|
],
|
||||||
"windows": {
|
"windows": {
|
||||||
"installMode": "passive"
|
"installMode": "passive"
|
||||||
|
|||||||
@@ -26,9 +26,8 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"dialog": false,
|
"dialog": false,
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json",
|
|
||||||
"https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json",
|
"https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json",
|
||||||
"https://gh-proxy.org/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json"
|
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json"
|
||||||
],
|
],
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK"
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,8 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"dialog": false,
|
"dialog": false,
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json",
|
|
||||||
"https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json",
|
"https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json",
|
||||||
"https://gh-proxy.org/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json"
|
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json"
|
||||||
],
|
],
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK"
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,8 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"dialog": false,
|
"dialog": false,
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json",
|
|
||||||
"https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json",
|
"https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json",
|
||||||
"https://gh-proxy.org/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json"
|
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json"
|
||||||
],
|
],
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK"
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,27 +21,3 @@ These rules supplement the repository's root `AGENTS.md`.
|
|||||||
- **Command ownership.** `services` owns backend command invocation and transport
|
- **Command ownership.** `services` owns backend command invocation and transport
|
||||||
wrappers. Components and hooks use those boundaries; plugin mutations follow
|
wrappers. Components and hooks use those boundaries; plugin mutations follow
|
||||||
the same intent ownership as backend commands.
|
the same intent ownership as backend commands.
|
||||||
- **State liveness.** Every `useState`/`useRef` member has a reachable write
|
|
||||||
that differs from its initial value and a read that observes the difference.
|
|
||||||
A value that can only ever hold its initial value is a constant: declare the
|
|
||||||
constant and delete the state. A ref nothing reads is deleted, not kept.
|
|
||||||
- **Surface liveness.** Anything a module exposes — props, parameters, context
|
|
||||||
members, imperative-handle methods — ships with at least one external caller
|
|
||||||
in the same change. "Reserved for future use" fields, `_`-prefixed params
|
|
||||||
that callers still pass, and error or callback channels that every receiver
|
|
||||||
ignores are removed, not maintained.
|
|
||||||
- **Path liveness.** A fallback or branch names the concrete runtime condition
|
|
||||||
that reaches it. If no supported runtime can reach it, the branch is not
|
|
||||||
added — or it replaces the primary path. Constant subexpressions and cleanup
|
|
||||||
of resources that cannot exist yet are simplified away.
|
|
||||||
- **Effect economy.** One user interaction produces one state write and one
|
|
||||||
command. A handler does not re-apply a value its wrapper already applied,
|
|
||||||
and an effect does not re-run a refresh another branch of the same effect
|
|
||||||
just ran.
|
|
||||||
- **Mount economy.** Components that issue commands or subscriptions on mount
|
|
||||||
are mounted only where their UI is reachable. A dialog no rendered control
|
|
||||||
can open stays unmounted, not mounted-and-idle.
|
|
||||||
- **Resource bounds.** Every cache key, timer and subscription a feature
|
|
||||||
allocates is reused or released on refresh; refresh never allocates an
|
|
||||||
unbounded sequence of new keys. Retries re-create per-attempt resources so
|
|
||||||
a retry can actually succeed.
|
|
||||||
|
|||||||
@@ -22,16 +22,23 @@ interface BaseSplitChipEditorProps {
|
|||||||
error?: boolean
|
error?: boolean
|
||||||
helperText?: ReactNode
|
helperText?: ReactNode
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
rows?: number
|
||||||
separator?: string
|
separator?: string
|
||||||
|
splitPattern?: RegExp
|
||||||
|
defaultMode?: BaseSplitChipEditorMode
|
||||||
|
showModeToggle?: boolean
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
|
addLabel?: ReactNode
|
||||||
|
emptyLabel?: ReactNode
|
||||||
|
modeLabels?: Partial<Record<BaseSplitChipEditorMode, ReactNode>>
|
||||||
renderHeader?: (modeToggle: ReactNode) => ReactNode
|
renderHeader?: (modeToggle: ReactNode) => ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/
|
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/
|
||||||
|
|
||||||
const splitValue = (value: string) =>
|
const splitValue = (value: string, splitPattern: RegExp) =>
|
||||||
value
|
value
|
||||||
.split(DEFAULT_SPLIT_PATTERN)
|
.split(splitPattern)
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
@@ -42,25 +49,35 @@ export const BaseSplitChipEditor = ({
|
|||||||
error = false,
|
error = false,
|
||||||
helperText,
|
helperText,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
rows = 4,
|
||||||
separator = ',',
|
separator = ',',
|
||||||
|
splitPattern = DEFAULT_SPLIT_PATTERN,
|
||||||
|
defaultMode = 'visual',
|
||||||
|
showModeToggle = true,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
|
addLabel,
|
||||||
|
emptyLabel,
|
||||||
|
modeLabels,
|
||||||
renderHeader,
|
renderHeader,
|
||||||
}: BaseSplitChipEditorProps) => {
|
}: BaseSplitChipEditorProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [mode, setMode] = useState<BaseSplitChipEditorMode>('visual')
|
const [mode, setMode] = useState<BaseSplitChipEditorMode>(defaultMode)
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
|
|
||||||
const resolvedLabels = useMemo(
|
const resolvedLabels = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
visual: t('shared.editorModes.visualization'),
|
visual: modeLabels?.visual ?? t('shared.editorModes.visualization'),
|
||||||
advanced: t('shared.editorModes.advanced'),
|
advanced: modeLabels?.advanced ?? t('shared.editorModes.advanced'),
|
||||||
add: t('shared.actions.new'),
|
add: addLabel ?? t('shared.actions.new'),
|
||||||
empty: t('shared.statuses.empty'),
|
empty: emptyLabel ?? t('shared.statuses.empty'),
|
||||||
}),
|
}),
|
||||||
[t],
|
[t, modeLabels, addLabel, emptyLabel],
|
||||||
)
|
)
|
||||||
|
|
||||||
const values = useMemo(() => splitValue(value), [value])
|
const values = useMemo(
|
||||||
|
() => splitValue(value, splitPattern),
|
||||||
|
[value, splitPattern],
|
||||||
|
)
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const counts = new Map<string, number>()
|
const counts = new Map<string, number>()
|
||||||
@@ -75,7 +92,7 @@ export const BaseSplitChipEditor = ({
|
|||||||
}, [values])
|
}, [values])
|
||||||
|
|
||||||
const handleAddDraft = () => {
|
const handleAddDraft = () => {
|
||||||
const nextValues = splitValue(draft)
|
const nextValues = splitValue(draft, splitPattern)
|
||||||
if (!nextValues.length) {
|
if (!nextValues.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -96,7 +113,7 @@ export const BaseSplitChipEditor = ({
|
|||||||
const resolvedAriaLabel =
|
const resolvedAriaLabel =
|
||||||
ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined)
|
ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined)
|
||||||
|
|
||||||
const modeToggle = (
|
const modeToggle = showModeToggle ? (
|
||||||
<Tooltip title={toggleLabel}>
|
<Tooltip title={toggleLabel}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -111,7 +128,7 @@ export const BaseSplitChipEditor = ({
|
|||||||
<ToggleIcon fontSize="small" />
|
<ToggleIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -185,7 +202,7 @@ export const BaseSplitChipEditor = ({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
size="small"
|
size="small"
|
||||||
multiline
|
multiline
|
||||||
rows={4}
|
rows={rows}
|
||||||
sx={{ width: '100%' }}
|
sx={{ width: '100%' }}
|
||||||
value={value}
|
value={value}
|
||||||
helperText={helperText}
|
helperText={helperText}
|
||||||
|
|||||||
@@ -464,6 +464,21 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
]
|
]
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setColumnOrder((prevValue) => {
|
||||||
|
const baseFields = baseColumns.map((col) => col.field)
|
||||||
|
const prev = Array.isArray(prevValue) ? prevValue : []
|
||||||
|
const reconciled = reconcileColumnOrder(prev, baseFields)
|
||||||
|
if (
|
||||||
|
reconciled.length === prev.length &&
|
||||||
|
reconciled.every((field, i) => field === prev[i])
|
||||||
|
) {
|
||||||
|
return prevValue
|
||||||
|
}
|
||||||
|
return reconciled
|
||||||
|
})
|
||||||
|
}, [baseColumns, setColumnOrder])
|
||||||
|
|
||||||
const orderedColumns = useMemo(() => {
|
const orderedColumns = useMemo(() => {
|
||||||
const baseFields = baseColumns.map((column) => column.field)
|
const baseFields = baseColumns.map((column) => column.field)
|
||||||
const reconciledOrder = reconcileColumnOrder(columnOrder, baseFields)
|
const reconciledOrder = reconcileColumnOrder(columnOrder, baseFields)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { DeveloperBoardOutlined, RestartAltRounded } from '@mui/icons-material'
|
import { DeveloperBoardOutlined } from '@mui/icons-material'
|
||||||
import { Divider, IconButton, Stack, Tooltip, Typography } from '@mui/material'
|
import { Divider, Stack, Typography } from '@mui/material'
|
||||||
import { useLockFn } from 'ahooks'
|
import { useMemo } from 'react'
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { useClash } from '@/hooks/use-clash'
|
import { useClash } from '@/hooks/use-clash'
|
||||||
@@ -12,8 +11,6 @@ import {
|
|||||||
useRulesData,
|
useRulesData,
|
||||||
useSystemData,
|
useSystemData,
|
||||||
} from '@/providers/app-data-context'
|
} from '@/providers/app-data-context'
|
||||||
import { restartCore } from '@/services/cmds'
|
|
||||||
import { mutate } from '@/services/mutate'
|
|
||||||
|
|
||||||
import { EnhancedCard } from './enhanced-card'
|
import { EnhancedCard } from './enhanced-card'
|
||||||
|
|
||||||
@@ -33,23 +30,6 @@ export const ClashInfoCard = () => {
|
|||||||
const { rules } = useRulesData()
|
const { rules } = useRulesData()
|
||||||
const uptime = useAppUptime()
|
const uptime = useAppUptime()
|
||||||
const { systemProxyAddress } = useSystemData()
|
const { systemProxyAddress } = useSystemData()
|
||||||
const [restarting, setRestarting] = useState(false)
|
|
||||||
|
|
||||||
const onRestart = useLockFn(async () => {
|
|
||||||
setRestarting(true)
|
|
||||||
try {
|
|
||||||
await mutate(() => restartCore(), {
|
|
||||||
id: 'restart-core',
|
|
||||||
successNotice: t(
|
|
||||||
'settings.feedback.notifications.clash.restartSuccess',
|
|
||||||
),
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// the funnel already notified
|
|
||||||
} finally {
|
|
||||||
setRestarting(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 使用useMemo缓存格式化后的uptime,避免频繁计算
|
// 使用useMemo缓存格式化后的uptime,避免频繁计算
|
||||||
const formattedUptime = useMemo(() => formatUptime(uptime), [uptime])
|
const formattedUptime = useMemo(() => formatUptime(uptime), [uptime])
|
||||||
@@ -121,20 +101,7 @@ export const ClashInfoCard = () => {
|
|||||||
title={t('home.components.clashInfo.title')}
|
title={t('home.components.clashInfo.title')}
|
||||||
icon={<DeveloperBoardOutlined />}
|
icon={<DeveloperBoardOutlined />}
|
||||||
iconColor="warning"
|
iconColor="warning"
|
||||||
action={
|
action={null}
|
||||||
<Tooltip title={t('shared.actions.restart')}>
|
|
||||||
<span>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={t('shared.actions.restart')}
|
|
||||||
loading={restarting}
|
|
||||||
onClick={onRestart}
|
|
||||||
>
|
|
||||||
<RestartAltRounded />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{cardContent}
|
{cardContent}
|
||||||
</EnhancedCard>
|
</EnhancedCard>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @eslint-react/set-state-in-effect */
|
||||||
import {
|
import {
|
||||||
AccessTimeRounded,
|
AccessTimeRounded,
|
||||||
ArrowDropDown,
|
ArrowDropDown,
|
||||||
@@ -30,15 +31,7 @@ import {
|
|||||||
useTheme,
|
useTheme,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useLockFn } from 'ahooks'
|
import { useLockFn } from 'ahooks'
|
||||||
import {
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useEffectEvent,
|
|
||||||
useId,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
@@ -577,22 +570,20 @@ export const CurrentProxyCard = () => {
|
|||||||
return profileValue
|
return profileValue
|
||||||
}
|
}
|
||||||
|
|
||||||
return profileKey !== baseKey ? localStorage.getItem(baseKey) : null
|
if (profileKey !== baseKey) {
|
||||||
|
const legacyValue = localStorage.getItem(baseKey)
|
||||||
|
if (legacyValue != null) {
|
||||||
|
localStorage.removeItem(baseKey)
|
||||||
|
localStorage.setItem(profileKey, legacyValue)
|
||||||
|
return legacyValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
},
|
},
|
||||||
[getProfileStorageKey],
|
[getProfileStorageKey],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Hand the pre-profile-scoping group key to the first profile that shows the card.
|
|
||||||
useEffect(() => {
|
|
||||||
const profileKey = getProfileStorageKey(STORAGE_KEY_GROUP)
|
|
||||||
if (profileKey === STORAGE_KEY_GROUP) return
|
|
||||||
if (localStorage.getItem(profileKey) != null) return
|
|
||||||
const legacyValue = localStorage.getItem(STORAGE_KEY_GROUP)
|
|
||||||
if (legacyValue == null) return
|
|
||||||
localStorage.removeItem(STORAGE_KEY_GROUP)
|
|
||||||
localStorage.setItem(profileKey, legacyValue)
|
|
||||||
}, [getProfileStorageKey])
|
|
||||||
|
|
||||||
const writeProfileScopedItem = useCallback(
|
const writeProfileScopedItem = useCallback(
|
||||||
(baseKey: string, value: string) => {
|
(baseKey: string, value: string) => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
@@ -605,7 +596,10 @@ export const CurrentProxyCard = () => {
|
|||||||
[getProfileStorageKey],
|
[getProfileStorageKey],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { select } = useProxySelection({
|
const { handleSelectChange } = useProxySelection({
|
||||||
|
onSuccess: () => {
|
||||||
|
refreshProxy()
|
||||||
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('代理切换失败', error)
|
console.error('代理切换失败', error)
|
||||||
refreshProxy()
|
refreshProxy()
|
||||||
@@ -621,17 +615,25 @@ export const CurrentProxyCard = () => {
|
|||||||
return savedSortType ? (Number(savedSortType) as ProxySortType) : 0
|
return savedSortType ? (Number(savedSortType) as ProxySortType) : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const [lastGroupName, setLastGroupName] = useState('')
|
const [selectedGroupName, setSelectedGroupName] = useState('')
|
||||||
const [openSelect, setOpenSelect] = useState<OpenSelect>(null)
|
const [openSelect, setOpenSelect] = useState<OpenSelect>(null)
|
||||||
const [openSelectMode, setOpenSelectMode] = useState(mode)
|
|
||||||
if (openSelectMode !== mode) {
|
useEffect(() => {
|
||||||
setOpenSelectMode(mode)
|
|
||||||
setOpenSelect(null)
|
setOpenSelect(null)
|
||||||
}
|
}, [mode])
|
||||||
|
|
||||||
const delayButtonRef = useRef<HTMLButtonElement>(null)
|
const delayButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const delays = useGroupDelays(selectedGroupName || null)
|
||||||
|
|
||||||
const autoCheckInProgressRef = useRef(false)
|
const autoCheckInProgressRef = useRef(false)
|
||||||
|
const latestTimeoutRef = useRef<number>(
|
||||||
|
verge?.default_latency_timeout || 10000,
|
||||||
|
)
|
||||||
|
const latestProxyMemberRef = useRef<ResolvedProxyMember | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
latestTimeoutRef.current = verge?.default_latency_timeout || 10000
|
||||||
|
}, [verge?.default_latency_timeout])
|
||||||
|
|
||||||
const selectableGroups = useMemo(() => {
|
const selectableGroups = useMemo(() => {
|
||||||
if (!proxyView) return []
|
if (!proxyView) return []
|
||||||
@@ -642,39 +644,6 @@ export const CurrentProxyCard = () => {
|
|||||||
)
|
)
|
||||||
}, [proxyView])
|
}, [proxyView])
|
||||||
|
|
||||||
const selectedGroupName = useMemo(() => {
|
|
||||||
if (isDirectMode) return 'DIRECT'
|
|
||||||
if (!proxyView) return lastGroupName
|
|
||||||
if (isGlobalMode) return proxyView.global?.name ?? 'GLOBAL'
|
|
||||||
|
|
||||||
const hasGroup = (name: string | null) =>
|
|
||||||
!!name && selectableGroups.some((group) => group.name === name)
|
|
||||||
if (hasGroup(lastGroupName)) return lastGroupName
|
|
||||||
const savedGroup = readProfileScopedItem(STORAGE_KEY_GROUP)
|
|
||||||
if (hasGroup(savedGroup)) return savedGroup!
|
|
||||||
|
|
||||||
const primaryKeywords = ['auto', 'select', 'proxy', '节点选择', '自动选择']
|
|
||||||
const primaryGroup =
|
|
||||||
selectableGroups.find((group) =>
|
|
||||||
primaryKeywords.some((keyword) =>
|
|
||||||
group.name.toLowerCase().includes(keyword.toLowerCase()),
|
|
||||||
),
|
|
||||||
) ?? selectableGroups[0]
|
|
||||||
return primaryGroup?.name ?? ''
|
|
||||||
}, [
|
|
||||||
isDirectMode,
|
|
||||||
isGlobalMode,
|
|
||||||
lastGroupName,
|
|
||||||
proxyView,
|
|
||||||
readProfileScopedItem,
|
|
||||||
selectableGroups,
|
|
||||||
])
|
|
||||||
// Keep the shown group, auto picks included, until it leaves the profile.
|
|
||||||
if (proxyView && lastGroupName !== selectedGroupName) {
|
|
||||||
setLastGroupName(selectedGroupName)
|
|
||||||
}
|
|
||||||
const delays = useGroupDelays(selectedGroupName || null)
|
|
||||||
|
|
||||||
const selectedGroup = useMemo<ProxyGroupView | null>(() => {
|
const selectedGroup = useMemo<ProxyGroupView | null>(() => {
|
||||||
if (!proxyView || isDirectMode) return null
|
if (!proxyView || isDirectMode) return null
|
||||||
if (isGlobalMode) return proxyView.global
|
if (isGlobalMode) return proxyView.global
|
||||||
@@ -711,6 +680,46 @@ export const CurrentProxyCard = () => {
|
|||||||
[optionsForGroup, selectedGroup],
|
[optionsForGroup, selectedGroup],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!proxyView) return
|
||||||
|
if (isDirectMode) {
|
||||||
|
setSelectedGroupName('DIRECT')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isGlobalMode) {
|
||||||
|
setSelectedGroupName(proxyView.global?.name ?? 'GLOBAL')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedGroup = readProfileScopedItem(STORAGE_KEY_GROUP)
|
||||||
|
const primaryKeywords = ['auto', 'select', 'proxy', '节点选择', '自动选择']
|
||||||
|
const primaryGroup =
|
||||||
|
selectableGroups.find((group) =>
|
||||||
|
primaryKeywords.some((keyword) =>
|
||||||
|
group.name.toLowerCase().includes(keyword.toLowerCase()),
|
||||||
|
),
|
||||||
|
) ?? selectableGroups[0]
|
||||||
|
const nextGroup = selectableGroups.some(
|
||||||
|
({ name }) => name === selectedGroupName,
|
||||||
|
)
|
||||||
|
? selectedGroupName
|
||||||
|
: selectableGroups.some(({ name }) => name === savedGroup)
|
||||||
|
? savedGroup!
|
||||||
|
: (primaryGroup?.name ?? '')
|
||||||
|
if (nextGroup !== selectedGroupName) {
|
||||||
|
setSelectedGroupName(nextGroup)
|
||||||
|
if (nextGroup) writeProfileScopedItem(STORAGE_KEY_GROUP, nextGroup)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isDirectMode,
|
||||||
|
isGlobalMode,
|
||||||
|
proxyView,
|
||||||
|
readProfileScopedItem,
|
||||||
|
selectableGroups,
|
||||||
|
selectedGroupName,
|
||||||
|
writeProfileScopedItem,
|
||||||
|
])
|
||||||
|
|
||||||
const currentOption = useMemo(() => {
|
const currentOption = useMemo(() => {
|
||||||
if (!proxyView) return undefined
|
if (!proxyView) return undefined
|
||||||
if (isDirectMode) {
|
if (isDirectMode) {
|
||||||
@@ -734,10 +743,12 @@ export const CurrentProxyCard = () => {
|
|||||||
: undefined
|
: undefined
|
||||||
}, [isDirectMode, proxyView, selectedGroup])
|
}, [isDirectMode, proxyView, selectedGroup])
|
||||||
|
|
||||||
|
latestProxyMemberRef.current = currentOption?.member ?? null
|
||||||
|
|
||||||
const handleGroupChange = useCallback(
|
const handleGroupChange = useCallback(
|
||||||
(newGroupName: string) => {
|
(newGroupName: string) => {
|
||||||
if (isGlobalMode || isDirectMode) return
|
if (isGlobalMode || isDirectMode) return
|
||||||
setLastGroupName(newGroupName)
|
setSelectedGroupName(newGroupName)
|
||||||
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName)
|
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName)
|
||||||
},
|
},
|
||||||
[isDirectMode, isGlobalMode, writeProfileScopedItem],
|
[isDirectMode, isGlobalMode, writeProfileScopedItem],
|
||||||
@@ -752,11 +763,18 @@ export const CurrentProxyCard = () => {
|
|||||||
if (!selectedGroup || !option || !isInteractableMember(option.member)) {
|
if (!selectedGroup || !option || !isInteractableMember(option.member)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const previousProxy = selectedGroup.now
|
||||||
const nextName = option.member.ref.name
|
const nextName = option.member.ref.name
|
||||||
// The profile selection is the durable source across core restarts and run modes.
|
// The profile selection is the durable source across core restarts and run modes.
|
||||||
select(selectedGroup.name, nextName, selectedGroup.fixed!)
|
handleSelectChange(
|
||||||
|
selectedGroup.name,
|
||||||
|
previousProxy!,
|
||||||
|
selectedGroup.fixed!,
|
||||||
|
)({
|
||||||
|
target: { value: nextName },
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[isDirectMode, select, selectedGroup, unsortedProxyOptions],
|
[handleSelectChange, isDirectMode, selectedGroup, unsortedProxyOptions],
|
||||||
)
|
)
|
||||||
|
|
||||||
const goToProxies = useCallback(() => {
|
const goToProxies = useCallback(() => {
|
||||||
@@ -781,7 +799,7 @@ export const CurrentProxyCard = () => {
|
|||||||
color: 'text.secondary',
|
color: 'text.secondary',
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkCurrentProxyDelay = useEffectEvent(async () => {
|
const checkCurrentProxyDelay = useCallback(async () => {
|
||||||
if (autoCheckInProgressRef.current) return
|
if (autoCheckInProgressRef.current) return
|
||||||
if (isDirectMode) return
|
if (isDirectMode) return
|
||||||
|
|
||||||
@@ -790,7 +808,7 @@ export const CurrentProxyCard = () => {
|
|||||||
|
|
||||||
if (!groupName || !proxyName) return
|
if (!groupName || !proxyName) return
|
||||||
|
|
||||||
const proxyMember = currentOption?.member
|
const proxyMember = latestProxyMemberRef.current
|
||||||
if (!proxyMember || !isInteractableMember(proxyMember)) {
|
if (!proxyMember || !isInteractableMember(proxyMember)) {
|
||||||
debugLog(
|
debugLog(
|
||||||
`[CurrentProxyCard] 自动延迟检测跳过,组: ${groupName}, 节点: ${proxyName} 未找到`,
|
`[CurrentProxyCard] 自动延迟检测跳过,组: ${groupName}, 节点: ${proxyName} 未找到`,
|
||||||
@@ -800,7 +818,7 @@ export const CurrentProxyCard = () => {
|
|||||||
|
|
||||||
autoCheckInProgressRef.current = true
|
autoCheckInProgressRef.current = true
|
||||||
|
|
||||||
const timeout = verge?.default_latency_timeout || 10000
|
const timeout = latestTimeoutRef.current || 10000
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugLog(
|
debugLog(
|
||||||
@@ -816,7 +834,7 @@ export const CurrentProxyCard = () => {
|
|||||||
autoCheckInProgressRef.current = false
|
autoCheckInProgressRef.current = false
|
||||||
refreshProxy()
|
refreshProxy()
|
||||||
}
|
}
|
||||||
})
|
}, [isDirectMode, refreshProxy, selectedGroupName, selectedProxyName])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDirectMode) return
|
if (isDirectMode) return
|
||||||
@@ -846,6 +864,7 @@ export const CurrentProxyCard = () => {
|
|||||||
if (intervalTimer) clearTimeout(intervalTimer)
|
if (intervalTimer) clearTimeout(intervalTimer)
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
checkCurrentProxyDelay,
|
||||||
autoDelayIntervalMs,
|
autoDelayIntervalMs,
|
||||||
isDirectMode,
|
isDirectMode,
|
||||||
selectedGroupName,
|
selectedGroupName,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useEffectEvent,
|
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
useReducer,
|
useReducer,
|
||||||
@@ -15,6 +14,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
|
|
||||||
import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor'
|
import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor'
|
||||||
import { useVerge } from '@/hooks/use-verge'
|
import { useVerge } from '@/hooks/use-verge'
|
||||||
|
import { debugLog } from '@/utils/debug'
|
||||||
import parseTraffic from '@/utils/parse-traffic'
|
import parseTraffic from '@/utils/parse-traffic'
|
||||||
import {
|
import {
|
||||||
formatTrafficHourMinute,
|
formatTrafficHourMinute,
|
||||||
@@ -23,7 +23,14 @@ import {
|
|||||||
isSameTrafficData,
|
isSameTrafficData,
|
||||||
} from '@/utils/traffic-sampler'
|
} from '@/utils/traffic-sampler'
|
||||||
|
|
||||||
|
interface ITrafficItem {
|
||||||
|
up: number
|
||||||
|
down: number
|
||||||
|
timestamp?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnhancedCanvasTrafficGraphRef {
|
export interface EnhancedCanvasTrafficGraphRef {
|
||||||
|
appendData: (data: ITrafficItem) => void
|
||||||
toggleStyle: () => void
|
toggleStyle: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +128,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
const drawFrameRef = useRef<number | undefined>(undefined)
|
const drawFrameRef = useRef<number | undefined>(undefined)
|
||||||
const hoverFrameRef = useRef<number | undefined>(undefined)
|
const hoverFrameRef = useRef<number | undefined>(undefined)
|
||||||
const mouseMoveFrameRef = useRef<number | undefined>(undefined)
|
const mouseMoveFrameRef = useRef<number | undefined>(undefined)
|
||||||
|
const scheduleDrawGraphRef = useRef<() => void>(() => {})
|
||||||
const pendingMousePositionRef = useRef<{
|
const pendingMousePositionRef = useRef<{
|
||||||
clientX: number
|
clientX: number
|
||||||
clientY: number
|
clientY: number
|
||||||
@@ -134,6 +142,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
const debounceTimeoutRef = useRef<number | null>(null)
|
const debounceTimeoutRef = useRef<number | null>(null)
|
||||||
|
const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS)
|
||||||
|
|
||||||
const colors = useMemo(
|
const colors = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -174,6 +183,8 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
if (displayData.length === 0) {
|
if (displayData.length === 0) {
|
||||||
lastDataTimestampRef.current = 0
|
lastDataTimestampRef.current = 0
|
||||||
dataStaleRef.current = false
|
dataStaleRef.current = false
|
||||||
|
// eslint-disable-next-line @eslint-react/set-state-in-effect
|
||||||
|
setCurrentFPS(GRAPH_CONFIG.targetFPS)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,11 +200,18 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
}
|
}
|
||||||
}, [displayData])
|
}, [displayData])
|
||||||
|
|
||||||
const handleFocusStateChange = useEffectEvent((focused: boolean) => {
|
const handleFocusStateChange = useCallback(
|
||||||
isWindowFocusedRef.current = focused
|
(focused: boolean) => {
|
||||||
|
isWindowFocusedRef.current = focused
|
||||||
|
|
||||||
scheduleDrawGraph()
|
if (focused || !pause_render_traffic_stats_on_blur) {
|
||||||
})
|
setCurrentFPS(GRAPH_CONFIG.targetFPS)
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleDrawGraphRef.current()
|
||||||
|
},
|
||||||
|
[pause_render_traffic_stats_on_blur],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
@@ -217,7 +235,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
window.removeEventListener('blur', handleBlur)
|
window.removeEventListener('blur', handleBlur)
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [handleFocusStateChange])
|
||||||
|
|
||||||
const calculateY = useCallback(
|
const calculateY = useCallback(
|
||||||
(
|
(
|
||||||
@@ -666,13 +684,19 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.moveTo(getX(0), getY(0))
|
ctx.moveTo(getX(0), getY(0))
|
||||||
|
|
||||||
for (let i = 1; i < data.length; i++) {
|
if (chartStyle === 'bezier') {
|
||||||
const currentX = getX(i)
|
for (let i = 1; i < data.length; i++) {
|
||||||
const currentY = getY(i)
|
const currentX = getX(i)
|
||||||
const nextIndex = Math.min(i + 1, lastIndex)
|
const currentY = getY(i)
|
||||||
const controlX = (currentX + getX(nextIndex)) / 2
|
const nextIndex = Math.min(i + 1, lastIndex)
|
||||||
const controlY = (currentY + getY(nextIndex)) / 2
|
const controlX = (currentX + getX(nextIndex)) / 2
|
||||||
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY)
|
const controlY = (currentY + getY(nextIndex)) / 2
|
||||||
|
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 1; i < data.length; i++) {
|
||||||
|
ctx.lineTo(getX(i), getY(i))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.lineTo(getX(lastIndex), height - padding.bottom)
|
ctx.lineTo(getX(lastIndex), height - padding.bottom)
|
||||||
@@ -906,21 +930,25 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
scheduleDrawGraph()
|
scheduleDrawGraph()
|
||||||
}, [scheduleDrawGraph])
|
}, [scheduleDrawGraph])
|
||||||
|
|
||||||
const redrawGraph = useEffectEvent(() => scheduleDrawGraph())
|
useEffect(() => {
|
||||||
|
scheduleDrawGraphRef.current = scheduleDrawGraph
|
||||||
|
}, [scheduleDrawGraph])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
if (!canvas || typeof window === 'undefined') return
|
if (!canvas || typeof window === 'undefined') return
|
||||||
|
|
||||||
if (typeof ResizeObserver === 'undefined') {
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
const handleResize = () => redrawGraph()
|
const handleResize = () => scheduleDrawGraphRef.current()
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => redrawGraph())
|
const resizeObserver = new ResizeObserver(() =>
|
||||||
|
scheduleDrawGraphRef.current(),
|
||||||
|
)
|
||||||
resizeObserver.observe(canvas)
|
resizeObserver.observe(canvas)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -956,7 +984,21 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier'))
|
setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier'))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({ toggleStyle }), [toggleStyle])
|
const appendData = useCallback((data: ITrafficItem) => {
|
||||||
|
debugLog(
|
||||||
|
'[EnhancedCanvasTrafficGraphV2] appendData called (using global data):',
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
appendData,
|
||||||
|
toggleStyle,
|
||||||
|
}),
|
||||||
|
[appendData, toggleStyle],
|
||||||
|
)
|
||||||
|
|
||||||
const getTimeRangeText = useCallback(() => {
|
const getTimeRangeText = useCallback(() => {
|
||||||
return t('home.components.traffic.patterns.minutes', {
|
return t('home.components.traffic.patterns.minutes', {
|
||||||
@@ -1100,7 +1142,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
{t('home.components.traffic.diagnostics', {
|
{t('home.components.traffic.diagnostics', {
|
||||||
points: displayData.length,
|
points: displayData.length,
|
||||||
compressed: samplerStats.compressedBufferSize,
|
compressed: samplerStats.compressedBufferSize,
|
||||||
fps: GRAPH_CONFIG.targetFPS,
|
fps: currentFPS,
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user