mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-15 22:00:56 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28f2efc504 | ||
|
|
12aa989af1 | ||
|
|
53a0971826 | ||
|
|
50d828d0d7 | ||
|
|
6e9288437f | ||
|
|
6bbe8bd37d | ||
|
|
4b04d035b6 | ||
|
|
becd86c179 | ||
|
|
b31437f727 | ||
|
|
b7978705af | ||
|
|
950e8195fb | ||
|
|
bdeaf2a062 | ||
|
|
f6440175d8 | ||
|
|
96e9564295 | ||
|
|
d327a435ff | ||
|
|
f09aef170d | ||
|
|
d144e78aeb | ||
|
|
6ea7f544b2 | ||
|
|
cd20bdb6a1 | ||
|
|
6c149d6234 | ||
|
|
993cc2ffb1 | ||
|
|
f1e0622813 | ||
|
|
7ca20fc4ed | ||
|
|
6f9fb97b31 | ||
|
|
eaf4fc2c17 | ||
|
|
7155047e02 | ||
|
|
0337340471 | ||
|
|
14bc636ee8 | ||
|
|
656b6b6d44 | ||
|
|
604fdb9252 | ||
|
|
6ad480f898 | ||
|
|
0c86b70fe9 | ||
|
|
6d3761a9ee | ||
|
|
ab1e15dceb | ||
|
|
34fee12496 | ||
|
|
1847002ebe | ||
|
|
562828e14d | ||
|
|
e8cefa8b07 | ||
|
|
b53227dab5 | ||
|
|
2557e9a222 | ||
|
|
7085d2bea7 | ||
|
|
7e3544cbcc | ||
|
|
979defc070 | ||
|
|
406c0ab5a7 | ||
|
|
8bf5fc1aca | ||
|
|
d4118295a3 | ||
|
|
d7fca9fd70 | ||
|
|
bd90d159ee | ||
|
|
be15131080 | ||
|
|
c8af762d8b | ||
|
|
afbc14840c | ||
|
|
1d7462a021 | ||
|
|
20ac214a83 | ||
|
|
0407384557 | ||
|
|
b3f07acb5b | ||
|
|
3da6d7e466 | ||
|
|
49ea0cd5df | ||
|
|
ac156afc99 | ||
|
|
c4658cff88 | ||
|
|
4fa2f9a329 | ||
|
|
1f76c67afb | ||
|
|
336bd9d735 | ||
|
|
e578e473d5 | ||
|
|
dd3ef698f5 | ||
|
|
c2ddb5c111 | ||
|
|
af0518d578 | ||
|
|
9a2f4ff8fb | ||
|
|
e176e4a81e | ||
|
|
728c7535c4 | ||
|
|
ad68e445dd | ||
|
|
a862667d9b | ||
|
|
4aec41d3be | ||
|
|
5fd2175b06 | ||
|
|
fa712b59c9 | ||
|
|
deae6442a7 | ||
|
|
520746428c | ||
|
|
3b52fceca1 | ||
|
|
3ce228ff7a | ||
|
|
2442634b2a | ||
|
|
4ec3a9bf4c | ||
|
|
2a5d29cdb2 | ||
|
|
3a4cd2c361 | ||
|
|
250198d547 | ||
|
|
55060d422a | ||
|
|
dad5121b7d | ||
|
|
dad18ce90f | ||
|
|
ac8a9ca7d8 | ||
|
|
1d3a7107d9 | ||
|
|
5ef85bb269 | ||
|
|
b44f671fc2 | ||
|
|
b9da0875cd | ||
|
|
e71e701fd0 | ||
|
|
294955cb8c | ||
|
|
16e15ed9d4 | ||
|
|
86072e599d | ||
|
|
b7157fac15 | ||
|
|
7cad713cac | ||
|
|
1112f0730d | ||
|
|
1e44bafa79 | ||
|
|
4c46223107 | ||
|
|
b7a454fedb | ||
|
|
5a96fdd9a2 | ||
|
|
d0d2f4e4f5 | ||
|
|
1026ba88bb | ||
|
|
a00df6c93d | ||
|
|
9b87cd53e1 | ||
|
|
b4a490fbeb | ||
|
|
de438aadf8 | ||
|
|
688dc0b9e1 | ||
|
|
651258479b | ||
|
|
468483660a | ||
|
|
138901f26b | ||
|
|
9867a2af99 | ||
|
|
a44173e3c7 | ||
|
|
4ee63b6cf2 | ||
|
|
f7ed126bb8 | ||
|
|
fd8c852698 | ||
|
|
42c9e19465 | ||
|
|
e5c1c852d7 | ||
|
|
a528b534b4 | ||
|
|
7e233efd10 | ||
|
|
57b891215e | ||
|
|
4105dc7479 | ||
|
|
aa0d5f6004 | ||
|
|
67cc2a63af | ||
|
|
276afb2429 | ||
|
|
894c73d602 | ||
|
|
220474888c | ||
|
|
658312fe7b | ||
|
|
55ce7bffe4 | ||
|
|
2ddd95cf40 | ||
|
|
f763fcb244 | ||
|
|
1e57f41190 | ||
|
|
27161876f0 | ||
|
|
e1380b9bab | ||
|
|
358dff9c62 | ||
|
|
35c534ae1c | ||
|
|
b7f8ca0a83 | ||
|
|
3c3d3f2443 | ||
|
|
6a0cc21244 | ||
|
|
ee7f66f49c | ||
|
|
b79e3a0569 | ||
|
|
2828782e4c | ||
|
|
ca325f1cb8 | ||
|
|
45afb1c15a | ||
|
|
ee9d5126d3 | ||
|
|
66d104593b | ||
|
|
bc2149c66d | ||
|
|
462d9f536d | ||
|
|
b5b41956e2 | ||
|
|
f1df8afb76 | ||
|
|
595f28d428 | ||
|
|
fa27d3eae5 | ||
|
|
2cb9c13ab6 | ||
|
|
9c639bfcc5 | ||
|
|
232be33754 | ||
|
|
3444d594ac | ||
|
|
6e6c537e6b | ||
|
|
08fd2ce68b | ||
|
|
43843f1eca | ||
|
|
dc094033b8 | ||
|
|
f916711717 | ||
|
|
761209bbbc | ||
|
|
a287d13b4d | ||
|
|
22c516f550 | ||
|
|
f9ffe0af8e | ||
|
|
c371f95815 | ||
|
|
2e0c783e66 | ||
|
|
512efe7d41 | ||
|
|
28139706d2 | ||
|
|
8752130f75 | ||
|
|
32ed10c25d | ||
|
|
c7cdc62512 | ||
|
|
435d611114 | ||
|
|
29fc8c9f4c | ||
|
|
746279fa03 | ||
|
|
e2d14a5ea7 | ||
|
|
d75c4cf59e | ||
|
|
ec9ef0f85e | ||
|
|
8ccb0b9abd | ||
|
|
2a397d7ef6 | ||
|
|
aa478bfe1a | ||
|
|
c18c23963d | ||
|
|
d3bb018ab7 | ||
|
|
95be5aa816 | ||
|
|
32029af0f9 | ||
|
|
d2f09211dd | ||
|
|
bdb5807d17 | ||
|
|
e25732eb9d | ||
|
|
2c7877a574 | ||
|
|
e48e24bb7e | ||
|
|
21878ea936 | ||
|
|
54d130a0c4 | ||
|
|
a08d625712 | ||
|
|
bedcb8a07c | ||
|
|
f7c9e36891 | ||
|
|
a45610e534 | ||
|
|
8efbf9e7fd | ||
|
|
c7a989b484 | ||
|
|
d05ead9cdd | ||
|
|
bdbc617aff | ||
|
|
df92e5d95d | ||
|
|
0113086d2a | ||
|
|
ba4ee35374 | ||
|
|
631c9d1b61 | ||
|
|
88f1927aa5 | ||
|
|
8c76e0f4f6 | ||
|
|
46fb817fe4 | ||
|
|
a00c67e8ae | ||
|
|
6ebdaaa7ff | ||
|
|
15de400f2f | ||
|
|
6f36613103 |
@@ -13,12 +13,19 @@ This is a **dispatcher agent** that routes your request to the appropriate speci
|
||||
|
||||
- **Creating new workflows**: Routes to `create` prompt
|
||||
- **Updating existing workflows**: Routes to `update` prompt
|
||||
- **Debugging workflows**: Routes to `debug` prompt
|
||||
- **Debugging workflows**: Routes to `debug` prompt
|
||||
- **Upgrading workflows**: Routes to `upgrade-agentic-workflows` prompt
|
||||
- **Creating report-generating workflows**: Routes to `report` prompt — consult this whenever the workflow posts status updates, audits, analyses, or any structured output as issues, discussions, or comments
|
||||
- **Creating shared components**: Routes to `create-shared-agentic-workflow` prompt
|
||||
- **Fixing Dependabot PRs**: Routes to `dependabot` prompt — use this when Dependabot opens PRs that modify generated manifest files (`.github/workflows/package.json`, `.github/workflows/requirements.txt`, `.github/workflows/go.mod`). Never merge those PRs directly; instead update the source `.md` files and rerun `gh aw compile --dependabot` to bundle all fixes
|
||||
- **Analyzing test coverage**: Routes to `test-coverage` prompt — consult this whenever the workflow reads, analyzes, or reports on test coverage data from PRs or CI runs
|
||||
- **Rendering ASCII charts in markdown**: Routes to `asciicharts` guide — consult this whenever the workflow needs compact charts that render reliably in GitHub issues, comments, or discussions
|
||||
- **CLI commands and triggering workflows**: Routes to `cli-commands` guide — consult this whenever the user asks how to run, compile, debug, or manage workflows from the command line, or when they need the MCP tool equivalent of a `gh aw` command
|
||||
- **Reducing token consumption / cost optimization**: Routes to `token-optimization` guide — consult this whenever the user asks how to reduce token usage, lower costs, speed up workflows, or measure the impact of prompt changes with experiments
|
||||
- **Choosing workflow architectures and design patterns**: Routes to `patterns` guide — consult this whenever the user asks for strategy, architecture, operating models, or pattern selection for agentic workflows
|
||||
|
||||
> [!IMPORTANT]
|
||||
> For architecture/pattern-selection requests, load `https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/patterns.md` first.
|
||||
|
||||
Workflows may optionally include:
|
||||
|
||||
@@ -30,7 +37,7 @@ Workflows may optionally include:
|
||||
- Workflow files: `.github/workflows/*.md` and `.github/workflows/**/*.md`
|
||||
- Workflow lock files: `.github/workflows/*.lock.yml`
|
||||
- Shared components: `.github/workflows/shared/*.md`
|
||||
- Configuration: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/github-agentic-workflows.md
|
||||
- Configuration: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/github-agentic-workflows.md
|
||||
|
||||
## Problems This Solves
|
||||
|
||||
@@ -52,27 +59,27 @@ When you interact with this agent, it will:
|
||||
### Create New Workflow
|
||||
**Load when**: User wants to create a new workflow from scratch, add automation, or design a workflow that doesn't exist yet
|
||||
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/create-agentic-workflow.md
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/create-agentic-workflow.md
|
||||
|
||||
**Use cases**:
|
||||
- "Create a workflow that triages issues"
|
||||
- "I need a workflow to label pull requests"
|
||||
- "Design a weekly research automation"
|
||||
|
||||
### Update Existing Workflow
|
||||
### Update Existing Workflow
|
||||
**Load when**: User wants to modify, improve, or refactor an existing workflow
|
||||
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/update-agentic-workflow.md
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/update-agentic-workflow.md
|
||||
|
||||
**Use cases**:
|
||||
- "Add web-fetch tool to the issue-classifier workflow"
|
||||
- "Update the PR reviewer to use discussions instead of issues"
|
||||
- "Improve the prompt for the weekly-research workflow"
|
||||
|
||||
### Debug Workflow
|
||||
### Debug Workflow
|
||||
**Load when**: User needs to investigate, audit, debug, or understand a workflow, troubleshoot issues, analyze logs, or fix errors
|
||||
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/debug-agentic-workflow.md
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/debug-agentic-workflow.md
|
||||
|
||||
**Use cases**:
|
||||
- "Why is this workflow failing?"
|
||||
@@ -82,7 +89,7 @@ When you interact with this agent, it will:
|
||||
### Upgrade Agentic Workflows
|
||||
**Load when**: User wants to upgrade workflows to a new gh-aw version or fix deprecations
|
||||
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/upgrade-agentic-workflows.md
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/upgrade-agentic-workflows.md
|
||||
|
||||
**Use cases**:
|
||||
- "Upgrade all workflows to the latest version"
|
||||
@@ -92,7 +99,7 @@ When you interact with this agent, it will:
|
||||
### Create a Report-Generating Workflow
|
||||
**Load when**: The workflow being created or updated produces reports — recurring status updates, audit summaries, analyses, or any structured output posted as a GitHub issue, discussion, or comment
|
||||
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/report.md
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/report.md
|
||||
|
||||
**Use cases**:
|
||||
- "Create a weekly CI health report"
|
||||
@@ -102,7 +109,7 @@ When you interact with this agent, it will:
|
||||
### Create Shared Agentic Workflow
|
||||
**Load when**: User wants to create a reusable workflow component or wrap an MCP server
|
||||
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/create-shared-agentic-workflow.md
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/create-shared-agentic-workflow.md
|
||||
|
||||
**Use cases**:
|
||||
- "Create a shared component for Notion integration"
|
||||
@@ -112,7 +119,7 @@ When you interact with this agent, it will:
|
||||
### Fix Dependabot PRs
|
||||
**Load when**: User needs to close or fix open Dependabot PRs that update dependencies in generated manifest files (`.github/workflows/package.json`, `.github/workflows/requirements.txt`, `.github/workflows/go.mod`)
|
||||
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/dependabot.md
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/dependabot.md
|
||||
|
||||
**Use cases**:
|
||||
- "Fix the open Dependabot PRs for npm dependencies"
|
||||
@@ -122,13 +129,58 @@ When you interact with this agent, it will:
|
||||
### Analyze Test Coverage
|
||||
**Load when**: The workflow reads, analyzes, or reports test coverage — whether triggered by a PR, a schedule, or a slash command. Always consult this prompt before designing the coverage data strategy.
|
||||
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/test-coverage.md
|
||||
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/test-coverage.md
|
||||
|
||||
**Use cases**:
|
||||
- "Create a workflow that comments coverage on PRs"
|
||||
- "Analyze coverage trends over time"
|
||||
- "Add a coverage gate that blocks PRs below a threshold"
|
||||
|
||||
### Render ASCII Charts in Markdown
|
||||
**Load when**: The workflow needs in-markdown charts (sparklines, bars, table+trend views) that must align cleanly and render reliably across GitHub surfaces, including mobile.
|
||||
|
||||
**Reference file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/asciicharts.md
|
||||
|
||||
**Use cases**:
|
||||
- "Show a compact trend chart in an issue comment"
|
||||
- "Render a dashboard table with sparkline trends"
|
||||
- "Generate aligned ASCII bars for service metrics"
|
||||
|
||||
### CLI Commands Reference
|
||||
**Load when**: The user asks how to run, compile, debug, or manage workflows from the command line; needs the MCP tool equivalent of a `gh aw` command; or is in a restricted environment (e.g., Copilot Cloud) without direct CLI access.
|
||||
|
||||
**Reference file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/cli-commands.md
|
||||
|
||||
**Use cases**:
|
||||
- "How do I trigger workflow X on the main branch?"
|
||||
- "What's the MCP equivalent of `gh aw logs`?"
|
||||
- "I'm in Copilot Cloud — how do I compile a workflow?"
|
||||
- "Show me all available gh aw commands"
|
||||
|
||||
### Token Consumption Optimization
|
||||
**Load when**: The user asks how to reduce token usage, lower workflow costs, make a workflow faster or cheaper, or measure the impact of prompt or configuration changes.
|
||||
|
||||
**Reference file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/token-optimization.md
|
||||
|
||||
**Use cases**:
|
||||
- "How do I reduce the token cost of this workflow?"
|
||||
- "My workflow is too expensive — how do I optimize it?"
|
||||
- "How do I compare token usage between two runs?"
|
||||
- "Should I use gh-proxy or the MCP server?"
|
||||
- "How do I use sub-agents to reduce costs?"
|
||||
- "How do I measure the impact of a prompt change?"
|
||||
|
||||
### Workflow Pattern Selection
|
||||
**Load when**: The user asks for architecture, strategy, operating model selection, or pattern recommendations for building agentic workflows.
|
||||
|
||||
**Reference file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/patterns.md
|
||||
|
||||
**Use cases**:
|
||||
- "Which pattern should I use for multi-repo rollout?"
|
||||
- "How should I structure this workflow architecture?"
|
||||
- "What pattern fits slash-command triage?"
|
||||
- "Should this be DispatchOps or DailyOps?"
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user interacts with you:
|
||||
@@ -147,6 +199,10 @@ gh aw init
|
||||
# Generate the lock file for a workflow
|
||||
gh aw compile [workflow-name]
|
||||
|
||||
# Trigger a workflow on demand (preferred over gh workflow run)
|
||||
gh aw run <workflow-name> # interactive input collection
|
||||
gh aw run <workflow-name> --ref main # run on a specific branch
|
||||
|
||||
# Debug workflow runs
|
||||
gh aw logs [workflow-name]
|
||||
gh aw audit <run-id>
|
||||
@@ -169,10 +225,12 @@ gh aw compile --validate
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Always reference the instructions file at https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/github-agentic-workflows.md for complete documentation
|
||||
- Always reference the instructions file at https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/github-agentic-workflows.md for complete documentation
|
||||
- Use the MCP tool `agentic-workflows` when running in GitHub Copilot Cloud
|
||||
- Workflows must be compiled to `.lock.yml` files before running in GitHub Actions
|
||||
- **Bash tools are enabled by default** - Don't restrict bash commands unnecessarily since workflows are sandboxed by the AWF
|
||||
- Follow security best practices: minimal permissions, explicit network access, no template injection
|
||||
- **Network configuration**: Use ecosystem identifiers (`node`, `python`, `go`, etc.) or explicit FQDNs in `network.allowed`. Bare shorthands like `npm` or `pypi` are **not** valid. See https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/network.md for the full list of valid ecosystem identifiers and domain patterns.
|
||||
- **Network configuration**: Use ecosystem identifiers (`node`, `python`, `go`, etc.) or explicit FQDNs in `network.allowed`. Bare shorthands like `npm` or `pypi` are **not** valid. See https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/network.md for the full list of valid ecosystem identifiers and domain patterns.
|
||||
- **Single-file output**: When creating a workflow, produce exactly **one** workflow `.md` file. Do not create separate documentation files (architecture docs, runbooks, usage guides, etc.). If documentation is needed, add a brief `## Usage` section inside the workflow file itself.
|
||||
- **Triggering runs**: Always use `gh aw run <workflow-name>` to trigger a workflow on demand — not `gh workflow run <file>.lock.yml`. `gh aw run` handles workflow resolution by short name, input parsing and validation, and correct run-tracking for agentic workflows. Use `--ref <branch>` to run on a specific branch.
|
||||
- **CLI commands reference**: For a complete guide on all `gh aw` commands and their MCP tool equivalents (for restricted environments), see https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/cli-commands.md
|
||||
|
||||
@@ -3,17 +3,69 @@
|
||||
"actions/github-script@v9.0.0": {
|
||||
"repo": "actions/github-script",
|
||||
"version": "v9.0.0",
|
||||
"sha": "d746ffe35508b1917358783b479e04febd2b8f71"
|
||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||
},
|
||||
"github/gh-aw-actions/setup@v0.68.1": {
|
||||
"github/gh-aw-actions/setup@v0.74.8": {
|
||||
"repo": "github/gh-aw-actions/setup",
|
||||
"version": "v0.68.1",
|
||||
"sha": "2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc"
|
||||
"version": "v0.74.8",
|
||||
"sha": "efa55847f72aadb03490d955263ff911bf758700"
|
||||
},
|
||||
"github/gh-aw/actions/setup@v0.68.2": {
|
||||
"github/gh-aw/actions/setup@v0.74.8": {
|
||||
"repo": "github/gh-aw/actions/setup",
|
||||
"version": "v0.68.2",
|
||||
"sha": "265e150164f303f0ea34d429eecd2d66ebe1d26f"
|
||||
"version": "v0.74.8",
|
||||
"sha": "0feed75a980b06f247abbbf80127f8eb2c19e2c5"
|
||||
}
|
||||
},
|
||||
"containers": {
|
||||
"ghcr.io/github/gh-aw-firewall/agent:0.25.18": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/agent:0.25.18",
|
||||
"digest": "sha256:c77e8c26bab6c39e8568d8e2f8c17015944849a8cbcdfb4bd9725d8893725ca2",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.25.18@sha256:c77e8c26bab6c39e8568d8e2f8c17015944849a8cbcdfb4bd9725d8893725ca2"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/agent:0.25.49": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/agent:0.25.49",
|
||||
"digest": "sha256:9e4b936d4215af09fa412e12a5fc82f97f2cde4993eefc85980a900122fa9062",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.25.49@sha256:9e4b936d4215af09fa412e12a5fc82f97f2cde4993eefc85980a900122fa9062"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18",
|
||||
"digest": "sha256:d16a40a3ca6e989896d0cef9f31b9412bb1fcc8755bafcafb95012ae1078539b",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18@sha256:d16a40a3ca6e989896d0cef9f31b9412bb1fcc8755bafcafb95012ae1078539b"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.49": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.25.49",
|
||||
"digest": "sha256:b795506f6b4fd12694a29f964b4d38d2d29ac3aaafe3394131619ce36020b646",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.25.49@sha256:b795506f6b4fd12694a29f964b4d38d2d29ac3aaafe3394131619ce36020b646"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/squid:0.25.18": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.25.18",
|
||||
"digest": "sha256:eb102afcfbae26ffcec016adebb74d3be7b0a5bf376ba306599cdf3effbe288e",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.25.18@sha256:eb102afcfbae26ffcec016adebb74d3be7b0a5bf376ba306599cdf3effbe288e"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/squid:0.25.49": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.25.49",
|
||||
"digest": "sha256:f6c6998edfb9f58b6e12d3f148e6f2104747a7702d563a9e8b13d4b2ae6997dd",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.25.49@sha256:f6c6998edfb9f58b6e12d3f148e6f2104747a7702d563a9e8b13d4b2ae6997dd"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-mcpg:v0.2.17": {
|
||||
"image": "ghcr.io/github/gh-aw-mcpg:v0.2.17",
|
||||
"digest": "sha256:a6dec6ec535a11c565d982afa2f98589805ed0598862b9ea9d3c751fc71afae8",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.2.17@sha256:a6dec6ec535a11c565d982afa2f98589805ed0598862b9ea9d3c751fc71afae8"
|
||||
},
|
||||
"ghcr.io/github/github-mcp-server:v0.32.0": {
|
||||
"image": "ghcr.io/github/github-mcp-server:v0.32.0",
|
||||
"digest": "sha256:2763823c63bcca718ce53850a1d7fcf2f501ec84028394f1b63ce7e9f4f9be28",
|
||||
"pinned_image": "ghcr.io/github/github-mcp-server:v0.32.0@sha256:2763823c63bcca718ce53850a1d7fcf2f501ec84028394f1b63ce7e9f4f9be28"
|
||||
},
|
||||
"ghcr.io/github/github-mcp-server:v1.0.4": {
|
||||
"image": "ghcr.io/github/github-mcp-server:v1.0.4",
|
||||
"digest": "sha256:e3816a476a977cfb836e7d221510011436c654d11861db66ecfd826601aba6a4",
|
||||
"pinned_image": "ghcr.io/github/github-mcp-server:v1.0.4@sha256:e3816a476a977cfb836e7d221510011436c654d11861db66ecfd826601aba6a4"
|
||||
},
|
||||
"node:lts-alpine": {
|
||||
"image": "node:lts-alpine",
|
||||
"digest": "sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f",
|
||||
"pinned_image": "node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,16 @@ name: Autobuild Check Logic Test
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check_autobuild_logic:
|
||||
name: Check Autobuild Should Run Logic
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
|
||||
@@ -29,16 +29,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check_commit
|
||||
if: ${{ needs.check_commit.outputs.should_run == 'true' }}
|
||||
outputs:
|
||||
release_id: ${{ steps.upload_release.outputs.id }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch UPDATE logs
|
||||
id: fetch_update_logs
|
||||
run: bash ./scripts/extract_update_logs.sh
|
||||
shell: bash
|
||||
|
||||
- uses: pnpm/action-setup@v6.0.4
|
||||
- uses: pnpm/action-setup@v6.0.9
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
@@ -46,7 +48,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -102,9 +104,11 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Upload Release
|
||||
id: upload_release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ env.TAG_NAME }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
|
||||
body_path: release.txt
|
||||
prerelease: true
|
||||
@@ -142,12 +146,12 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.91.0'
|
||||
toolchain: '1.96.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -170,16 +174,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Install x86 OpenSSL (macOS only)
|
||||
if: matrix.target == 'x86_64-apple-darwin'
|
||||
run: |
|
||||
arch -x86_64 brew install openssl@3
|
||||
echo "OPENSSL_DIR=$(brew --prefix openssl@3)" >> $GITHUB_ENV
|
||||
echo "OPENSSL_INCLUDE_DIR=$(brew --prefix openssl@3)/include" >> $GITHUB_ENV
|
||||
echo "OPENSSL_LIB_DIR=$(brew --prefix openssl@3)/lib" >> $GITHUB_ENV
|
||||
echo "PKG_CONFIG_PATH=$(brew --prefix openssl@3)/lib/pkgconfig" >> $GITHUB_ENV
|
||||
|
||||
- uses: pnpm/action-setup@v6.0.4
|
||||
- uses: pnpm/action-setup@v6.0.9
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
@@ -187,11 +182,11 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm Cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||
@@ -209,12 +204,12 @@ jobs:
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
- name: Tauri build for Windows-macOS-Linux
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
uses: tauri-apps/tauri-action@v1
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -227,6 +222,7 @@ jobs:
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
releaseId: ${{ needs.update_tag.outputs.release_id }}
|
||||
tagName: ${{ env.TAG_NAME }}
|
||||
releaseName: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
|
||||
releaseBody: 'More new features are now supported.'
|
||||
@@ -255,12 +251,12 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.91.0'
|
||||
toolchain: '1.96.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -278,18 +274,18 @@ jobs:
|
||||
cache-workspace-crates: true
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm Cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||
@@ -351,7 +347,7 @@ jobs:
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
@@ -406,7 +402,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Add Rust Target
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
@@ -423,18 +419,18 @@ jobs:
|
||||
cache-workspace-crates: true
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm Cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||
@@ -459,13 +455,13 @@ jobs:
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
- name: Tauri build for Windows
|
||||
id: build
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
uses: tauri-apps/tauri-action@v1
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -522,7 +518,7 @@ jobs:
|
||||
]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch UPDATE logs
|
||||
id: fetch_update_logs
|
||||
@@ -532,9 +528,9 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6.0.4
|
||||
- uses: pnpm/action-setup@v6.0.9
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -3,6 +3,9 @@ name: Cargo Audit
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
HUSKY: 0
|
||||
|
||||
@@ -22,7 +25,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
autobuild_version: ${{ steps.check.outputs.autobuild_version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
autobuild_version: ${{ steps.check.outputs.autobuild_version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
needs: check_current_version
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Clean old assets from release
|
||||
env:
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
name: "Copilot Setup Steps"
|
||||
|
||||
# This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
jobs:
|
||||
# The job MUST be called 'copilot-setup-steps' to be recognized by GitHub Copilot Agent
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Set minimal permissions for setup steps
|
||||
# Copilot Agent receives its own token with appropriate permissions
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install gh-aw extension
|
||||
uses: github/gh-aw-actions/setup-cli@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3
|
||||
with:
|
||||
version: v0.68.1
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
@@ -70,11 +70,11 @@ jobs:
|
||||
|
||||
- name: Checkout Repository
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust Stable
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
uses: dtolnay/rust-toolchain@1.91.0
|
||||
uses: dtolnay/rust-toolchain@1.95.0
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -103,11 +103,11 @@ jobs:
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm Cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
|
||||
@@ -129,13 +129,13 @@ jobs:
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
run: |
|
||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
- name: Tauri build
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
uses: tauri-apps/tauri-action@v1
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
HUSKY: 0
|
||||
|
||||
@@ -11,7 +14,7 @@ jobs:
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Check frontend changes
|
||||
id: check_frontend
|
||||
@@ -30,6 +33,7 @@ jobs:
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'knip.json'
|
||||
- 'eslint.config.ts'
|
||||
- 'tsconfig.json'
|
||||
- 'vite.config.*'
|
||||
@@ -47,12 +51,12 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Restore pnpm cache
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: "pnpm-shared-stable-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}"
|
||||
@@ -73,3 +77,7 @@ jobs:
|
||||
- name: Run TypeScript typecheck
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Report unused frontend code and dependencies
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: pnpm knip:report
|
||||
|
||||
@@ -5,6 +5,8 @@ on:
|
||||
workflow_dispatch:
|
||||
env:
|
||||
HUSKY: 0
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
clippy:
|
||||
@@ -44,7 +46,7 @@ jobs:
|
||||
echo "Manual trigger detected: skipping changes check and running clippy."
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
|
||||
+377
-177
File diff suppressed because it is too large
Load Diff
@@ -6,18 +6,19 @@ description: |
|
||||
|
||||
on:
|
||||
roles: all
|
||||
skip-bots: [dependabot, renovate]
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
workflow_dispatch:
|
||||
|
||||
checkout: false
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
|
||||
tools:
|
||||
github:
|
||||
toolsets: [default]
|
||||
toolsets: [issues, pull_requests]
|
||||
lockdown: false
|
||||
min-integrity: unapproved
|
||||
|
||||
@@ -48,6 +49,8 @@ This workflow is not a technical code reviewer. Do not judge correctness, archit
|
||||
- AI assistance by itself is not a problem.
|
||||
- Domain Isolation, do not let the author's personal background, hobbies, or professional titles influence the risk score. High-quality code is its own evidence; poor logic cannot be excused by status.
|
||||
- Missing issue linkage is a strong negative signal.
|
||||
- Retroactive issue linkage, PR body edits, or explanatory comments may reduce only the missing-linkage concern. They must not erase one-shot structural evidence unless accompanied by new commits that show real implementation iteration, scope reduction, or reviewer-directed code changes.
|
||||
- Existing AI-slop labels must not be downgraded on a rerun for the same head commit unless there is new substantive code evidence or maintainer-provided context that directly disproves the earlier risk assessment.
|
||||
- Always leave exactly one comment on the PR.
|
||||
- Always remove stale AI-slop labels before adding a replacement label.
|
||||
- Keep the tone factual, calm, and maintainership-oriented.
|
||||
@@ -63,6 +66,7 @@ Use GitHub tools to inspect the triggering pull request in full:
|
||||
- PR author association, repository role signals, and visible ownership history when available
|
||||
- Changed files and diff shape
|
||||
- Existing review comments and author replies when available
|
||||
- Existing AI-slop labels and earlier AI-slop review comments, especially whether the current run is evaluating the same head commit as a previous run
|
||||
|
||||
If the PR references an issue, inspect that issue as well and compare the stated problem with the actual scope of the code changes.
|
||||
|
||||
@@ -70,26 +74,48 @@ If the PR references an issue, inspect that issue as well and compare the stated
|
||||
|
||||
- No referenced issue, or only vague claims like "fixes multiple issues" without a concrete issue number
|
||||
- Single large commit or a very small number of commits covering many unrelated areas
|
||||
- One-shot feature burst: a non-trivial feature delivered in one commit across many files or multiple subsystems, especially when the change is hundreds or thousands of lines and includes backend, frontend, settings, logging, and service-layer edits together
|
||||
- Implementation-first linkage: the referenced issue was created after the PR was opened, after an AI-slop review comment, or after maintainers requested issue linkage
|
||||
- Metadata-only remediation: the author edits the PR body, adds a retroactive issue, comments an explanation, closes/reopens the PR, or otherwise retriggers the workflow without adding commits that change the implementation
|
||||
- PR body reads like a generated report rather than a maintainer-owned change description
|
||||
- PR body or author replies use stock report sections such as "Root Cause", "Root Case", "Checklist", or "Check List" without concrete issue evidence, reproduction context, or project-specific reasoning
|
||||
- PR body includes duplicated or performative testing claims, such as both "Test" and "Testing" sections, repeated verification language, or generic lint/static-analysis output that does not explain how the reported issue was reproduced or validated.
|
||||
- Explicit AI provenance links or bot-authored commits from coding agents
|
||||
- Large-scale mechanical edits with little behavioral justification
|
||||
- Random renames, comment rewrites, or same-meaning text changes that do not support the fix
|
||||
- Ghost Comments, code comments that explain the obvious (e.g., explaining a variable name) or use AI-typical hedging language.
|
||||
- Code comments that restate obvious behavior or narrate trivial operations without contributing repository-specific reasoning
|
||||
- New tests that are generic, padded, or not clearly connected to the reported issue
|
||||
- Scope Drift, the PR claims to fix a specific bug but touches unrelated modules, config files, or documentation without justification.
|
||||
- Draft or vague "ongoing optimization" style PRs with broad churn and weak problem statement
|
||||
- Lint/Static Analysis Padding: The PR body includes exhaustive, unnecessary listings of "Lint" results, static analysis summaries, or tool outputs intended to pad the appearance of rigor without providing actual insight without explaining how these results directly relate to the reported issue.
|
||||
- Performative Verification/Testing: The PR contains elaborate "Verify" or "Test" sections that look structurally impressive (using templated checklists or fancy headers) but lack project-specific context, concrete reproduction steps, or meaningful assertions of correctness or where the 'Test' steps are just a generic echo of the implementation code..
|
||||
- Branch names that clearly match AI-agent workflow patterns, ephemeral execution IDs, or automated coding-session naming conventions.
|
||||
|
||||
## Hard Escalation Rules
|
||||
|
||||
- If the pull request branch name clearly appears to be generated by an AI coding workflow, autonomous coding agent, or mass-generated automation pipeline (for example: generic agent-run IDs, Copilot/Cursor/Windsurf/Codex/Claude Code style ephemeral task branches, or machine-formatted execution/session identifiers), treat the PR as likely-one-shot-ai with high confidence regardless of the diff quality or linked issue state.
|
||||
- This override exists because the submission flow itself indicates weak evidence of direct human ownership and iterative stewardship.
|
||||
- If a PR combines all of these structural signals, treat it as likely-one-shot-ai with high confidence even when the later PR body or a later linked issue is detailed: one large commit, a broad feature touching multiple subsystems, no pre-existing issue or prior problem discussion at PR open time, report-style validation or generated-looking body text, and no later implementation commits showing real iteration.
|
||||
- If a rerun evaluates the same head commit as a previous AI-slop run, do not lower an existing `ai-slop:med` or `ai-slop:high` label based only on retroactive issue linkage, PR body edits, author comments, close/reopen activity, or clearer prose. Preserve or escalate the label unless new commits materially change the ownership evidence.
|
||||
Do not claim the PR is AI-generated as a fact unless explicitly disclosed. Frame conclusions in terms of likelihood and ownership-risk assessment.
|
||||
|
||||
## Counter-Signals
|
||||
|
||||
- Clear issue linkage with a concrete bug report or feature request
|
||||
- Clear issue linkage with a concrete bug report or feature request that existed before the PR was opened, or that is backed by visible prior discussion predating the implementation
|
||||
- Tight file scope that matches the linked issue
|
||||
- Commits that show iteration, review response, or narrowing of scope
|
||||
- Tests that directly validate the reported regression or expected behavior
|
||||
- Clear explanation of why each changed area is necessary for the fix
|
||||
- Cross-Contextual Logic, the author explains *why* a change was made in a way that shows understanding of the project's specific constraints, rather than just repeating the issue text.
|
||||
- Report-style sections are backed by concrete reproduction steps, failure evidence, or repository-specific constraints; template-required checklists should not count as a slop signal by themselves
|
||||
- Incremental commit evolution where later commits refine or partially revert earlier assumptions instead of only appending generated output
|
||||
- Reviewer interaction that changes implementation direction, scope, or reasoning in response to feedback. Comments, PR body edits, and retroactive issue creation without implementation commits are weak counter-signals only.
|
||||
- Explicit tradeoff discussion tied to repository-specific constraints, historical behavior, or compatibility concerns
|
||||
- Small corrective follow-up commits that indicate active maintenance rather than one-pass generation
|
||||
- Diffs that preserve existing project conventions even when alternative "cleaner" patterns exist
|
||||
- Evidence that the author investigated prior behavior, regressions, or historical implementation choices before modifying code
|
||||
- Evidence of established repository ownership or ongoing stewardship may reduce slop likelihood, but must never be disclosed in the public comment
|
||||
- References to prior repository behavior, historical regressions, earlier PRs, or subsystem-specific constraints that are not obvious from the current diff alone
|
||||
- Report-style sections should be discounted when they match this repository's own PR template or established maintainer convention.
|
||||
|
||||
## Decision Rules
|
||||
|
||||
@@ -105,12 +131,20 @@ Then choose exactly one confidence level for AI-slop likelihood:
|
||||
- `medium`: enough evidence to apply `ai-slop:med`
|
||||
- `high`: enough evidence to apply `ai-slop:high`
|
||||
|
||||
Confidence calibration:
|
||||
|
||||
- Use `high` for strong structural one-shot evidence, even without explicit AI disclosure.
|
||||
- Use `high` for broad one-commit feature submissions that look implementation-first and are later backfilled with issue linkage or polished prose.
|
||||
- Use `medium` for incomplete ownership evidence when the scope is smaller, the change is narrow, or there are some genuine implementation-iteration signals.
|
||||
- Use `low` only when the one-shot evidence is weak, or when new code commits/reviewer-driven changes materially demonstrate human ownership.
|
||||
|
||||
Label handling rules:
|
||||
|
||||
- Always remove any existing AI-slop confidence labels first.
|
||||
- If confidence is `medium`, add only `ai-slop:med`.
|
||||
- If confidence is `high`, add only `ai-slop:high`.
|
||||
- If confidence is `low`, do not add either label after cleanup.
|
||||
- When the same head commit already had `ai-slop:med` or `ai-slop:high`, the new confidence must not be lower unless new commits or maintainer context justify the downgrade. In that case, keep the previous label outcome instead of clearing it.
|
||||
|
||||
## Commenting Rules
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -63,13 +63,38 @@ jobs:
|
||||
|
||||
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:
|
||||
name: Update tag
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release, release-for-linux-arm, release-for-fixed-webview2]
|
||||
needs:
|
||||
[
|
||||
prepare_release,
|
||||
release,
|
||||
release-for-linux-arm,
|
||||
release-for-fixed-webview2,
|
||||
]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch UPDATE logs
|
||||
id: fetch_update_logs
|
||||
@@ -130,21 +155,21 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PRERELEASE: ${{ contains(github.ref_name, '-rc') }}
|
||||
run: |
|
||||
matches=$(gh api repos/{owner}/{repo}/releases --paginate --jq '.[] | select(.tag_name == env.TAG_NAME) | [.id, .draft, (.assets | length), .html_url] | @tsv')
|
||||
release_count=$(grep -c . <<< "$matches" || true)
|
||||
release_id="${{ needs.prepare_release.outputs.release_id }}"
|
||||
|
||||
if [ "$release_count" -eq 0 ]; then
|
||||
echo "::error::No release found for $TAG_NAME."
|
||||
if [ -z "$release_id" ]; then
|
||||
echo "::error::No prepared release ID found for $TAG_NAME."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$release_count" -gt 1 ]; then
|
||||
printf '%s\n' "$matches"
|
||||
echo "::error::Multiple releases found for $TAG_NAME. Merge or delete duplicate releases before rerunning."
|
||||
release_info=$(gh api repos/{owner}/{repo}/releases/"$release_id" --jq '[.tag_name, .draft, (.assets | length), .html_url] | @tsv')
|
||||
IFS=$'\t' read -r release_tag _ asset_count release_url <<< "$release_info"
|
||||
|
||||
if [ "$release_tag" != "$TAG_NAME" ]; then
|
||||
echo "::error::Prepared release $release_id belongs to $release_tag, expected $TAG_NAME."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IFS=$'\t' read -r release_id _ asset_count release_url <<< "$matches"
|
||||
echo "Publishing release $release_id ($release_url) with $asset_count assets."
|
||||
|
||||
gh api --method PATCH repos/{owner}/{repo}/releases/"$release_id" \
|
||||
@@ -156,7 +181,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Release Build
|
||||
needs: [check_tag_version]
|
||||
needs: [prepare_release]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -175,12 +200,12 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.91.0'
|
||||
toolchain: '1.96.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -203,19 +228,10 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Install x86 OpenSSL (macOS only)
|
||||
if: matrix.target == 'x86_64-apple-darwin'
|
||||
run: |
|
||||
arch -x86_64 brew install openssl@3
|
||||
echo "OPENSSL_DIR=$(brew --prefix openssl@3)" >> $GITHUB_ENV
|
||||
echo "OPENSSL_INCLUDE_DIR=$(brew --prefix openssl@3)/include" >> $GITHUB_ENV
|
||||
echo "OPENSSL_LIB_DIR=$(brew --prefix openssl@3)/lib" >> $GITHUB_ENV
|
||||
echo "PKG_CONFIG_PATH=$(brew --prefix openssl@3)/lib/pkgconfig" >> $GITHUB_ENV
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -230,13 +246,13 @@ jobs:
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
- name: Tauri build
|
||||
# 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本
|
||||
uses: tauri-apps/tauri-action@v0.6.2
|
||||
uses: tauri-apps/tauri-action@v1.0.0
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -249,11 +265,9 @@ jobs:
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
releaseId: ${{ needs.prepare_release.outputs.release_id }}
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: 'Clash Verge Rev ${{ github.ref_name }}'
|
||||
releaseBody: 'Draft release, will be updated later.'
|
||||
releaseDraft: true
|
||||
prerelease: ${{ contains(github.ref_name, '-rc') }}
|
||||
tauriScript: pnpm
|
||||
args: --target ${{ matrix.target }}
|
||||
includeUpdaterJson: true
|
||||
@@ -280,7 +294,7 @@ jobs:
|
||||
|
||||
release-for-linux-arm:
|
||||
name: Release Build for Linux ARM
|
||||
needs: [check_tag_version]
|
||||
needs: [prepare_release]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -294,12 +308,12 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.91.0'
|
||||
toolchain: '1.96.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -319,7 +333,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
@@ -378,7 +392,7 @@ jobs:
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
@@ -398,13 +412,6 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: Get Version
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install jq
|
||||
echo "VERSION=$(cat package.json | jq '.version' | tr -d '"')" >> $GITHUB_ENV
|
||||
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
|
||||
|
||||
- name: Attest Linux bundles
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
@@ -412,22 +419,18 @@ jobs:
|
||||
target/${{ matrix.target }}/release/bundle/deb/*.deb
|
||||
target/${{ matrix.target }}/release/bundle/rpm/*.rpm
|
||||
|
||||
- name: Upload Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: v${{env.VERSION}}
|
||||
name: 'Clash Verge Rev v${{env.VERSION}}'
|
||||
body: 'See release notes for detailed changelog.'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
draft: true
|
||||
prerelease: ${{ contains(github.ref_name, '-rc') }}
|
||||
files: |
|
||||
target/${{ matrix.target }}/release/bundle/deb/*.deb
|
||||
target/${{ matrix.target }}/release/bundle/rpm/*.rpm
|
||||
- name: Upload release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" \
|
||||
target/${{ matrix.target }}/release/bundle/deb/*.deb \
|
||||
target/${{ matrix.target }}/release/bundle/rpm/*.rpm \
|
||||
--clobber
|
||||
|
||||
release-for-fixed-webview2:
|
||||
name: Release Build for Fixed WebView2
|
||||
needs: [check_tag_version]
|
||||
needs: [prepare_release]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -441,12 +444,12 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.91.0'
|
||||
toolchain: '1.96.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -466,7 +469,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -488,13 +491,12 @@ jobs:
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
- name: Tauri build
|
||||
id: build
|
||||
uses: tauri-apps/tauri-action@v0.6.2
|
||||
uses: tauri-apps/tauri-action@v1.0.0
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -529,16 +531,13 @@ jobs:
|
||||
with:
|
||||
subject-path: target/${{ matrix.target }}/release/bundle/nsis/*setup*
|
||||
|
||||
- name: Upload Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: v${{steps.build.outputs.appVersion}}
|
||||
name: 'Clash Verge Rev v${{steps.build.outputs.appVersion}}'
|
||||
body: 'See release notes for detailed changelog.'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
draft: true
|
||||
prerelease: ${{ contains(github.ref_name, '-rc') }}
|
||||
files: target/${{ matrix.target }}/release/bundle/nsis/*setup*
|
||||
- name: Upload release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$files = Get-ChildItem "target\${{ matrix.target }}\release\bundle\nsis\*setup*" | Select-Object -ExpandProperty FullName
|
||||
gh release upload "${{ github.ref_name }}" $files --clobber
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Portable Bundle
|
||||
run: pnpm portable-fixed-webview2 ${{ matrix.target }}
|
||||
@@ -552,12 +551,12 @@ jobs:
|
||||
needs: [update_tag]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -578,12 +577,12 @@ jobs:
|
||||
needs: [update_tag]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -605,7 +604,7 @@ jobs:
|
||||
needs: [update_tag, release-update]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Get Version
|
||||
@@ -635,7 +634,7 @@ jobs:
|
||||
]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch UPDATE logs
|
||||
id: fetch_update_logs
|
||||
@@ -645,7 +644,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
@@ -7,6 +7,9 @@ name: Check Formatting
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
HUSKY: 0
|
||||
|
||||
@@ -14,7 +17,7 @@ jobs:
|
||||
rustfmt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Check Rust changes
|
||||
id: check_rust
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
name: Sync Mihomo Changelog
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# UTC+8 Monday 11:30
|
||||
- cron: '30 3 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: sync-mihomo-changelog
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync Changelog
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
|
||||
- name: Sync Mihomo version
|
||||
run: node scripts/sync-mihomo-version.mjs
|
||||
|
||||
- name: Detect changelog changes
|
||||
id: changes
|
||||
run: |
|
||||
if git diff --quiet -- Changelog.md; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mihomo_version="$(grep -Eo 'Mihomo\(Meta\).*v[0-9]+(\.[0-9]+)+' Changelog.md | grep -Eo 'v[0-9]+(\.[0-9]+)+' | head -n 1)"
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "mihomo_version=$mihomo_version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Open pull request
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BASE_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
MIHOMO_VERSION: ${{ steps.changes.outputs.mihomo_version }}
|
||||
run: |
|
||||
branch="chore/sync-mihomo-changelog"
|
||||
title="chore: sync Mihomo changelog to ${MIHOMO_VERSION}"
|
||||
body="Automated update for the Mihomo core version recorded in Changelog.md."
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git fetch origin "$branch:refs/remotes/origin/$branch" || true
|
||||
git checkout -B "$branch"
|
||||
git add Changelog.md
|
||||
git commit -m "$title"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
if gh pr view "$branch" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then
|
||||
gh pr edit "$branch" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "$title" \
|
||||
--body "$body"
|
||||
else
|
||||
gh pr create \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--base "$BASE_BRANCH" \
|
||||
--head "$branch" \
|
||||
--title "$title" \
|
||||
--body "$body"
|
||||
fi
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Fetch UPDATE logs
|
||||
id: fetch_update_logs
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
@@ -10,12 +10,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -34,12 +34,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.15.0'
|
||||
node-version: '24.18.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
+3
-1
@@ -15,5 +15,7 @@ scripts/_env.sh
|
||||
target
|
||||
CLAUDE.md
|
||||
.vfox.toml
|
||||
.vfox/
|
||||
.vfox
|
||||
.claude
|
||||
AGENTS.md
|
||||
.zed
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v "cargo-make" >/dev/null 2>&1; then
|
||||
echo "❌ cargo-make is required for pre-commit checks."
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v "cargo-make" >/dev/null 2>&1; then
|
||||
echo "❌ cargo-make is required for pre-push checks."
|
||||
|
||||
Generated
+797
-1547
File diff suppressed because it is too large
Load Diff
+63
-58
@@ -53,87 +53,92 @@ clash-verge-i18n = { path = "crates/clash-verge-i18n" }
|
||||
clash-verge-limiter = { path = "crates/clash-verge-limiter" }
|
||||
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
||||
|
||||
tauri = { version = "2.10.3" }
|
||||
tauri = { version = "2.11.5" }
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
||||
anyhow = "1.0.102"
|
||||
anyhow = "1.0.103"
|
||||
criterion = { version = "0.8.2", features = ["async_tokio"] }
|
||||
tokio = { version = "1.50.0", features = [
|
||||
tokio = { version = "1.52.3", features = [
|
||||
"rt-multi-thread",
|
||||
"macros",
|
||||
"time",
|
||||
"sync",
|
||||
] }
|
||||
flexi_logger = "0.31.8"
|
||||
log = "0.4.29"
|
||||
flexi_logger = "0.31.9"
|
||||
log = "0.4.33"
|
||||
|
||||
smartstring = { version = "1.0.1" }
|
||||
compact_str = { version = "0.9.0", features = ["serde"] }
|
||||
compact_str = { version = "0.9.1", features = ["serde"] }
|
||||
|
||||
serde = { version = "1.0.228" }
|
||||
serde_json = { version = "1.0.149" }
|
||||
serde_json = { version = "1.0.150" }
|
||||
serde_yaml_ng = { version = "0.10.0" }
|
||||
bitflags = { version = "2.11.0" }
|
||||
bitflags = { version = "2.13.0" }
|
||||
|
||||
# *** For Windows platform only ***
|
||||
deelevate = "0.2.0"
|
||||
windows-sys = { version = "0.61.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_JobObjects"
|
||||
] }
|
||||
# *********************************
|
||||
|
||||
[workspace.lints.clippy]
|
||||
correctness = { level = "deny", priority = -1 }
|
||||
suspicious = { level = "deny", priority = -1 }
|
||||
unwrap_used = "warn"
|
||||
expect_used = "warn"
|
||||
panic = "deny"
|
||||
unimplemented = "deny"
|
||||
todo = "warn"
|
||||
dbg_macro = "warn"
|
||||
clone_on_ref_ptr = "warn"
|
||||
rc_clone_in_vec_init = "warn"
|
||||
large_stack_arrays = "warn"
|
||||
large_const_arrays = "warn"
|
||||
# fallible_impl_from = "deny" // 过于激进,暂时不开启
|
||||
# implicit_clone = "deny" // 可能会造成额外开销,暂时不开启
|
||||
# option_if_let_else = "deny" // 过于激进,暂时不开启
|
||||
# self_only_used_in_recursion = "deny" // Since 1.92.0
|
||||
async_yields_async = "deny"
|
||||
await_holding_lock = "deny"
|
||||
branches_sharing_code = "deny"
|
||||
clone_on_ref_ptr = "warn"
|
||||
cloned_instead_of_copied = "deny"
|
||||
cognitive_complexity = "deny"
|
||||
collection_is_never_read = "deny"
|
||||
copy_iterator = "deny"
|
||||
correctness = { level = "deny", priority = -1 }
|
||||
dbg_macro = "warn"
|
||||
equatable_if_let = "deny"
|
||||
expect_used = "warn"
|
||||
expl_impl_clone_on_copy = "deny"
|
||||
future_not_send = "deny"
|
||||
iter_on_empty_collections = "deny"
|
||||
iter_on_single_items = "deny"
|
||||
iter_with_drain = "deny"
|
||||
large_const_arrays = "warn"
|
||||
large_futures = "deny"
|
||||
large_stack_arrays = "warn"
|
||||
large_types_passed_by_value = "deny"
|
||||
missing_const_for_fn = "deny"
|
||||
mutex_atomic = "deny"
|
||||
mutex_integer = "deny"
|
||||
rc_mutex = "deny"
|
||||
unused_async = "deny"
|
||||
await_holding_lock = "deny"
|
||||
large_futures = "deny"
|
||||
future_not_send = "deny"
|
||||
redundant_else = "deny"
|
||||
needless_continue = "deny"
|
||||
needless_raw_string_hashes = "deny"
|
||||
or_fun_call = "deny"
|
||||
cognitive_complexity = "deny"
|
||||
useless_let_if_seq = "deny"
|
||||
use_self = "deny"
|
||||
tuple_array_conversions = "deny"
|
||||
trait_duplication_in_bounds = "deny"
|
||||
suspicious_operation_groupings = "deny"
|
||||
string_lit_as_bytes = "deny"
|
||||
significant_drop_tightening = "deny"
|
||||
significant_drop_in_scrutinee = "deny"
|
||||
redundant_clone = "deny"
|
||||
# option_if_let_else = "deny" // 过于激进,暂时不开启
|
||||
needless_pass_by_ref_mut = "deny"
|
||||
needless_collect = "deny"
|
||||
missing_const_for_fn = "deny"
|
||||
iter_with_drain = "deny"
|
||||
iter_on_single_items = "deny"
|
||||
iter_on_empty_collections = "deny"
|
||||
# fallible_impl_from = "deny" // 过于激进,暂时不开启
|
||||
equatable_if_let = "deny"
|
||||
collection_is_never_read = "deny"
|
||||
branches_sharing_code = "deny"
|
||||
pathbuf_init_then_push = "deny"
|
||||
needless_continue = "deny"
|
||||
needless_pass_by_ref_mut = "deny"
|
||||
needless_raw_string_hashes = "deny"
|
||||
option_as_ref_cloned = "deny"
|
||||
large_types_passed_by_value = "deny"
|
||||
# implicit_clone = "deny" // 可能会造成额外开销,暂时不开启
|
||||
expl_impl_clone_on_copy = "deny"
|
||||
copy_iterator = "deny"
|
||||
cloned_instead_of_copied = "deny"
|
||||
# self_only_used_in_recursion = "deny" // Since 1.92.0
|
||||
or_fun_call = "deny"
|
||||
panic = "deny"
|
||||
pathbuf_init_then_push = "deny"
|
||||
rc_clone_in_vec_init = "warn"
|
||||
rc_mutex = "deny"
|
||||
redundant_clone = "deny"
|
||||
redundant_else = "deny"
|
||||
significant_drop_in_scrutinee = "deny"
|
||||
significant_drop_tightening = "deny"
|
||||
string_lit_as_bytes = "deny"
|
||||
suspicious = { level = "deny", priority = -1 }
|
||||
suspicious_operation_groupings = "deny"
|
||||
todo = "warn"
|
||||
trait_duplication_in_bounds = "deny"
|
||||
tuple_array_conversions = "deny"
|
||||
unimplemented = "deny"
|
||||
unnecessary_self_imports = "deny"
|
||||
unused_trait_names = "deny"
|
||||
wildcard_imports = "deny"
|
||||
unnecessary_wraps = "deny"
|
||||
unused_async = "deny"
|
||||
unused_trait_names = "deny"
|
||||
unwrap_used = "warn"
|
||||
use_self = "deny"
|
||||
useless_let_if_seq = "deny"
|
||||
wildcard_imports = "deny"
|
||||
|
||||
+74
-26
@@ -1,35 +1,83 @@
|
||||
## v2.5.0
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 关于版本的说明:Clash Verge 版本号遵循 x.y.z:x 为重大架构变更,y 为功能新增,z 为 Bug 修复。
|
||||
|
||||
- **Mihomo(Meta) 内核升级至 v1.19.25**
|
||||
## v2.5.2
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
- 修复系统代理关闭后在 PAC 模式下未完全关闭
|
||||
- 修复 macOS 开关代理时可能的卡死
|
||||
- 修复修改定时自动更新后记时未及时刷新
|
||||
- 修复 Linux 关闭 TUN 不立即生效
|
||||
- 修复系统代理关闭序列逻辑(防止快速退出时系统代理关闭状态没有保存)
|
||||
- 修复 Linux 快捷键映射错误
|
||||
- 修复 Linux Wayland 兼容性导致渲染错误
|
||||
- 修复 Linux 边缘情况无法加载页面
|
||||
- 修复 macOS 托盘速率显示样式异常
|
||||
- 修复订阅 TLS 1.0/1.1 等过旧协议时显示更明确错误原因
|
||||
- 修复 gzip 压缩订阅响应被当作无效 YAML 导致导入失败的问题
|
||||
- 修复订阅 URL 使用空密码 Basic Auth 时未发送认证信息的问题
|
||||
- 修复 Linux 托盘可能与其他 Tauri 程序冲突导致的图标异常
|
||||
- 修复前端连接页面导致的内存泄漏
|
||||
- 修复 macOS 12(Monterey)首页 IP 卡片兼容性问题
|
||||
- 修复首页代理卡片可能错误显示通信异常的问题
|
||||
- 修复 Fake-IP 模式开启 IPv6 后未生成 fake-ip-range6 的问题
|
||||
- 修复 DNS 覆写的高级模式无法正常编辑
|
||||
- 修复部分非标准 WebDAV 服务器在备份目录已存在时的问题
|
||||
- 修复 Linux 应用内更新问题
|
||||
- 修复 JS 脚本验证因 console 方法调用导致的执行失败问题
|
||||
- 修复 macOS 内存压力下 WebView 渲染进程被系统终止引发的白屏与主进程内存泄漏
|
||||
- 修复全局热键开关设置可能被意外覆盖的问题
|
||||
- 修复编辑节点时 Base64 解码未能正确处理非 ASCII 字符的问题
|
||||
- 修复订阅规则编辑器自定义规则偶发显示为空的问题
|
||||
- 修复开启“允许局域网连接”后,其他局域网设备仍可能无法连接代理的问题
|
||||
- 修复“允许局域网连接”页面可能无法显示网络接口的问题
|
||||
- 修复导入包含无效 v2ray-plugin 参数的 ss:// 链接时可能崩溃的问题
|
||||
- 修复 macOS 托盘图标可能闪烁的问题
|
||||
- 修复 macOS 应用启动及从菜单栏重新打开时,窗口可能无法正确激活的问题
|
||||
- 修复 macOS App Translocation 状态下服务安装或启动异常的问题
|
||||
- 修复异常窗口尺寸被保存后,应用窗口可能无法正常显示的问题
|
||||
- 修复首页上传和下载总流量统计不准确的问题
|
||||
- 修复首页当前代理状态偶尔不同步的问题
|
||||
- 修复 URLTest 代理组未出现在部分代理组选择列表中的问题
|
||||
- 修复切换代理模式失败时未正确显示错误的问题
|
||||
- 修复重新加载 Clash 配置后规则页面数据未及时刷新的问题
|
||||
- 修复全局快捷键隐藏主窗口行为异常的问题
|
||||
- 修复外部控制器 CORS 编辑时输入框可能丢失焦点的问题
|
||||
- 修复日志刷新时页面可能被强制滚动到底部的问题
|
||||
- 修复全局扩展脚本中的通配符配置可能生成无效 YAML 的问题
|
||||
- 修复代理组筛选后定位及滚动位置恢复异常的问题
|
||||
- 修复编辑订阅配置时清空 HTTP 请求超时会报错的问题
|
||||
- 修复纯 IP 连接的主机地址显示错误,并保留目标 IP 为空时的地址回退
|
||||
- 修复 Windows 管理员模式下启动 TUN 时因等待不可用 Service 导致延迟 30 秒的问题
|
||||
- 修复内核启动慢导致首页上下跳动的问题
|
||||
- 修复 VLESS xhttp 链接解析及 IPv6 地址格式问题
|
||||
- 修复通过普通导入或深链导入订阅后,自动更新任务未及时生效的问题
|
||||
- 修复链式配置文件首次编辑失败及校验回滚后遗留空文件的问题,并优化文件读取错误提示
|
||||
|
||||
### ✨ 新增功能
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 订阅 QR code 分享
|
||||
- 新增 macOS 托盘速率显示
|
||||
- 快捷键操作通知操作结果
|
||||
- 软件自动更新(后台下载,下次启动自动安装)
|
||||
- **Mihomo(Meta) 内核升级至 v1.19.29**
|
||||
- 增加 TrustTunnel、OpenVPN、Tailscale、GostRelay 节点显示支持
|
||||
- 全局扩展脚本增加恢复默认按钮
|
||||
- DNS 添加 fake-ip-range6 可配置项
|
||||
- Linux 无边框窗口支持拖拽调整大小
|
||||
- 代理组新增筛选、排序、延迟测试和快速定位工具
|
||||
- 首页当前节点过滤已隐藏的代理组
|
||||
|
||||
### 🚀 优化改进
|
||||
</details>
|
||||
|
||||
- 优化 macOS 读取系统代理性能
|
||||
- 优化前端 CPU 性能
|
||||
- 更健壮的服务模式与边缘状况内核恢复
|
||||
- 优化白名单网络下的订阅 TLS 更新兼容性
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
### 👙 界面样式
|
||||
- 更健壮的服务生命周期管理
|
||||
- 更健壮的 Mihomo API 通信机制
|
||||
- 关闭 autofill 弹出窗口
|
||||
- 改进切换订阅后激活选中节点的逻辑
|
||||
- 实现代理组粘性滚动列表
|
||||
- 完善了配置覆写的相关逻辑
|
||||
- 支持 Provider 节点延迟检测
|
||||
- 优化页面不可见时的 Mihomo WebSocket 订阅,减少后台资源占用
|
||||
- 代理组支持分别恢复普通模式和链式代理模式的滚动位置
|
||||
- 重构改进订阅配置切换逻辑
|
||||
- 优化大量订阅卡片时的拖拽排序性能
|
||||
|
||||
- 代理组实现 Sticky Scroll 效果
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 👙 界面样式 </strong></summary>
|
||||
|
||||
- 改进 Sticky Groups 的吸顶阴影、间距和展开/折叠样式
|
||||
- 优化代理组工具栏和筛选框布局
|
||||
|
||||
</details>
|
||||
|
||||
+63
-5
@@ -7,6 +7,7 @@ skip_crate_env_info = true
|
||||
# --- Backend ---
|
||||
|
||||
[tasks.rust-format]
|
||||
description = "Run rustfmt on Rust code"
|
||||
install_crate = "rustfmt"
|
||||
command = "cargo"
|
||||
args = ["fmt", "--", "--emit=files"]
|
||||
@@ -22,13 +23,23 @@ args = ["clippy", "--all-targets", "--all-features", "--", "-D", "warnings"]
|
||||
description = "Run type checks"
|
||||
command = "pnpm"
|
||||
args = ["typecheck"]
|
||||
|
||||
[tasks.typecheck.windows]
|
||||
command = "pnpm.cmd"
|
||||
|
||||
[tasks.knip-report]
|
||||
description = "Report unused frontend code and dependencies"
|
||||
command = "pnpm"
|
||||
args = ["knip:report"]
|
||||
|
||||
[tasks.knip-report.windows]
|
||||
command = "pnpm.cmd"
|
||||
|
||||
[tasks.lint-staged]
|
||||
description = "Run lint-staged for staged files"
|
||||
command = "pnpm"
|
||||
args = ["exec", "lint-staged"]
|
||||
|
||||
[tasks.lint-staged.windows]
|
||||
command = "pnpm.cmd"
|
||||
|
||||
@@ -36,6 +47,7 @@ command = "pnpm.cmd"
|
||||
description = "Format i18n keys"
|
||||
command = "pnpm"
|
||||
args = ["i18n:format"]
|
||||
|
||||
[tasks.i18n-format.windows]
|
||||
command = "pnpm.cmd"
|
||||
|
||||
@@ -43,11 +55,12 @@ command = "pnpm.cmd"
|
||||
description = "Generate i18n key types"
|
||||
command = "pnpm"
|
||||
args = ["i18n:types"]
|
||||
|
||||
[tasks.i18n-types.windows]
|
||||
command = "pnpm.cmd"
|
||||
|
||||
[tasks.git-add]
|
||||
description = "Add changed files to git"
|
||||
description = "Add generated files back to git index"
|
||||
command = "git"
|
||||
args = [
|
||||
"add",
|
||||
@@ -60,14 +73,59 @@ args = [
|
||||
|
||||
[tasks.frontend-format]
|
||||
description = "Frontend format checks"
|
||||
dependencies = ["i18n-format", "i18n-types", "git-add", "lint-staged"]
|
||||
script_runner = "@duckscript"
|
||||
script = '''
|
||||
# i18n related changes
|
||||
has_i18n = exec git diff --cached --quiet -- src/locales crates/clash-verge-i18n/locales
|
||||
if eq ${has_i18n.code} 1
|
||||
cm_run_task i18n-format
|
||||
cm_run_task i18n-types
|
||||
cm_run_task git-add
|
||||
end
|
||||
|
||||
# frontend related changes
|
||||
has_frontend = exec git diff --cached --quiet -- "*.js" "*.jsx" "*.ts" "*.tsx" "*.json" "*.css" "*.scss" "*.html"
|
||||
if eq ${has_frontend.code} 1
|
||||
cm_run_task lint-staged
|
||||
end
|
||||
'''
|
||||
|
||||
# --- Git Hooks ---
|
||||
|
||||
[tasks.pre-commit]
|
||||
description = "Pre-commit checks: format only"
|
||||
dependencies = ["rust-format", "frontend-format"]
|
||||
script_runner = "@duckscript"
|
||||
script = '''
|
||||
# rust related changes
|
||||
has_rust = exec git diff --cached --quiet -- "*.rs" Cargo.toml Cargo.lock
|
||||
if eq ${has_rust.code} 1
|
||||
cm_run_task rust-format
|
||||
end
|
||||
|
||||
# frontend checks
|
||||
cm_run_task frontend-format
|
||||
'''
|
||||
|
||||
[tasks.pre-push]
|
||||
description = "Pre-push checks: lint and typecheck"
|
||||
dependencies = ["rust-clippy", "typecheck"]
|
||||
description = "Pre-push checks: lint, typecheck, and unused files"
|
||||
script_runner = "@duckscript"
|
||||
script = '''
|
||||
has_upstream = exec git rev-parse --abbrev-ref --symbolic-full-name @{u}
|
||||
if not eq ${has_upstream.code} 0
|
||||
echo "No upstream found, skipping diff-based checks"
|
||||
exit 0
|
||||
end
|
||||
|
||||
# rust related changes
|
||||
has_rust = exec git diff @{u}..HEAD --quiet -- "*.rs" Cargo.toml Cargo.lock
|
||||
if eq ${has_rust.code} 1
|
||||
cm_run_task rust-clippy
|
||||
end
|
||||
|
||||
# frontend related changes
|
||||
has_frontend = exec git diff @{u}..HEAD --quiet -- "*.js" "*.jsx" "*.ts" "*.tsx" "*.json" "*.css" "*.scss" "*.html"
|
||||
if eq ${has_frontend.code} 1
|
||||
cm_run_task typecheck
|
||||
cm_run_task knip-report
|
||||
end
|
||||
'''
|
||||
|
||||
@@ -125,3 +125,7 @@ Clash Verge rev was based on or inspired by these projects and so on:
|
||||
## License
|
||||
|
||||
GPL-3.0 License. See [License here](./LICENSE) for details.
|
||||
|
||||
<p align="right">
|
||||
<sub><a href="https://sponsorship.forztn.com/github/clash-verge-rev/clash-verge-rev">Supported By ForZTN</a></sub>
|
||||
</p>
|
||||
|
||||
+5
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
|
||||
"$schema": "node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
@@ -17,7 +17,8 @@
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80
|
||||
"lineWidth": 80,
|
||||
"lineEnding": "auto"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
@@ -41,7 +42,8 @@
|
||||
"!CONTRIBUTING.md",
|
||||
"!.changelog_backups",
|
||||
"!.github",
|
||||
"!.pnpm-lock.yaml"
|
||||
"!.pnpm-lock.yaml",
|
||||
"!.vfox"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ harness = false
|
||||
[dependencies]
|
||||
parking_lot = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -1,27 +1,54 @@
|
||||
use parking_lot::RwLock;
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{
|
||||
AtomicBool,
|
||||
Ordering::{Acquire, Relaxed, Release},
|
||||
};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
pub type SharedDraft<T> = Arc<T>;
|
||||
type DraftInner<T> = (SharedDraft<T>, Option<SharedDraft<T>>);
|
||||
|
||||
/// Draft 管理:committed 与 optional draft 都以 Arc<Box<T>> 存储,
|
||||
pub type SharedDraft<T> = Arc<Box<T>>;
|
||||
// (committed_snapshot, optional_draft_snapshot)
|
||||
type DraftData<T> = (SharedDraft<T>, Option<SharedDraft<T>>);
|
||||
// Retry one scheduler turn before parking on Notify for long async updates.
|
||||
const DATA_MODIFY_FAST_RETRY_YIELDS: usize = 1;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DraftInner<T> {
|
||||
data: Mutex<DraftData<T>>,
|
||||
data_modifying: AtomicBool,
|
||||
data_modify_notify: Notify,
|
||||
}
|
||||
|
||||
struct DataModifyPermit<'a>(&'a AtomicBool, &'a Notify);
|
||||
|
||||
impl Drop for DataModifyPermit<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Release);
|
||||
self.1.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
/// Draft 管理:committed 与 optional draft 都以 Arc<Box<T>> 存储。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Draft<T> {
|
||||
inner: Arc<RwLock<DraftInner<T>>>,
|
||||
inner: Arc<DraftInner<T>>,
|
||||
}
|
||||
|
||||
impl<T: Clone> Draft<T> {
|
||||
#[inline]
|
||||
pub fn new(data: T) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new((Arc::new(data), None))),
|
||||
inner: Arc::new(DraftInner {
|
||||
data: Mutex::new((Arc::new(Box::new(data)), None)),
|
||||
data_modifying: AtomicBool::new(false),
|
||||
data_modify_notify: Notify::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// 以 Arc<Box<T>> 的形式获取当前“已提交(正式)”数据的快照(零拷贝,仅 clone Arc)
|
||||
#[inline]
|
||||
pub fn data_arc(&self) -> SharedDraft<T> {
|
||||
let guard = self.inner.read();
|
||||
let guard = self.inner.data.lock();
|
||||
Arc::clone(&guard.0)
|
||||
}
|
||||
|
||||
@@ -29,7 +56,7 @@ impl<T: Clone> Draft<T> {
|
||||
/// 这也是零拷贝:只 clone Arc,不 clone T
|
||||
#[inline]
|
||||
pub fn latest_arc(&self) -> SharedDraft<T> {
|
||||
let guard = self.inner.read();
|
||||
let guard = self.inner.data.lock();
|
||||
guard.1.clone().unwrap_or_else(|| Arc::clone(&guard.0))
|
||||
}
|
||||
|
||||
@@ -41,10 +68,10 @@ impl<T: Clone> Draft<T> {
|
||||
where
|
||||
F: FnOnce(&mut T) -> R,
|
||||
{
|
||||
let mut guard = self.inner.write();
|
||||
let mut guard = self.inner.data.lock();
|
||||
let mut draft_arc = guard.1.take().unwrap_or_else(|| Arc::clone(&guard.0));
|
||||
let data_mut = Arc::make_mut(&mut draft_arc);
|
||||
let result = f(data_mut);
|
||||
let result = f(data_mut.as_mut());
|
||||
guard.1 = Some(draft_arc);
|
||||
result
|
||||
}
|
||||
@@ -52,7 +79,7 @@ impl<T: Clone> Draft<T> {
|
||||
/// 将草稿提交到已提交位置(替换),并清除草稿
|
||||
#[inline]
|
||||
pub fn apply(&self) {
|
||||
let mut guard = self.inner.write();
|
||||
let mut guard = self.inner.data.lock();
|
||||
if let Some(d) = guard.1.take() {
|
||||
guard.0 = d;
|
||||
}
|
||||
@@ -61,7 +88,7 @@ impl<T: Clone> Draft<T> {
|
||||
/// 丢弃草稿(如果存在)
|
||||
#[inline]
|
||||
pub fn discard(&self) {
|
||||
let mut guard = self.inner.write();
|
||||
let mut guard = self.inner.data.lock();
|
||||
guard.1 = None;
|
||||
}
|
||||
|
||||
@@ -74,27 +101,47 @@ impl<T: Clone> Draft<T> {
|
||||
F: FnOnce(T) -> Fut + Send,
|
||||
Fut: std::future::Future<Output = Result<(T, R), anyhow::Error>> + Send,
|
||||
{
|
||||
let _permit = self.acquire_data_modify_permit().await;
|
||||
let (local, original_arc) = {
|
||||
let guard = self.inner.read();
|
||||
let guard = self.inner.data.lock();
|
||||
let arc = Arc::clone(&guard.0);
|
||||
((*arc).clone(), arc)
|
||||
((**arc).clone(), arc)
|
||||
};
|
||||
let (new_local, res) = f(local).await?;
|
||||
let mut guard = self.inner.write();
|
||||
let mut guard = self.inner.data.lock();
|
||||
if !Arc::ptr_eq(&guard.0, &original_arc) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Optimistic lock failed: Committed data has changed during async operation"
|
||||
));
|
||||
}
|
||||
guard.0 = Arc::from(new_local);
|
||||
guard.0 = Arc::new(Box::new(new_local));
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone> Clone for Draft<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: Arc::clone(&self.inner),
|
||||
#[inline]
|
||||
fn try_acquire_data_modify_permit(&self) -> Option<DataModifyPermit<'_>> {
|
||||
self.inner
|
||||
.data_modifying
|
||||
.compare_exchange(false, true, Acquire, Relaxed)
|
||||
.ok()
|
||||
.map(|_| DataModifyPermit(&self.inner.data_modifying, &self.inner.data_modify_notify))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
async fn acquire_data_modify_permit(&self) -> DataModifyPermit<'_> {
|
||||
for _ in 0..DATA_MODIFY_FAST_RETRY_YIELDS {
|
||||
if let Some(permit) = self.try_acquire_data_modify_permit() {
|
||||
return permit;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
loop {
|
||||
let notified = self.inner.data_modify_notify.notified();
|
||||
if let Some(permit) = self.try_acquire_data_modify_permit() {
|
||||
return permit;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ mod tests {
|
||||
use anyhow::anyhow;
|
||||
use clash_verge_draft::Draft;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct IVerge {
|
||||
@@ -12,69 +14,48 @@ mod tests {
|
||||
enable_tun_mode: Option<bool>,
|
||||
}
|
||||
|
||||
// Minimal single-threaded executor for immediately-ready futures
|
||||
fn block_on_ready<F: Future>(fut: F) -> F::Output {
|
||||
fn no_op_raw_waker() -> RawWaker {
|
||||
fn clone(_: *const ()) -> RawWaker {
|
||||
no_op_raw_waker()
|
||||
}
|
||||
fn wake(_: *const ()) {}
|
||||
fn wake_by_ref(_: *const ()) {}
|
||||
fn drop(_: *const ()) {}
|
||||
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake_by_ref, drop);
|
||||
RawWaker::new(std::ptr::null(), &VTABLE)
|
||||
}
|
||||
const STRESS_LEVELS: [usize; 6] = [8, 16, 32, 64, 128, 256];
|
||||
|
||||
let waker = unsafe { Waker::from_raw(no_op_raw_waker()) };
|
||||
let mut cx = Context::from_waker(&waker);
|
||||
let mut fut = Box::pin(fut);
|
||||
loop {
|
||||
match Pin::as_mut(&mut fut).poll(&mut cx) {
|
||||
Poll::Ready(v) => return v,
|
||||
Poll::Pending => std::thread::yield_now(),
|
||||
}
|
||||
fn verge(enable_auto_launch: bool, enable_tun_mode: bool) -> IVerge {
|
||||
IVerge {
|
||||
enable_auto_launch: Some(enable_auto_launch),
|
||||
enable_tun_mode: Some(enable_tun_mode),
|
||||
}
|
||||
}
|
||||
|
||||
fn block_on_ready<F: Future>(fut: F) -> F::Output {
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(fut)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_draft_basic_flow() {
|
||||
let verge = IVerge {
|
||||
enable_auto_launch: Some(true),
|
||||
enable_tun_mode: Some(false),
|
||||
};
|
||||
let draft = Draft::new(verge);
|
||||
let draft = Draft::new(verge(true, false));
|
||||
|
||||
// 读取正式数据(data_arc)
|
||||
{
|
||||
let data = draft.data_arc();
|
||||
assert_eq!(data.enable_auto_launch, Some(true));
|
||||
assert_eq!(data.enable_tun_mode, Some(false));
|
||||
}
|
||||
|
||||
// 修改草稿(使用 edit_draft)
|
||||
draft.edit_draft(|d| {
|
||||
d.enable_auto_launch = Some(false);
|
||||
d.enable_tun_mode = Some(true);
|
||||
});
|
||||
|
||||
// 正式数据未变
|
||||
{
|
||||
let data = draft.data_arc();
|
||||
assert_eq!(data.enable_auto_launch, Some(true));
|
||||
assert_eq!(data.enable_tun_mode, Some(false));
|
||||
}
|
||||
|
||||
// 草稿已变
|
||||
{
|
||||
let latest = draft.latest_arc();
|
||||
assert_eq!(latest.enable_auto_launch, Some(false));
|
||||
assert_eq!(latest.enable_tun_mode, Some(true));
|
||||
}
|
||||
|
||||
// 提交草稿
|
||||
draft.apply();
|
||||
|
||||
// 正式数据已更新
|
||||
{
|
||||
let data = draft.data_arc();
|
||||
assert_eq!(data.enable_auto_launch, Some(false));
|
||||
@@ -91,18 +72,13 @@ mod tests {
|
||||
assert_eq!(latest.enable_tun_mode, Some(true));
|
||||
}
|
||||
|
||||
// 丢弃草稿
|
||||
draft.discard();
|
||||
|
||||
// 丢弃后再次创建草稿,会从已提交重新 clone
|
||||
{
|
||||
draft.edit_draft(|d| {
|
||||
// 原 committed 是 enable_auto_launch = Some(false)
|
||||
assert_eq!(d.enable_auto_launch, Some(false));
|
||||
// 再修改一下
|
||||
d.enable_tun_mode = Some(false);
|
||||
});
|
||||
// 草稿中值已修改,但正式数据仍是 apply 后的值
|
||||
let data = draft.data_arc();
|
||||
assert_eq!(data.enable_auto_launch, Some(false));
|
||||
assert_eq!(data.enable_tun_mode, Some(true));
|
||||
@@ -111,41 +87,29 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_arc_pointer_behavior_on_edit_and_apply() {
|
||||
let draft = Draft::new(IVerge {
|
||||
enable_auto_launch: Some(true),
|
||||
enable_tun_mode: Some(false),
|
||||
});
|
||||
let draft = Draft::new(verge(true, false));
|
||||
|
||||
// 初始 latest == committed
|
||||
let committed = draft.data_arc();
|
||||
let latest = draft.latest_arc();
|
||||
assert!(std::sync::Arc::ptr_eq(&committed, &latest));
|
||||
assert!(Arc::ptr_eq(&committed, &latest));
|
||||
|
||||
// 第一次 edit:由于与 committed 共享,Arc::make_mut 会克隆
|
||||
draft.edit_draft(|d| d.enable_tun_mode = Some(true));
|
||||
let committed_after_first_edit = draft.data_arc();
|
||||
let draft_after_first_edit = draft.latest_arc();
|
||||
assert!(!std::sync::Arc::ptr_eq(
|
||||
&committed_after_first_edit,
|
||||
&draft_after_first_edit
|
||||
));
|
||||
// 提交会把 committed 指向草稿的 Arc
|
||||
let prev_draft_ptr = std::sync::Arc::as_ptr(&draft_after_first_edit);
|
||||
assert!(!Arc::ptr_eq(&committed_after_first_edit, &draft_after_first_edit));
|
||||
let prev_draft_ptr = Arc::as_ptr(&draft_after_first_edit);
|
||||
draft.apply();
|
||||
let committed_after_apply = draft.data_arc();
|
||||
assert_eq!(std::sync::Arc::as_ptr(&committed_after_apply), prev_draft_ptr);
|
||||
assert_eq!(Arc::as_ptr(&committed_after_apply), prev_draft_ptr);
|
||||
|
||||
// 第二次编辑:此时草稿唯一持有(无其它引用),不应再克隆
|
||||
// 获取草稿 Arc 的指针并立即丢弃本地引用,避免增加 strong_count
|
||||
draft.edit_draft(|d| d.enable_auto_launch = Some(false));
|
||||
let latest1 = draft.latest_arc();
|
||||
let latest1_ptr = std::sync::Arc::as_ptr(&latest1);
|
||||
drop(latest1); // 确保只有 Draft 内部持有草稿 Arc
|
||||
let latest1_ptr = Arc::as_ptr(&latest1);
|
||||
drop(latest1);
|
||||
|
||||
// 再次编辑(unique,Arc::make_mut 不应克隆)
|
||||
draft.edit_draft(|d| d.enable_tun_mode = Some(false));
|
||||
let latest2 = draft.latest_arc();
|
||||
let latest2_ptr = std::sync::Arc::as_ptr(&latest2);
|
||||
let latest2_ptr = Arc::as_ptr(&latest2);
|
||||
|
||||
assert_eq!(latest1_ptr, latest2_ptr, "Unique edit should not clone Arc");
|
||||
assert_eq!(latest2.enable_auto_launch, Some(false));
|
||||
@@ -154,22 +118,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_discard_restores_latest_to_committed() {
|
||||
let draft = Draft::new(IVerge {
|
||||
enable_auto_launch: Some(false),
|
||||
enable_tun_mode: Some(false),
|
||||
});
|
||||
let draft = Draft::new(verge(false, false));
|
||||
|
||||
// 创建草稿并修改
|
||||
draft.edit_draft(|d| d.enable_auto_launch = Some(true));
|
||||
let committed = draft.data_arc();
|
||||
let latest = draft.latest_arc();
|
||||
assert!(!std::sync::Arc::ptr_eq(&committed, &latest));
|
||||
assert!(!Arc::ptr_eq(&committed, &latest));
|
||||
|
||||
// 丢弃草稿后 latest 应回到 committed
|
||||
draft.discard();
|
||||
let committed2 = draft.data_arc();
|
||||
let latest2 = draft.latest_arc();
|
||||
assert!(std::sync::Arc::ptr_eq(&committed2, &latest2));
|
||||
assert!(Arc::ptr_eq(&committed2, &latest2));
|
||||
assert_eq!(latest2.enable_auto_launch, Some(false));
|
||||
}
|
||||
|
||||
@@ -187,23 +146,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_with_data_modify_ok_and_replaces_committed() {
|
||||
let draft = Draft::new(IVerge {
|
||||
enable_auto_launch: Some(false),
|
||||
enable_tun_mode: Some(false),
|
||||
});
|
||||
let draft = Draft::new(verge(false, false));
|
||||
|
||||
// 使用 with_data_modify 异步(立即就绪)地更新 committed
|
||||
let res = block_on_ready(draft.with_data_modify(|mut v| async move {
|
||||
v.enable_auto_launch = Some(true);
|
||||
Ok((v, "done"))
|
||||
}));
|
||||
assert_eq!(
|
||||
{
|
||||
#[allow(clippy::unwrap_used)]
|
||||
res.unwrap()
|
||||
},
|
||||
"done"
|
||||
);
|
||||
assert_eq!(res.unwrap(), "done");
|
||||
|
||||
let committed = draft.data_arc();
|
||||
assert_eq!(committed.enable_auto_launch, Some(true));
|
||||
@@ -211,7 +160,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_data_modify_error_propagation() {
|
||||
fn test_with_data_modify_keeps_boxed_data_shape() {
|
||||
let draft = Draft::new(Box::new(verge(false, false)));
|
||||
|
||||
block_on_ready(draft.with_data_modify(|mut v| async move {
|
||||
v.enable_auto_launch = Some(true);
|
||||
Ok((v, ()))
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(draft.data_arc().enable_auto_launch, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_data_modify_error_releases_permit() {
|
||||
let draft = Draft::new(IVerge::default());
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
@@ -219,42 +181,122 @@ mod tests {
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(format!("{err}"), "boom");
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
block_on_ready(draft.with_data_modify(|mut v| async move {
|
||||
v.enable_auto_launch = Some(true);
|
||||
Ok((v, ()))
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(draft.data_arc().enable_auto_launch, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_data_modify_serialized_under_stress_levels() {
|
||||
for task_count in STRESS_LEVELS {
|
||||
run_with_data_modify_stress(task_count, Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_data_modify_keeps_apply_conflict_detection() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let draft = Arc::new(Draft::new(verge(false, false)));
|
||||
let started = Arc::new(Notify::new());
|
||||
let finish = Arc::new(Notify::new());
|
||||
|
||||
rt.block_on(async {
|
||||
let task = {
|
||||
let draft = Arc::clone(&draft);
|
||||
let started = Arc::clone(&started);
|
||||
let finish = Arc::clone(&finish);
|
||||
tokio::spawn(async move {
|
||||
draft
|
||||
.with_data_modify(|mut v| async move {
|
||||
started.notify_one();
|
||||
finish.notified().await;
|
||||
v.enable_auto_launch = Some(true);
|
||||
Ok((v, ()))
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
|
||||
started.notified().await;
|
||||
draft.edit_draft(|d| d.enable_tun_mode = Some(true));
|
||||
draft.apply();
|
||||
finish.notify_one();
|
||||
|
||||
let err = task.await.expect("task panicked").unwrap_err();
|
||||
assert_eq!(
|
||||
format!("{err}"),
|
||||
"Optimistic lock failed: Committed data has changed during async operation"
|
||||
);
|
||||
});
|
||||
|
||||
let committed = draft.data_arc();
|
||||
assert_eq!(committed.enable_auto_launch, Some(false));
|
||||
assert_eq!(committed.enable_tun_mode, Some(true));
|
||||
}
|
||||
|
||||
fn run_with_data_modify_stress(task_count: usize, delay: Duration) {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let draft = Arc::new(Draft::new(verge(false, false)));
|
||||
|
||||
rt.block_on(async {
|
||||
let mut handles = Vec::with_capacity(task_count);
|
||||
for _ in 0..task_count {
|
||||
let draft = Arc::clone(&draft);
|
||||
handles.push(tokio::spawn(async move {
|
||||
draft
|
||||
.with_data_modify(|mut v| async move {
|
||||
if !delay.is_zero() {
|
||||
sleep(delay).await;
|
||||
}
|
||||
v.enable_auto_launch = Some(!v.enable_auto_launch.unwrap_or(false));
|
||||
Ok((v, ()))
|
||||
})
|
||||
.await
|
||||
}));
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
handle.await.expect("task panicked").expect("with_data_modify failed");
|
||||
}
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_data_modify_does_not_touch_existing_draft() {
|
||||
let draft = Draft::new(IVerge {
|
||||
enable_auto_launch: Some(false),
|
||||
enable_tun_mode: Some(false),
|
||||
});
|
||||
let draft = Draft::new(verge(false, false));
|
||||
|
||||
// 创建草稿并修改
|
||||
draft.edit_draft(|d| {
|
||||
d.enable_auto_launch = Some(true);
|
||||
d.enable_tun_mode = Some(true);
|
||||
});
|
||||
let draft_before = draft.latest_arc();
|
||||
let draft_before_ptr = std::sync::Arc::as_ptr(&draft_before);
|
||||
let draft_before_ptr = Arc::as_ptr(&draft_before);
|
||||
|
||||
// 同时通过 with_data_modify 修改 committed
|
||||
#[allow(clippy::unwrap_used)]
|
||||
block_on_ready(draft.with_data_modify(|mut v| async move {
|
||||
v.enable_auto_launch = Some(false); // 与草稿不同
|
||||
v.enable_auto_launch = Some(false);
|
||||
Ok((v, ()))
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
// 草稿应保持不变
|
||||
let draft_after = draft.latest_arc();
|
||||
assert_eq!(
|
||||
std::sync::Arc::as_ptr(&draft_after),
|
||||
Arc::as_ptr(&draft_after),
|
||||
draft_before_ptr,
|
||||
"Existing draft should not be replaced by with_data_modify"
|
||||
);
|
||||
assert_eq!(draft_after.enable_auto_launch, Some(true));
|
||||
assert_eq!(draft_after.enable_tun_mode, Some(true));
|
||||
|
||||
// 丢弃草稿后 latest == committed,且 committed 为异步修改结果
|
||||
draft.discard();
|
||||
let latest = draft.latest_arc();
|
||||
assert_eq!(latest.enable_auto_launch, Some(false));
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rust-i18n = "4.0.0"
|
||||
rust-i18n = "4.1.0"
|
||||
sys-locale = "0.3.2"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -34,6 +34,7 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
||||
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
tray:
|
||||
dashboard: لوحة التحكم
|
||||
ruleMode: وضع القواعد
|
||||
|
||||
@@ -34,6 +34,7 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
|
||||
adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
tray:
|
||||
dashboard: Übersicht
|
||||
ruleMode: Regelmodus
|
||||
|
||||
@@ -34,6 +34,7 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
tray:
|
||||
dashboard: Dashboard
|
||||
ruleMode: Rule Mode
|
||||
|
||||
@@ -34,6 +34,7 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
|
||||
adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
tray:
|
||||
dashboard: Panel
|
||||
ruleMode: Modo de reglas
|
||||
|
||||
@@ -34,6 +34,7 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
||||
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
tray:
|
||||
dashboard: داشبورد
|
||||
ruleMode: حالت قوانین
|
||||
|
||||
@@ -34,6 +34,7 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
|
||||
adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
tray:
|
||||
dashboard: Dasbor
|
||||
ruleMode: Mode Aturan
|
||||
|
||||
@@ -34,6 +34,7 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
|
||||
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
tray:
|
||||
dashboard: ダッシュボード
|
||||
ruleMode: ルールモード
|
||||
|
||||
@@ -34,6 +34,7 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
|
||||
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
tray:
|
||||
dashboard: 대시보드
|
||||
ruleMode: 규칙 모드
|
||||
|
||||
@@ -34,6 +34,7 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
|
||||
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
tray:
|
||||
dashboard: Панель
|
||||
ruleMode: Режим правил
|
||||
|
||||
@@ -34,6 +34,7 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
|
||||
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
tray:
|
||||
dashboard: Gösterge Paneli
|
||||
ruleMode: Kural Modu
|
||||
|
||||
@@ -34,6 +34,7 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
|
||||
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
tray:
|
||||
dashboard: Идарә панеле
|
||||
ruleMode: Кагыйдә режимы
|
||||
|
||||
@@ -34,6 +34,7 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
|
||||
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限
|
||||
translocatedCorePath: 检测到 Clash Verge 从临时路径(App Translocation)运行,无法定位内核。请将 Clash Verge 移动到「应用程序」文件夹后重新打开。
|
||||
tray:
|
||||
dashboard: 仪表板
|
||||
ruleMode: 规则模式
|
||||
|
||||
@@ -34,6 +34,7 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
|
||||
adminUninstallPrompt: 卸载 Clash Verge 服務需要管理員權限
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
tray:
|
||||
dashboard: 儀表板
|
||||
ruleMode: 規則模式
|
||||
|
||||
@@ -14,20 +14,18 @@ where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future + Send + 'static,
|
||||
{
|
||||
RUNTIME.get_or_init(
|
||||
|| match tokio::runtime::Builder::new_current_thread().enable_all().build() {
|
||||
Ok(rt) => Some(rt),
|
||||
Err(e) => {
|
||||
logging!(
|
||||
info,
|
||||
Type::SystemSignal,
|
||||
"register shutdown signal failed, create tokio runtime error: {}",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
);
|
||||
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
|
||||
Ok(rt) => Some(rt),
|
||||
Err(e) => {
|
||||
logging!(
|
||||
info,
|
||||
Type::SystemSignal,
|
||||
"register shutdown signal failed, create tokio runtime error: {}",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(unix)]
|
||||
unix::register(f);
|
||||
|
||||
@@ -8,12 +8,13 @@ rust-version = "1.91"
|
||||
tauri = { workspace = true }
|
||||
tauri-plugin-clipboard-manager = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
# sysinfo 0.38.2 conflicts with dark-light
|
||||
# see https://github.com/GuillaumeGomez/sysinfo/issues/1623
|
||||
sysinfo = { version = "0.38", features = ["network", "system"] }
|
||||
sysinfo = { version = "0.39.5", features = ["network", "system"] }
|
||||
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
libc = "0.2.183"
|
||||
libc = "0.2.186"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
deelevate = { workspace = true }
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Runtime, State, command};
|
||||
use tauri_plugin_clipboard_manager::{ClipboardExt as _, Error};
|
||||
|
||||
use crate::Platform;
|
||||
|
||||
// TODO 迁移,让新的结构体允许通过 tauri command 正确使用 structure.field 方式获取信息
|
||||
#[derive(Serialize)]
|
||||
pub struct SystemInfo {
|
||||
pub system_name: String,
|
||||
pub system_version: String,
|
||||
pub system_kernel_version: String,
|
||||
pub system_arch: String,
|
||||
pub app_version: String,
|
||||
pub app_core_mode: String,
|
||||
pub app_is_admin: bool,
|
||||
}
|
||||
|
||||
impl From<Platform> for SystemInfo {
|
||||
fn from(platform: Platform) -> Self {
|
||||
Self {
|
||||
system_name: platform.sysinfo.system_name,
|
||||
system_version: platform.sysinfo.system_version,
|
||||
system_kernel_version: platform.sysinfo.system_kernel_version,
|
||||
system_arch: platform.sysinfo.system_arch,
|
||||
app_version: platform.appinfo.app_version,
|
||||
app_core_mode: platform.appinfo.app_core_mode,
|
||||
app_is_admin: platform.appinfo.app_is_admin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<String, Error> {
|
||||
Ok(state.inner().read().to_string())
|
||||
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<SystemInfo, Error> {
|
||||
let platform = state.inner().read();
|
||||
Ok(SystemInfo::from(platform.clone()))
|
||||
}
|
||||
|
||||
/// 获取应用的运行时间(毫秒)
|
||||
|
||||
@@ -16,6 +16,7 @@ use tauri::{
|
||||
plugin::{Builder, TauriPlugin},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SysInfo {
|
||||
system_name: String,
|
||||
system_version: String,
|
||||
@@ -39,6 +40,7 @@ impl Default for SysInfo {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppInfo {
|
||||
app_version: String,
|
||||
app_core_mode: String,
|
||||
@@ -62,7 +64,7 @@ impl Default for AppInfo {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Platform {
|
||||
pub sysinfo: SysInfo,
|
||||
pub appinfo: AppInfo,
|
||||
|
||||
@@ -21,15 +21,13 @@ src/locales/
|
||||
home.json
|
||||
shared.json
|
||||
...
|
||||
index.ts
|
||||
zh/
|
||||
...
|
||||
```
|
||||
|
||||
- JSON files map to namespaces (for example `home.json` → `home.*`). Keep keys scoped to the file they belong to.
|
||||
- `shared.json` stores reusable vocabulary (buttons, validations, etc.); feature-specific wording should live in the relevant namespace.
|
||||
- `index.ts` re-exports a `resources` object that aggregates the namespace JSON files. When adding or removing namespaces, mirror the pattern from `src/locales/en/index.ts`.
|
||||
- Frontend bundles are lazy-loaded by `src/services/i18n.ts`. Only languages listed in `supportedLanguages` are fetched at runtime, so append new codes there when you add a locale.
|
||||
- `src/services/i18n.ts` automatically discovers and lazy-loads each locale's JSON sections, so no aggregate module is required. Keep locales aligned with `src/locales/en/`, and add new codes to `supportedLanguages`.
|
||||
|
||||
Because backend translations now live in their own directory, you no longer need to run `pnpm prebuild` just to sync locales—the frontend folder is the sole source of truth for web bundles.
|
||||
|
||||
@@ -52,10 +50,9 @@ Native UI strings (tray menu, notifications, dialogs) use `rust-i18n` with YAML
|
||||
## Adding a new language
|
||||
|
||||
1. Duplicate `src/locales/en/` into `src/locales/<new-lang>/` and translate the JSON files while preserving key structure.
|
||||
2. Update the locale’s `index.ts` to import every namespace. Matching the English file is the easiest way to avoid missing exports.
|
||||
3. Append the language code to `supportedLanguages` in `src/services/i18n.ts`.
|
||||
4. If the backend should expose the language, create `crates/clash-verge-i18n/<new-lang>.yml` and translate the keys used in existing YAML files.
|
||||
5. Run `pnpm i18n:format`, `pnpm i18n:types`, and (optionally) `pnpm i18n:check` in dry-run mode to confirm structure.
|
||||
2. Append the language code to `supportedLanguages` in `src/services/i18n.ts`.
|
||||
3. If the backend should expose the language, create `crates/clash-verge-i18n/<new-lang>.yml` and translate the keys used in existing YAML files.
|
||||
4. Run `pnpm i18n:format`, `pnpm i18n:types`, and (optionally) `pnpm i18n:check` in dry-run mode to confirm structure.
|
||||
|
||||
## Authoring guidelines
|
||||
|
||||
|
||||
@@ -1,3 +1,50 @@
|
||||
## v2.5.1
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
- 备份设置功能异常
|
||||
- 修复 Windows 节点交互异常
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## v2.5.0
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 关于版本的说明:Clash Verge 版本号遵循 x.y.z:x 为重大架构变更,y 为功能新增,z 为 Bug 修复。
|
||||
|
||||
- **Mihomo(Meta) 内核升级至 v1.19.25**
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
- 修复系统代理关闭后在 PAC 模式下未完全关闭
|
||||
- 修复 macOS 开关代理时可能的卡死
|
||||
- 修复修改定时自动更新后记时未及时刷新
|
||||
- 修复 Linux 关闭 TUN 不立即生效
|
||||
- 修复系统代理关闭序列逻辑(防止快速退出时系统代理关闭状态没有保存)
|
||||
- 修复 Linux 快捷键映射错误
|
||||
- 修复 Linux Wayland 兼容性导致渲染错误
|
||||
- 修复 Linux 边缘情况无法加载页面
|
||||
|
||||
### ✨ 新增功能
|
||||
|
||||
- 订阅 QR code 分享
|
||||
- 新增 macOS 托盘速率显示
|
||||
- 快捷键操作通知操作结果
|
||||
- 软件自动更新(后台下载,下次启动自动安装)
|
||||
|
||||
### 🚀 优化改进
|
||||
|
||||
- 优化 macOS 读取系统代理性能
|
||||
- 优化前端 CPU 性能
|
||||
- 更健壮的服务模式与边缘状况内核恢复
|
||||
- 优化白名单网络下的订阅 TLS 更新兼容性
|
||||
|
||||
### 👙 界面样式
|
||||
|
||||
- 代理组实现 Sticky Scroll 效果
|
||||
|
||||
|
||||
## v2.4.7
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
+6
-1
@@ -2,15 +2,18 @@ import eslintJS from '@eslint/js'
|
||||
import eslintReact from '@eslint-react/eslint-plugin'
|
||||
import { defineConfig } from 'eslint/config'
|
||||
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
|
||||
import pluginESx from 'eslint-plugin-es-x'
|
||||
import pluginImportX from 'eslint-plugin-import-x'
|
||||
import pluginReactCompiler from 'eslint-plugin-react-compiler'
|
||||
import pluginReactHooks from 'eslint-plugin-react-hooks'
|
||||
import pluginReactRefresh from 'eslint-plugin-react-refresh'
|
||||
import PluginSukka from 'eslint-plugin-sukka'
|
||||
import pluginUnusedImports from 'eslint-plugin-unused-imports'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default defineConfig([
|
||||
pluginESx.configs['flat/restrict-to-es2022'],
|
||||
{
|
||||
files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
|
||||
@@ -19,9 +22,11 @@ export default defineConfig([
|
||||
// @ts-expect-error -- https://github.com/typescript-eslint/typescript-eslint/issues/11543
|
||||
'react-hooks': pluginReactHooks,
|
||||
'react-compiler': pluginReactCompiler,
|
||||
'es-x': pluginESx,
|
||||
'import-x': pluginImportX,
|
||||
'react-refresh': pluginReactRefresh,
|
||||
'unused-imports': pluginUnusedImports,
|
||||
sukka: PluginSukka,
|
||||
},
|
||||
|
||||
extends: [
|
||||
@@ -88,7 +93,7 @@ export default defineConfig([
|
||||
'@eslint-react/no-unused-class-component-members': 'error',
|
||||
'@eslint-react/no-unused-state': 'error',
|
||||
'@eslint-react/jsx-no-useless-fragment': 'warn',
|
||||
'@eslint-react/prefer-destructuring-assignment': 'warn',
|
||||
'sukka/react-prefer-destructuring-assignment': 'warn',
|
||||
|
||||
// TypeScript
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"entry": ["src/polyfills/*.js!"],
|
||||
"project": ["src/**/*.{js,jsx,ts,tsx}!", "scripts/**/*.{js,mjs}!"],
|
||||
"include": [
|
||||
"files",
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"unlisted",
|
||||
"unresolved",
|
||||
"binaries",
|
||||
"exports",
|
||||
"types",
|
||||
"duplicates",
|
||||
"cycles"
|
||||
],
|
||||
"ignoreBinaries": ["rustc"],
|
||||
"ignoreDependencies": ["lint-staged"],
|
||||
"ignoreIssues": {
|
||||
"src/types/generated/**": ["exports", "types"]
|
||||
},
|
||||
"cycles": {
|
||||
"dynamicImports": true
|
||||
}
|
||||
}
|
||||
+54
-62
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clash-verge",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.2",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"prepare": "husky || true",
|
||||
@@ -31,7 +31,8 @@
|
||||
"i18n:check": "node scripts/cleanup-unused-i18n.mjs",
|
||||
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
|
||||
"i18n:types": "node scripts/generate-i18n-keys.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"knip:report": "knip --no-exit-code"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -41,85 +42,85 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@mui/icons-material": "^9.0.0",
|
||||
"@mui/lab": "9.0.0-beta.2",
|
||||
"@mui/material": "^9.0.0",
|
||||
"@tanstack/react-query": "^5.96.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tauri-apps/api": "2.10.1",
|
||||
"@mui/icons-material": "^9.0.1",
|
||||
"@mui/material": "^9.0.1",
|
||||
"@tanstack/react-virtual": "^3.13.25",
|
||||
"@tauri-apps/api": "2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-http": "~2.5.7",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-http": "~2.5.9",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "2.3.5",
|
||||
"@tauri-apps/plugin-updater": "2.10.1",
|
||||
"ahooks": "^3.9.6",
|
||||
"ahooks": "^3.9.7",
|
||||
"cidr-block": "^2.3.0",
|
||||
"dayjs": "1.11.20",
|
||||
"foxact": "^0.3.0",
|
||||
"foxts": "^5.3.0",
|
||||
"i18next": "^26.0.0",
|
||||
"dayjs": "1.11.21",
|
||||
"foxact": "^0.3.4",
|
||||
"foxts": "^5.4.2",
|
||||
"i18next": "^26.2.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lodash-es": "^4.17.23",
|
||||
"meta-json-schema": "^1.19.21",
|
||||
"lodash-es": "^4.18.1",
|
||||
"meta-json-schema": "^1.19.25",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"monaco-yaml": "^5.4.1",
|
||||
"nanoid": "^5.1.7",
|
||||
"monaco-yaml": "^5.5.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-error-boundary": "6.1.1",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"react-i18next": "17.0.6",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-error-boundary": "6.1.2",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "^7.13.1",
|
||||
"react-router": "^8.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"swr": "^2.4.2",
|
||||
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo",
|
||||
"types-pac": "^1.0.3",
|
||||
"validator": "^13.15.26"
|
||||
"validator": "^13.15.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/github": "^9.0.0",
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
"@eslint-react/eslint-plugin": "^4.0.0",
|
||||
"@actions/github": "^9.1.1",
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@eslint-react/eslint-plugin": "^5.8.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "2.10.1",
|
||||
"@tauri-apps/cli": "2.11.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@vitejs/plugin-legacy": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.13.6",
|
||||
"@vitejs/plugin-legacy": "^8.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"adm-zip": "^0.5.17",
|
||||
"axios": "^1.16.1",
|
||||
"cli-color": "^2.0.4",
|
||||
"commander": "^14.0.3",
|
||||
"commander": "^15.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-es-x": "^9.6.0",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-sukka": "^9.5.0",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"glob": "^13.0.6",
|
||||
"globals": "^17.4.0",
|
||||
"globals": "^17.6.0",
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.6.1",
|
||||
"lint-staged": "^16.4.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"sass": "^1.98.0",
|
||||
"tar": "^7.5.12",
|
||||
"terser": "^5.46.1",
|
||||
"typescript": "^6.0.0",
|
||||
"typescript-eslint": "^8.57.1",
|
||||
"vite": "^8.0.1",
|
||||
"vite-plugin-svgr": "^5.0.0"
|
||||
"jiti": "^2.7.0",
|
||||
"knip": "6.26.0",
|
||||
"lint-staged": "^17.0.5",
|
||||
"sass": "^1.100.0",
|
||||
"tar": "^7.5.15",
|
||||
"terser": "^5.48.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"vite": "^8.0.14",
|
||||
"vite-plugin-svgr": "^5.2.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,mjs}": [
|
||||
@@ -131,14 +132,5 @@
|
||||
]
|
||||
},
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
"core-js",
|
||||
"es5-ext",
|
||||
"meta-json-schema",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
"packageManager": "pnpm@11.3.0"
|
||||
}
|
||||
|
||||
Generated
+2483
-1943
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
"@parcel/watcher": true
|
||||
core-js: true
|
||||
es5-ext: true
|
||||
meta-json-schema: true
|
||||
unrs-resolver: true
|
||||
minimumReleaseAgeExclude:
|
||||
- dayjs@1.11.21
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.91.0"
|
||||
channel = "1.95.0"
|
||||
components = ["rustfmt", "clippy"]
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
@@ -1020,7 +1018,7 @@ function loadBackendLocales() {
|
||||
const localePath = path.join(BACKEND_LOCALES_DIR, entry.name)
|
||||
const name = entry.name.replace(/\.(ya?ml)$/i, '')
|
||||
const raw = fs.readFileSync(localePath, 'utf8')
|
||||
let data = {}
|
||||
let data
|
||||
try {
|
||||
const parsed = yaml.load(raw)
|
||||
data = isPlainObject(parsed) ? parsed : {}
|
||||
@@ -1055,6 +1053,7 @@ function ensureBackup(localePath) {
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to recycle existing backup for ${path.basename(localePath)}: ${error.message}`,
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1086,59 +1085,6 @@ function cleanupBackups(backups) {
|
||||
backups.clear()
|
||||
}
|
||||
|
||||
function toModuleIdentifier(namespace, seen) {
|
||||
const RESERVED = new Set([
|
||||
'default',
|
||||
'function',
|
||||
'var',
|
||||
'let',
|
||||
'const',
|
||||
'import',
|
||||
'export',
|
||||
'class',
|
||||
'enum',
|
||||
])
|
||||
|
||||
const base =
|
||||
namespace.replace(/[^a-zA-Z0-9_$]/g, '_').replace(/^[^a-zA-Z_$]+/, '') ||
|
||||
'ns'
|
||||
|
||||
let candidate = base
|
||||
let counter = 1
|
||||
while (RESERVED.has(candidate) || seen.has(candidate)) {
|
||||
candidate = `${base}_${counter}`
|
||||
counter += 1
|
||||
}
|
||||
seen.add(candidate)
|
||||
return candidate
|
||||
}
|
||||
|
||||
function regenerateLocaleIndex(localeDir, namespaces) {
|
||||
const seen = new Set()
|
||||
const imports = []
|
||||
const mappings = []
|
||||
|
||||
for (const namespace of namespaces) {
|
||||
const filePath = path.join(localeDir, `${namespace}.json`)
|
||||
if (!fs.existsSync(filePath)) continue
|
||||
const identifier = toModuleIdentifier(namespace, seen)
|
||||
const key = namespace === identifier ? namespace : `'${namespace}'`
|
||||
imports.push(`import ${identifier} from './${namespace}.json'`)
|
||||
mappings.push(` ${key}: ${identifier},`)
|
||||
}
|
||||
|
||||
const content = `${imports.join('\n')}
|
||||
|
||||
const resources = {
|
||||
${mappings.join('\n')}
|
||||
}
|
||||
|
||||
export default resources
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(localeDir, 'index.ts'), content, 'utf8')
|
||||
}
|
||||
|
||||
function writeLocale(locale, data, options) {
|
||||
const backups = new Map()
|
||||
let success = false
|
||||
@@ -1163,7 +1109,6 @@ function writeLocale(locale, data, options) {
|
||||
}
|
||||
|
||||
const entries = Object.entries(data)
|
||||
const orderedNamespaces = entries.map(([namespace]) => namespace)
|
||||
const existingFiles = new Map(
|
||||
locale.files.map((file) => [file.namespace, file.path]),
|
||||
)
|
||||
@@ -1187,11 +1132,6 @@ function writeLocale(locale, data, options) {
|
||||
}
|
||||
}
|
||||
|
||||
regenerateLocaleIndex(locale.dir, orderedNamespaces)
|
||||
locale.files = orderedNamespaces.map((namespace) => ({
|
||||
namespace,
|
||||
path: path.join(locale.dir, `${namespace}.json`),
|
||||
}))
|
||||
success = true
|
||||
} finally {
|
||||
if (success) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execSync } from 'child_process'
|
||||
import { execFileSync, execSync } from 'child_process'
|
||||
import { createHash } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import fsp from 'fs/promises'
|
||||
@@ -8,7 +8,6 @@ import zlib from 'zlib'
|
||||
import AdmZip from 'adm-zip'
|
||||
import { glob } from 'glob'
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent'
|
||||
import fetch from 'node-fetch'
|
||||
import { extract } from 'tar'
|
||||
|
||||
import { log_debug, log_error, log_info, log_success } from './utils.mjs'
|
||||
@@ -410,7 +409,7 @@ async function resolveSidecar(binInfo) {
|
||||
throw new Error(`Expected binary not found in ${tempDir}`)
|
||||
await fsp.rename(path.join(tempDir, candidate), sidecarPath)
|
||||
}
|
||||
if (platform !== 'win32') execSync(`chmod 755 ${sidecarPath}`)
|
||||
if (platform !== 'win32') await fsp.chmod(sidecarPath, 0o755)
|
||||
log_success(`unzip finished: "${name}"`)
|
||||
} else if (zipFile.endsWith('.tgz')) {
|
||||
await extract({ cwd: tempDir, file: tempZip })
|
||||
@@ -426,7 +425,7 @@ async function resolveSidecar(binInfo) {
|
||||
if (!extracted) extracted = files[0]
|
||||
if (!extracted) throw new Error(`Expected file not found in ${tempDir}`)
|
||||
await fsp.rename(path.join(tempDir, extracted), sidecarPath)
|
||||
execSync(`chmod 755 ${sidecarPath}`)
|
||||
await fsp.chmod(sidecarPath, 0o755)
|
||||
log_success(`tgz processed: "${name}"`)
|
||||
} else {
|
||||
// .gz
|
||||
@@ -441,7 +440,8 @@ async function resolveSidecar(binInfo) {
|
||||
})
|
||||
.pipe(writeStream)
|
||||
.on('finish', () => {
|
||||
if (platform !== 'win32') execSync(`chmod 755 ${sidecarPath}`)
|
||||
if (platform !== 'win32')
|
||||
execFileSync('chmod', ['755', sidecarPath])
|
||||
resolve()
|
||||
})
|
||||
.on('error', (e) => {
|
||||
|
||||
+31
-13
@@ -1,6 +1,6 @@
|
||||
// scripts/publish-version.mjs
|
||||
import { spawn } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { execFileSync, spawn } from 'child_process'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const rootDir = process.cwd()
|
||||
@@ -29,7 +29,27 @@ const runRelease = () =>
|
||||
|
||||
// 2. 判断是否需要打 tag
|
||||
function isSemver(version) {
|
||||
return /^v?\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?$/.test(version)
|
||||
return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?$/i.test(
|
||||
version,
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeVersion(version) {
|
||||
return version.startsWith('v') ? version : `v${version}`
|
||||
}
|
||||
|
||||
function readPackageVersion() {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(path.join(rootDir, 'package.json'), 'utf8'),
|
||||
)
|
||||
return pkg.version
|
||||
}
|
||||
|
||||
function validateTag(tag) {
|
||||
if (!isSemver(tag)) {
|
||||
console.error(`[ERROR]: Invalid git tag: ${tag}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
@@ -37,22 +57,20 @@ async function run() {
|
||||
|
||||
let tag = null
|
||||
if (versionArg === 'alpha') {
|
||||
// 读取 package.json 里的主版本
|
||||
const pkg = await import(path.join(rootDir, 'package.json'), {
|
||||
assert: { type: 'json' },
|
||||
})
|
||||
tag = `v${pkg.default.version}-alpha`
|
||||
// 读取 release-version.mjs 写入后的版本
|
||||
tag = normalizeVersion(readPackageVersion())
|
||||
} else if (isSemver(versionArg)) {
|
||||
// 1.2.3 或 v1.2.3
|
||||
tag = versionArg.startsWith('v') ? versionArg : `v${versionArg}`
|
||||
// 1.2.3、v1.2.3、1.2.3-beta.1 或 1.2.3+build.1
|
||||
tag = normalizeVersion(versionArg)
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
validateTag(tag)
|
||||
|
||||
// 打 tag 并推送
|
||||
const { execSync } = await import('child_process')
|
||||
try {
|
||||
execSync(`git tag ${tag}`, { stdio: 'inherit' })
|
||||
execSync(`git push origin ${tag}`, { stdio: 'inherit' })
|
||||
execFileSync('git', ['tag', tag], { stdio: 'inherit' })
|
||||
execFileSync('git', ['push', 'origin', tag], { stdio: 'inherit' })
|
||||
console.log(`[INFO]: Git tag ${tag} created and pushed.`)
|
||||
} catch {
|
||||
console.error(`[ERROR]: Failed to create or push git tag: ${tag}`)
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const VERSION_URL =
|
||||
'https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt'
|
||||
const DEFAULT_CHANGELOG = 'Changelog.md'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const CHECK = args.includes('--check')
|
||||
const DRY_RUN = args.includes('--dry-run')
|
||||
|
||||
const logError = (message, ...optionalParams) =>
|
||||
console.error(message, ...optionalParams)
|
||||
const logInfo = (message, ...optionalParams) =>
|
||||
console.log(message, ...optionalParams)
|
||||
|
||||
function readArgValue(name) {
|
||||
const index = args.indexOf(name)
|
||||
if (index === -1) return null
|
||||
return args[index + 1] ?? null
|
||||
}
|
||||
|
||||
function normalizeVersion(version) {
|
||||
const normalized = version.trim()
|
||||
return normalized.startsWith('v') ? normalized : `v${normalized}`
|
||||
}
|
||||
|
||||
async function fetchLatestVersion() {
|
||||
const explicitVersion = readArgValue('--version')
|
||||
if (explicitVersion) return normalizeVersion(explicitVersion)
|
||||
|
||||
const response = await fetch(VERSION_URL, {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${VERSION_URL}: ${response.status}`)
|
||||
}
|
||||
|
||||
return normalizeVersion(await response.text())
|
||||
}
|
||||
|
||||
function replaceExistingLine(content, version) {
|
||||
const mihomoLine =
|
||||
/^(\s*-\s*(?:\*\*)?\s*Mihomo\(Meta\)\s*内核升级至\s*)v?\d+(?:\.\d+)+(?:[-+][^\s*]+)?((?:\*\*)?\s*)$/m
|
||||
|
||||
if (!mihomoLine.test(content)) return null
|
||||
|
||||
return content.replace(mihomoLine, `$1${version}$2`)
|
||||
}
|
||||
|
||||
function insertMihomoLine(content, version) {
|
||||
const title = /^## .+$/m
|
||||
const match = content.match(title)
|
||||
|
||||
if (!match || match.index === undefined) {
|
||||
throw new Error('could not find changelog title')
|
||||
}
|
||||
|
||||
const titleEnd = match.index + match[0].length
|
||||
const before = content.slice(0, titleEnd)
|
||||
const after = content.slice(titleEnd).replace(/^\n*/, '\n\n')
|
||||
|
||||
return `${before}\n\n- **Mihomo(Meta) 内核升级至 ${version}**${after}`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cwd = process.cwd()
|
||||
const changelogArg = readArgValue('--file')
|
||||
const changelogPath = path.join(cwd, changelogArg || DEFAULT_CHANGELOG)
|
||||
|
||||
if (!fs.existsSync(changelogPath)) {
|
||||
throw new Error(`could not find ${path.relative(cwd, changelogPath)}`)
|
||||
}
|
||||
|
||||
const version = await fetchLatestVersion()
|
||||
const content = await fsp.readFile(changelogPath, 'utf-8')
|
||||
const nextContent =
|
||||
replaceExistingLine(content, version) ?? insertMihomoLine(content, version)
|
||||
|
||||
if (nextContent === content) {
|
||||
logInfo(`Mihomo version in changelog is already ${version}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (CHECK) {
|
||||
logError(`Mihomo version in changelog is not ${version}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
logInfo(`Would update Mihomo version in changelog to ${version}`)
|
||||
return
|
||||
}
|
||||
|
||||
await fsp.writeFile(changelogPath, nextContent)
|
||||
logInfo(`Updated Mihomo version in changelog to ${version}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
logError('sync-mihomo-version failed:', error.message)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { context, getOctokit } from '@actions/github'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { resolveUpdateLog } from './updatelog.mjs'
|
||||
|
||||
|
||||
+113
-31
@@ -1,5 +1,4 @@
|
||||
import { getOctokit, context } from '@actions/github'
|
||||
import fetch from 'node-fetch'
|
||||
import { context, getOctokit } from '@actions/github'
|
||||
|
||||
import { resolveUpdateLog, resolveUpdateLogDefault } from './updatelog.mjs'
|
||||
|
||||
@@ -92,21 +91,38 @@ async function processRelease(github, options, tag, isAlpha) {
|
||||
),
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms: {
|
||||
win64: { signature: '', url: '' }, // compatible with older formats
|
||||
linux: { signature: '', url: '' }, // compatible with older formats
|
||||
darwin: { signature: '', url: '' }, // compatible with older formats
|
||||
'darwin-aarch64': { signature: '', url: '' },
|
||||
'darwin-intel': { signature: '', url: '' },
|
||||
// platform format:
|
||||
// standard: "{os}-{arch}-{installer}",
|
||||
// fallback: "{os}-{arch}"
|
||||
'darwin-x86_64': { signature: '', url: '' },
|
||||
'linux-x86_64': { signature: '', url: '' },
|
||||
'darwin-x86_64-app': { signature: '', url: '' },
|
||||
'darwin-aarch64': { signature: '', url: '' },
|
||||
'darwin-aarch64-app': { signature: '', url: '' },
|
||||
|
||||
'linux-x86': { signature: '', url: '' },
|
||||
'linux-x86-deb': { signature: '', url: '' },
|
||||
'linux-x86-rpm': { signature: '', url: '' },
|
||||
'linux-x86_64': { signature: '', url: '' },
|
||||
'linux-x86_64-deb': { signature: '', url: '' },
|
||||
'linux-x86_64-rpm': { signature: '', url: '' },
|
||||
'linux-i686': { signature: '', url: '' },
|
||||
'linux-i686-deb': { signature: '', url: '' },
|
||||
'linux-i686-rpm': { signature: '', url: '' },
|
||||
'linux-aarch64': { signature: '', url: '' },
|
||||
'linux-aarch64-deb': { signature: '', url: '' },
|
||||
'linux-aarch64-rpm': { signature: '', url: '' },
|
||||
'linux-armv7': { signature: '', url: '' },
|
||||
'windows-x86_64': { signature: '', url: '' },
|
||||
'windows-aarch64': { signature: '', url: '' },
|
||||
'linux-armv7-deb': { signature: '', url: '' },
|
||||
'linux-armv7-rpm': { signature: '', url: '' },
|
||||
|
||||
'windows-x86': { signature: '', url: '' },
|
||||
'windows-x86-nsis': { signature: '', url: '' },
|
||||
'windows-x86_64': { signature: '', url: '' },
|
||||
'windows-x86_64-nsis': { signature: '', url: '' },
|
||||
'windows-aarch64': { signature: '', url: '' },
|
||||
'windows-aarch64-nsis': { signature: '', url: '' },
|
||||
'windows-i686': { signature: '', url: '' },
|
||||
'windows-i686-nsis': { signature: '', url: '' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -116,73 +132,139 @@ async function processRelease(github, options, tag, isAlpha) {
|
||||
// Process all the platform URL and signature data
|
||||
// win64 url
|
||||
if (name.endsWith('x64-setup.exe')) {
|
||||
updateData.platforms.win64.url = browser_download_url
|
||||
updateData.platforms['windows-x86_64'].url = browser_download_url
|
||||
updateData.platforms['windows-x86_64-nsis'].url = browser_download_url
|
||||
}
|
||||
// win64 signature
|
||||
if (name.endsWith('x64-setup.exe.sig')) {
|
||||
const sig = await getSignature(browser_download_url)
|
||||
updateData.platforms.win64.signature = sig
|
||||
updateData.platforms['windows-x86_64'].signature = sig
|
||||
updateData.platforms['windows-x86_64-nsis'].signature = sig
|
||||
}
|
||||
|
||||
// win32 url
|
||||
if (name.endsWith('x86-setup.exe')) {
|
||||
updateData.platforms['windows-x86'].url = browser_download_url
|
||||
updateData.platforms['windows-i686'].url = browser_download_url
|
||||
updateData.platforms['windows-x86-nsis'].url = browser_download_url
|
||||
updateData.platforms['windows-i686-nsis'].url = browser_download_url
|
||||
}
|
||||
// win32 signature
|
||||
if (name.endsWith('x86-setup.exe.sig')) {
|
||||
const sig = await getSignature(browser_download_url)
|
||||
updateData.platforms['windows-x86'].signature = sig
|
||||
updateData.platforms['windows-i686'].signature = sig
|
||||
updateData.platforms['windows-x86-nsis'].signature = sig
|
||||
updateData.platforms['windows-i686-nsis'].signature = sig
|
||||
}
|
||||
|
||||
// win arm url
|
||||
if (name.endsWith('arm64-setup.exe')) {
|
||||
updateData.platforms['windows-aarch64'].url = browser_download_url
|
||||
updateData.platforms['windows-aarch64-nsis'].url = browser_download_url
|
||||
}
|
||||
// win arm signature
|
||||
if (name.endsWith('arm64-setup.exe.sig')) {
|
||||
const sig = await getSignature(browser_download_url)
|
||||
updateData.platforms['windows-aarch64'].signature = sig
|
||||
updateData.platforms['windows-aarch64-nsis'].signature = sig
|
||||
}
|
||||
|
||||
// darwin url (intel)
|
||||
if (name.endsWith('.app.tar.gz') && !name.includes('aarch')) {
|
||||
updateData.platforms.darwin.url = browser_download_url
|
||||
updateData.platforms['darwin-intel'].url = browser_download_url
|
||||
updateData.platforms['darwin-x86_64'].url = browser_download_url
|
||||
updateData.platforms['darwin-x86_64-app'].url = browser_download_url
|
||||
}
|
||||
// darwin signature (intel)
|
||||
if (name.endsWith('.app.tar.gz.sig') && !name.includes('aarch')) {
|
||||
const sig = await getSignature(browser_download_url)
|
||||
updateData.platforms.darwin.signature = sig
|
||||
updateData.platforms['darwin-intel'].signature = sig
|
||||
updateData.platforms['darwin-x86_64'].signature = sig
|
||||
updateData.platforms['darwin-x86_64-app'].signature = sig
|
||||
}
|
||||
|
||||
// darwin url (aarch)
|
||||
if (name.endsWith('aarch64.app.tar.gz')) {
|
||||
updateData.platforms['darwin-aarch64'].url = browser_download_url
|
||||
// 使linux可以检查更新
|
||||
updateData.platforms.linux.url = browser_download_url
|
||||
updateData.platforms['linux-x86_64'].url = browser_download_url
|
||||
updateData.platforms['linux-x86'].url = browser_download_url
|
||||
updateData.platforms['linux-i686'].url = browser_download_url
|
||||
updateData.platforms['linux-aarch64'].url = browser_download_url
|
||||
updateData.platforms['linux-armv7'].url = browser_download_url
|
||||
updateData.platforms['darwin-aarch64-app'].url = browser_download_url
|
||||
}
|
||||
// darwin signature (aarch)
|
||||
if (name.endsWith('aarch64.app.tar.gz.sig')) {
|
||||
const sig = await getSignature(browser_download_url)
|
||||
updateData.platforms['darwin-aarch64'].signature = sig
|
||||
updateData.platforms.linux.signature = sig
|
||||
updateData.platforms['linux-x86_64'].signature = sig
|
||||
updateData.platforms['darwin-aarch64-app'].signature = sig
|
||||
}
|
||||
|
||||
// Linux x86
|
||||
if (name.endsWith('i386.deb')) {
|
||||
updateData.platforms['linux-x86'].url = browser_download_url
|
||||
updateData.platforms['linux-x86-deb'].url = browser_download_url
|
||||
updateData.platforms['linux-i686'].url = browser_download_url
|
||||
updateData.platforms['linux-i686-deb'].url = browser_download_url
|
||||
}
|
||||
if (name.endsWith('i386.deb.sig')) {
|
||||
const sig = await getSignature(browser_download_url)
|
||||
updateData.platforms['linux-x86'].signature = sig
|
||||
updateData.platforms['linux-x86-deb'].signature = sig
|
||||
updateData.platforms['linux-i686'].signature = sig
|
||||
updateData.platforms['linux-i686-deb'].signature = browser_download_url
|
||||
}
|
||||
if (name.endsWith('i386.rpm')) {
|
||||
updateData.platforms['linux-x86-rpm'].url = browser_download_url
|
||||
updateData.platforms['linux-i686-rpm'].url = browser_download_url
|
||||
}
|
||||
if (name.endsWith('i386.rpm.sig')) {
|
||||
const sig = await getSignature(browser_download_url)
|
||||
updateData.platforms['linux-x86-rpm'].signature = sig
|
||||
updateData.platforms['linux-i686-rpm'].signature = sig
|
||||
}
|
||||
|
||||
// Linux x86_64
|
||||
if (name.endsWith('amd64.deb')) {
|
||||
updateData.platforms['linux-x86_64'].url = browser_download_url
|
||||
updateData.platforms['linux-x86_64-deb'].url = browser_download_url
|
||||
}
|
||||
if (name.endsWith('amd64.deb.sig')) {
|
||||
const sig = await getSignature(browser_download_url)
|
||||
updateData.platforms['linux-x86_64'].signature = sig
|
||||
updateData.platforms['linux-x86_64-deb'].signature = sig
|
||||
}
|
||||
if (name.endsWith('x86_64.rpm')) {
|
||||
updateData.platforms['linux-x86_64-rpm'].url = browser_download_url
|
||||
}
|
||||
if (name.endsWith('x86_64.rpm.sig')) {
|
||||
const sig = await getSignature(browser_download_url)
|
||||
updateData.platforms['linux-x86_64-rpm'].signature = sig
|
||||
}
|
||||
|
||||
// Linux aarch64
|
||||
if (name.endsWith('arm64.deb')) {
|
||||
updateData.platforms['linux-aarch64'].url = browser_download_url
|
||||
updateData.platforms['linux-aarch64-deb'].url = browser_download_url
|
||||
}
|
||||
if (name.endsWith('arm64.deb.sig')) {
|
||||
const sig = await getSignature(browser_download_url)
|
||||
updateData.platforms['linux-aarch64'].signature = sig
|
||||
updateData.platforms['linux-aarch64-deb'].signature = sig
|
||||
}
|
||||
if (name.endsWith('aarch64.rpm')) {
|
||||
updateData.platforms['linux-aarch64-rpm'].url = browser_download_url
|
||||
}
|
||||
if (name.endsWith('aarch64.rpm.sig')) {
|
||||
const sig = await getSignature(browser_download_url)
|
||||
updateData.platforms['linux-aarch64-rpm'].signature = sig
|
||||
}
|
||||
|
||||
// Linux armv7
|
||||
if (name.endsWith('armhf.deb')) {
|
||||
updateData.platforms['linux-armv7'].url = browser_download_url
|
||||
updateData.platforms['linux-armv7-deb'].url = browser_download_url
|
||||
}
|
||||
if (name.endsWith('armhf.deb.sig')) {
|
||||
const sig = await getSignature(browser_download_url)
|
||||
updateData.platforms['linux-armv7'].signature = sig
|
||||
updateData.platforms['linux-armv7-deb'].signature = sig
|
||||
}
|
||||
if (name.endsWith('armhfp.rpm')) {
|
||||
updateData.platforms['linux-armv7-rpm'].url = browser_download_url
|
||||
}
|
||||
if (name.endsWith('armhfp.rpm.sig')) {
|
||||
const sig = await getSignature(browser_download_url)
|
||||
updateData.platforms['linux-armv7-rpm'].signature = sig
|
||||
}
|
||||
})
|
||||
|
||||
@@ -204,7 +286,7 @@ async function processRelease(github, options, tag, isAlpha) {
|
||||
Object.entries(updateDataNew.platforms).forEach(([key, value]) => {
|
||||
if (value.url) {
|
||||
updateDataNew.platforms[key].url =
|
||||
'https://update.hwdns.net/' + value.url
|
||||
`https://update.hwdns.net/${value.url}`
|
||||
} else {
|
||||
console.log(`[Error]: updateDataNew.platforms.${key} is null`)
|
||||
}
|
||||
|
||||
+36
-32
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clash-verge"
|
||||
version = "2.5.0"
|
||||
version = "2.5.2"
|
||||
description = "clash verge"
|
||||
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
||||
license = "GPL-3.0-only"
|
||||
@@ -8,7 +8,7 @@ repository = "https://github.com/clash-verge-rev/clash-verge-rev.git"
|
||||
default-run = "clash-verge"
|
||||
build = "build.rs"
|
||||
edition = "2024"
|
||||
rust-version = "1.91"
|
||||
rust-version = "1.95"
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
@@ -27,7 +27,7 @@ tracing = []
|
||||
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.6", features = [] }
|
||||
tauri-build = { version = "2.6.3", features = [] }
|
||||
|
||||
[dependencies]
|
||||
clash-verge-draft = { workspace = true }
|
||||
@@ -55,69 +55,72 @@ serde_json = { workspace = true }
|
||||
serde_yaml_ng = { workspace = true }
|
||||
smartstring = { workspace = true, features = ["serde"] }
|
||||
bitflags = { workspace = true }
|
||||
warp = { version = "0.4.2", features = ["server"] }
|
||||
open = "5.3.3"
|
||||
warp = { version = "0.4.3", features = ["server"] }
|
||||
open = "5.3.6"
|
||||
dunce = "1.0.5"
|
||||
nanoid = "0.5"
|
||||
chrono = "0.4.44"
|
||||
boa_engine = "0.21.0"
|
||||
chrono = "0.4.45"
|
||||
boa_engine = "0.21.1"
|
||||
once_cell = { version = "1.21.4", features = ["parking_lot"] }
|
||||
delay_timer = "0.11.6"
|
||||
percent-encoding = "2.3.2"
|
||||
reqwest = { version = "0.13.2", features = [
|
||||
reqwest = { version = "0.13.4", features = [
|
||||
"json",
|
||||
"cookies",
|
||||
"gzip",
|
||||
"rustls",
|
||||
"form",
|
||||
] }
|
||||
regex = "1.12.3"
|
||||
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", branch = "0.5.4", features = [
|
||||
regex = "1.12.4"
|
||||
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", features = [
|
||||
"guard",
|
||||
] }
|
||||
network-interface = { version = "2.0.5", features = ["serde"] }
|
||||
tauri-plugin-shell = "2.3.5"
|
||||
tauri-plugin-dialog = "2.6.0"
|
||||
tauri-plugin-fs = "2.4.5"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-deep-link = "2.4.7"
|
||||
tauri-plugin-deep-link = "2.4.9"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
zip = "8.3.1"
|
||||
zip = "8.6.0"
|
||||
reqwest_dav = "0.3.3"
|
||||
aes-gcm = { version = "0.10.3", features = ["std"] }
|
||||
serde-xml-rs = "0.6.0"
|
||||
aes-gcm = { version = "0.11.0" }
|
||||
base64 = "0.22.1"
|
||||
getrandom = "0.4.2"
|
||||
getrandom = "0.4.3"
|
||||
futures = "0.3.32"
|
||||
gethostname = "1.1.0"
|
||||
scopeguard = "1.2.0"
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tokio-stream = "0.1.18"
|
||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||
tauri-plugin-http = "2.5.7"
|
||||
tauri-plugin-http = "2.5.9"
|
||||
console-subscriber = { version = "0.5.0", optional = true }
|
||||
tauri-plugin-devtools = { version = "2.0.1" }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo" }
|
||||
tauri-plugin-devtools = { version = "2.1.0" }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "fix/api-robustness" }
|
||||
clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" }
|
||||
async-trait = "0.1.89"
|
||||
clash_verge_service_ipc = { version = "2.3.0", features = [
|
||||
clash_verge_service_ipc = { version = "2.3.3", features = [
|
||||
"client",
|
||||
], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" }
|
||||
arc-swap = "1.9.0"
|
||||
tokio-rustls = "0.26"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
webpki-roots = "1.0"
|
||||
rust_iso3166 = "0.1.14"
|
||||
arc-swap = "1.9.2"
|
||||
tokio-rustls = "0.26.4"
|
||||
rustls = { version = "0.23.41", features = ["ring"] }
|
||||
webpki-roots = "1.0.8"
|
||||
rust_iso3166 = "0.2.0"
|
||||
# Use the git repo until the next release after v2.0.0.
|
||||
dark-light = { git = "https://github.com/rust-dark-light/dark-light" }
|
||||
bytes = "1.11.1"
|
||||
windows-sys.workspace = true
|
||||
bytes = "1.12.0"
|
||||
tokio-util = { version = "0.7.18", features = ["time"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-foundation = { version = "0.3", features = [
|
||||
objc2 = "0.6.4"
|
||||
objc2-foundation = { version = "0.3.2", features = [
|
||||
"NSString",
|
||||
"NSDictionary",
|
||||
"NSAttributedString",
|
||||
] }
|
||||
objc2-app-kit = { version = "0.3", features = [
|
||||
objc2-app-kit = { version = "0.3.2", features = [
|
||||
"NSAttributedString",
|
||||
"NSStatusItem",
|
||||
"NSStatusBarButton",
|
||||
@@ -129,6 +132,7 @@ objc2-app-kit = { version = "0.3", features = [
|
||||
"NSFontDescriptor",
|
||||
"NSColor",
|
||||
"NSParagraphStyle",
|
||||
"NSStringDrawing",
|
||||
"NSText",
|
||||
] }
|
||||
|
||||
@@ -140,8 +144,8 @@ windows = { version = "0.62.2", features = ["Win32_Globalization"] }
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-autostart = "2.5.1"
|
||||
tauri-plugin-global-shortcut = "2.3.1"
|
||||
tauri-plugin-updater = "2.10.0"
|
||||
tauri-plugin-global-shortcut = "2.3.2"
|
||||
tauri-plugin-updater = "2.10.1"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"core:window:allow-set-cursor-position",
|
||||
"core:window:allow-set-ignore-cursor-events",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
|
||||
@@ -32,26 +32,6 @@ pub fn open_web_url(url: String) -> CmdResult<()> {
|
||||
open::that(url.as_str()).stringify_err()
|
||||
}
|
||||
|
||||
// TODO 后续可以为前端提供接口,当前作为托盘菜单使用
|
||||
/// 打开 Verge 最新日志
|
||||
#[tauri::command]
|
||||
pub async fn open_app_log() -> CmdResult<()> {
|
||||
let log_path = dirs::app_latest_log().stringify_err()?;
|
||||
#[cfg(target_os = "windows")]
|
||||
let log_path = crate::utils::help::snapshot_path(&log_path).stringify_err()?;
|
||||
open::that(log_path).stringify_err()
|
||||
}
|
||||
|
||||
// TODO 后续可以为前端提供接口,当前作为托盘菜单使用
|
||||
/// 打开 Clash 最新日志
|
||||
#[tauri::command]
|
||||
pub async fn open_core_log() -> CmdResult<()> {
|
||||
let log_path = dirs::clash_latest_log().stringify_err()?;
|
||||
#[cfg(target_os = "windows")]
|
||||
let log_path = crate::utils::help::snapshot_path(&log_path).stringify_err()?;
|
||||
open::that(log_path).stringify_err()
|
||||
}
|
||||
|
||||
/// 打开/关闭开发者工具
|
||||
#[tauri::command]
|
||||
pub fn open_devtools(app_handle: AppHandle) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::CmdResult;
|
||||
use crate::feat;
|
||||
use crate::utils::dirs;
|
||||
use crate::utils::{dirs, yaml_emitter};
|
||||
use crate::{
|
||||
cmd::StringifyErr as _,
|
||||
config::{ClashInfo, Config},
|
||||
@@ -36,10 +36,21 @@ pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
|
||||
}
|
||||
|
||||
/// 修改Clash模式
|
||||
///
|
||||
/// 将 `change_clash_mode` 的失败上抛给前端,使前端 `catch` 能真正感知后端 PATCH 失败
|
||||
/// 并提示用户(此前命令始终返回 `Ok(())`,吞掉了后端错误)。
|
||||
#[tauri::command]
|
||||
pub async fn patch_clash_mode(payload: String) -> CmdResult {
|
||||
feat::change_clash_mode(payload).await;
|
||||
Ok(())
|
||||
feat::change_clash_mode(payload).await
|
||||
}
|
||||
|
||||
/// 获取当前 Clash 模式(容错读取)
|
||||
///
|
||||
/// 直接读取已保存的 clash 配置中的 `mode`,绕开 mihomo `/configs` 的严格
|
||||
/// `BaseConfig` 反序列化,作为主页 mode 显示的兜底来源。
|
||||
#[tauri::command]
|
||||
pub async fn get_clash_mode() -> CmdResult<Option<String>> {
|
||||
Ok(Config::clash().await.data_arc().get_mode().map(Into::into))
|
||||
}
|
||||
|
||||
/// 切换Clash核心
|
||||
@@ -125,14 +136,13 @@ pub async fn test_delay(url: String) -> CmdResult<u32> {
|
||||
#[tauri::command]
|
||||
pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
|
||||
use crate::utils::dirs;
|
||||
use serde_yaml_ng;
|
||||
use tokio::fs;
|
||||
|
||||
// 获取DNS配置文件路径
|
||||
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
|
||||
|
||||
// 保存DNS配置到文件
|
||||
let yaml_str = serde_yaml_ng::to_string(&dns_config).stringify_err()?;
|
||||
let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config).stringify_err()?;
|
||||
fs::write(&dns_path, yaml_str).await.stringify_err()?;
|
||||
logging!(info, Type::Config, "DNS config saved to {dns_path:?}");
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::CmdResult;
|
||||
use super::StringifyErr as _;
|
||||
use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice};
|
||||
use crate::config::profiles;
|
||||
use crate::utils::window_manager::WindowManager;
|
||||
use crate::{
|
||||
config::{
|
||||
@@ -15,8 +16,8 @@ use crate::{
|
||||
feat,
|
||||
utils::{dirs, help},
|
||||
};
|
||||
use clash_verge_draft::SharedDraft;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use clash_verge_draft::{Draft, SharedDraft};
|
||||
use clash_verge_logging::{Type, logging, logging_error};
|
||||
use scopeguard::defer;
|
||||
use smartstring::alias::String;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -24,6 +25,14 @@ use std::time::Duration;
|
||||
|
||||
static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn profile_import_error(err: &anyhow::Error) -> std::string::String {
|
||||
if let Some(cause) = err.chain().find(|cause| cause.to_string().contains("TLS 1.0/1.1")) {
|
||||
return cause.to_string();
|
||||
}
|
||||
|
||||
format!("导入订阅失败: {err:#}")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
|
||||
logging!(debug, Type::Cmd, "获取配置文件列表");
|
||||
@@ -70,34 +79,23 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(error, Type::Cmd, "[导入订阅] 下载失败: {}", e);
|
||||
return Err(format!("导入订阅失败: {}", e).into());
|
||||
return Err(profile_import_error(&e).into());
|
||||
}
|
||||
};
|
||||
|
||||
match profiles_append_item_safe(item).await {
|
||||
Ok(_) => match profiles_save_file_safe().await {
|
||||
Ok(_) => {
|
||||
logging!(info, Type::Cmd, "[导入订阅] 配置文件保存成功");
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(error, Type::Cmd, "[导入订阅] 保存配置文件失败: {}", e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
logging!(error, Type::Cmd, "[导入订阅] 保存配置失败: {}", e);
|
||||
return Err(format!("导入订阅失败: {}", e).into());
|
||||
}
|
||||
if let Err(e) = profiles_append_item_safe(item).await {
|
||||
logging!(error, Type::Cmd, "[导入订阅] 保存配置失败: {}", e);
|
||||
return Err(format!("导入订阅失败: {}", e).into());
|
||||
}
|
||||
|
||||
if let Some(uid) = &item.uid {
|
||||
logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid);
|
||||
handle::Handle::notify_profile_changed(uid);
|
||||
if let Err(e) = profiles_save_file_safe().await {
|
||||
logging!(error, Type::Cmd, "[导入订阅] 保存配置文件失败: {}", e);
|
||||
return Err(format!("导入订阅失败: {}", e).into());
|
||||
}
|
||||
logging!(info, Type::Cmd, "[导入订阅] 配置文件保存成功");
|
||||
logging_error!(Type::Timer, Timer::global().refresh().await);
|
||||
|
||||
// 异步保存配置文件并发送全局通知
|
||||
if let Some(uid) = &item.uid {
|
||||
// 延迟发送,确保文件已完全写入
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid);
|
||||
handle::Handle::notify_profile_changed(uid);
|
||||
}
|
||||
@@ -128,6 +126,7 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
|
||||
match profiles_append_item_with_filedata_safe(&item, file_data).await {
|
||||
Ok(_) => {
|
||||
profiles_save_file_safe().await.stringify_err()?;
|
||||
logging_error!(Type::Timer, Timer::global().refresh().await);
|
||||
// 发送配置变更通知
|
||||
if let Some(uid) = &item.uid {
|
||||
logging!(info, Type::Cmd, "[创建订阅] 发送配置变更通知: {}", uid);
|
||||
@@ -210,17 +209,29 @@ async fn restore_previous_profile(prev_profile: &String) -> CmdResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn commit_current_profile(profiles: &Draft<IProfiles>, current: Option<String>) -> anyhow::Result<()> {
|
||||
profiles.discard();
|
||||
let Some(current) = current else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
profiles
|
||||
.with_data_modify(|mut committed| async move {
|
||||
committed.patch_config(&IProfiles {
|
||||
current: Some(current),
|
||||
items: None,
|
||||
});
|
||||
Ok((committed, ()))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_success(current_value: Option<&String>) -> CmdResult<ValidationOutcome> {
|
||||
Config::profiles().await.apply();
|
||||
handle::Handle::refresh_clash();
|
||||
|
||||
if let Err(e) = Tray::global().update_tooltip().await {
|
||||
logging!(warn, Type::Cmd, "Warning: 异步更新托盘提示失败: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = Tray::global().update_menu().await {
|
||||
logging!(warn, Type::Cmd, "Warning: 异步更新托盘菜单失败: {e}");
|
||||
}
|
||||
commit_current_profile(&Config::profiles().await, current_value.cloned())
|
||||
.await
|
||||
.stringify_err()?;
|
||||
// Runtime refresh and tray rebuilding happen after saved node selections are restored.
|
||||
profiles::activate_selected_nodes().stringify_err()?;
|
||||
|
||||
if let Err(e) = profiles_save_file_safe().await {
|
||||
logging!(warn, Type::Cmd, "Warning: 异步保存配置文件失败: {e}");
|
||||
@@ -349,9 +360,9 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
|
||||
// 如果更新间隔或允许自动更新变更,异步刷新定时器
|
||||
if should_refresh_timer {
|
||||
crate::process::AsyncHandler::spawn(move || async move {
|
||||
logging!(info, Type::Timer, "定时器更新间隔已变更,正在刷新定时器...");
|
||||
logging!(info, Type::Timer, "Timer update settings changed, refreshing timer...");
|
||||
if let Err(e) = crate::core::Timer::global().refresh().await {
|
||||
logging!(error, Type::Timer, "刷新定时器失败: {}", e);
|
||||
logging!(error, Type::Timer, "Failed to refresh timer: {}", e);
|
||||
} else {
|
||||
// 刷新成功后发送自定义事件,不触发配置重载
|
||||
crate::core::handle::Handle::notify_timer_updated(&index);
|
||||
@@ -393,6 +404,18 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(file) = item.file.as_ref() {
|
||||
let path = dirs::app_profiles_dir().stringify_err()?.join(file.as_str());
|
||||
match tokio::fs::try_exists(&path).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return Ok(String::new()),
|
||||
Err(err) => {
|
||||
return Err(format!("failed to check profile file \"{}\": {err}", path.display()).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data = item.read_file().await.stringify_err()?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -404,3 +427,44 @@ pub async fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
|
||||
let next_time = timer.get_next_update_time(&uid).await;
|
||||
Ok(next_time)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::commit_current_profile;
|
||||
use crate::config::{IProfiles, PrfItem};
|
||||
use clash_verge_draft::Draft;
|
||||
|
||||
fn profile(uid: &str) -> PrfItem {
|
||||
PrfItem {
|
||||
uid: Some(uid.into()),
|
||||
..PrfItem::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committing_profile_switch_preserves_profiles_added_after_draft_creation() -> anyhow::Result<()> {
|
||||
let profiles = Draft::new(IProfiles {
|
||||
current: Some("a".into()),
|
||||
items: Some(vec![profile("a"), profile("b")]),
|
||||
});
|
||||
profiles.edit_draft(|draft| {
|
||||
draft.patch_config(&IProfiles {
|
||||
current: Some("b".into()),
|
||||
items: None,
|
||||
});
|
||||
});
|
||||
profiles
|
||||
.with_data_modify(|mut committed| async move {
|
||||
committed.items.get_or_insert_with(Vec::new).push(profile("new"));
|
||||
Ok((committed, ()))
|
||||
})
|
||||
.await?;
|
||||
|
||||
commit_current_profile(&profiles, Some("b".into())).await?;
|
||||
|
||||
let committed = profiles.data_arc();
|
||||
assert_eq!(committed.current.as_deref(), Some("b"));
|
||||
assert!(committed.get_item("new").is_ok());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::CmdResult;
|
||||
use crate::{cmd::StringifyErr as _, config::Config, core::CoreManager};
|
||||
use crate::{cmd::StringifyErr as _, config::Config, core::CoreManager, utils::yaml_emitter};
|
||||
use anyhow::{Context as _, anyhow};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use serde_yaml_ng::Mapping;
|
||||
@@ -12,6 +12,28 @@ pub async fn get_runtime_config() -> CmdResult<Option<Mapping>> {
|
||||
Ok(Config::runtime().await.latest_arc().config.clone())
|
||||
}
|
||||
|
||||
/// 获取运行时代理组顺序
|
||||
#[tauri::command]
|
||||
pub async fn get_runtime_proxy_group_order() -> CmdResult<Vec<String>> {
|
||||
let runtime = Config::runtime().await;
|
||||
let runtime = runtime.latest_arc();
|
||||
|
||||
Ok(runtime
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|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())
|
||||
.map(String::from)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 获取运行时YAML配置
|
||||
#[tauri::command]
|
||||
pub async fn get_runtime_yaml() -> CmdResult<String> {
|
||||
@@ -22,7 +44,7 @@ pub async fn get_runtime_yaml() -> CmdResult<String> {
|
||||
config
|
||||
.ok_or_else(|| anyhow!("failed to parse config to yaml file"))
|
||||
.and_then(|config| {
|
||||
serde_yaml_ng::to_string(config)
|
||||
yaml_emitter::to_mihomo_config_string(config)
|
||||
.context("failed to convert config to yaml")
|
||||
.map(|s| s.into())
|
||||
})
|
||||
@@ -82,7 +104,7 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
|
||||
|
||||
config.insert("proxies".into(), proxies_chain);
|
||||
|
||||
serde_yaml_ng::to_string(&config)
|
||||
yaml_emitter::to_mihomo_config_string(&config)
|
||||
.context("YAML generation failed")
|
||||
.map(|s| s.into())
|
||||
.stringify_err()
|
||||
@@ -94,12 +116,10 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
|
||||
/// 更新运行时链式代理配置
|
||||
#[tauri::command]
|
||||
pub async fn update_proxy_chain_config_in_runtime(proxy_chain_config: Option<serde_yaml_ng::Value>) -> CmdResult<()> {
|
||||
match CoreManager::global()
|
||||
.update_runtime_config(|d| d.update_proxy_chain_config(proxy_chain_config))
|
||||
.await
|
||||
{
|
||||
let runtime = Config::runtime().await;
|
||||
runtime.edit_draft(|d| d.update_proxy_chain_config(proxy_chain_config));
|
||||
// 我们需要在 CoreManager 中验证并应用配置,这里不应该直接调用 runtime.apply()
|
||||
}
|
||||
match CoreManager::global().apply_generate_config().await {
|
||||
Ok(outcome) if outcome.is_valid() => {}
|
||||
Ok(outcome) => logging!(
|
||||
warn,
|
||||
|
||||
@@ -40,19 +40,29 @@ pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdR
|
||||
(path, is_merge, is_script, affects_runtime)
|
||||
};
|
||||
|
||||
// 读取原始内容(在释放profiles_guard后进行)
|
||||
let original_content = PrfItem {
|
||||
file: Some(rel_path.clone()),
|
||||
..Default::default()
|
||||
}
|
||||
.read_file()
|
||||
.await
|
||||
.stringify_err()?;
|
||||
|
||||
let profiles_dir = dirs::app_profiles_dir().stringify_err()?;
|
||||
let file_path = profiles_dir.join(rel_path.as_str());
|
||||
let file_path_str = file_path.to_string_lossy().to_string();
|
||||
|
||||
// 读取原始内容(在释放profiles_guard后进行)
|
||||
let original_existed = fs::try_exists(&file_path).await.map_err(|err| {
|
||||
String::from(format!(
|
||||
"failed to check profile file \"{}\": {err}",
|
||||
file_path.display()
|
||||
))
|
||||
})?;
|
||||
let original_content = if original_existed {
|
||||
PrfItem {
|
||||
file: Some(rel_path.clone()),
|
||||
..Default::default()
|
||||
}
|
||||
.read_file()
|
||||
.await
|
||||
.stringify_err()?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// 保存新的配置文件
|
||||
fs::write(&file_path, &file_data).await.stringify_err()?;
|
||||
|
||||
@@ -68,6 +78,7 @@ pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdR
|
||||
&file_path_str,
|
||||
&file_path,
|
||||
&original_content,
|
||||
original_existed,
|
||||
is_merge_file,
|
||||
is_script_file,
|
||||
affects_runtime,
|
||||
@@ -83,8 +94,16 @@ pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdR
|
||||
Ok(changes_applied)
|
||||
}
|
||||
|
||||
async fn restore_original(file_path: &std::path::Path, original_content: &str) -> Result<(), String> {
|
||||
fs::write(file_path, original_content).await.stringify_err()
|
||||
async fn restore_original(
|
||||
file_path: &std::path::Path,
|
||||
original_content: &str,
|
||||
original_existed: bool,
|
||||
) -> Result<(), String> {
|
||||
if original_existed {
|
||||
fs::write(file_path, original_content).await.stringify_err()
|
||||
} else {
|
||||
fs::remove_file(file_path).await.stringify_err()
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_affects_runtime(profiles: &IProfiles, index: &str) -> bool {
|
||||
@@ -112,6 +131,7 @@ async fn handle_saved_profile_file(
|
||||
file_path_str: &str,
|
||||
file_path: &std::path::Path,
|
||||
original_content: &str,
|
||||
original_existed: bool,
|
||||
is_merge_file: bool,
|
||||
is_script_file: bool,
|
||||
affects_runtime: bool,
|
||||
@@ -138,13 +158,13 @@ async fn handle_saved_profile_file(
|
||||
}
|
||||
Ok(outcome) => {
|
||||
logging!(warn, Type::Config, "[cmd配置save] 文件验证失败: {}", outcome);
|
||||
restore_original(file_path, original_content).await?;
|
||||
restore_original(file_path, original_content, original_existed).await?;
|
||||
handle_validation_notice(&outcome, target, file_type);
|
||||
return Ok(outcome);
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(error, Type::Config, "[cmd配置save] 验证过程发生错误: {}", e);
|
||||
restore_original(file_path, original_content).await?;
|
||||
restore_original(file_path, original_content, original_existed).await?;
|
||||
return Err(e.to_string().into());
|
||||
}
|
||||
}
|
||||
@@ -165,13 +185,13 @@ async fn handle_saved_profile_file(
|
||||
}
|
||||
Ok(outcome) => {
|
||||
logging!(warn, Type::Config, "[cmd配置save] 运行时配置应用失败: {}", outcome);
|
||||
restore_original(file_path, original_content).await?;
|
||||
restore_original(file_path, original_content, original_existed).await?;
|
||||
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
|
||||
Ok(outcome)
|
||||
}
|
||||
Err(err) => {
|
||||
logging!(error, Type::Config, "[cmd配置save] 运行时配置应用错误: {}", err);
|
||||
restore_original(file_path, original_content).await?;
|
||||
restore_original(file_path, original_content, original_existed).await?;
|
||||
Err(err.to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
use super::{CmdResult, StringifyErr as _};
|
||||
use crate::core::service::{self, SERVICE_MANAGER, ServiceStatus};
|
||||
use smartstring::SmartString;
|
||||
|
||||
async fn execute_service_operation_sync(status: ServiceStatus, op_type: &str) -> CmdResult {
|
||||
if let Err(e) = SERVICE_MANAGER.lock().await.handle_service_status(&status).await {
|
||||
let emsg = format!("{} Service failed: {}", op_type, e);
|
||||
return Err(SmartString::from(emsg));
|
||||
}
|
||||
Ok(())
|
||||
SERVICE_MANAGER
|
||||
.handle_service_status(status)
|
||||
.await
|
||||
.map_err(|e| format!("{op_type} Service failed: {e}").into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -7,6 +7,7 @@ use clash_verge_logging::{Type, logging};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml_ng::{Mapping, Value};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
str::FromStr as _,
|
||||
};
|
||||
@@ -179,6 +180,17 @@ impl IClashTemp {
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 容错读取当前代理模式。
|
||||
///
|
||||
/// 仅从已保存的 clash 配置 Mapping 中提取 `mode` 字段,不依赖 mihomo `/configs`
|
||||
/// 的严格 `BaseConfig` 反序列化,因此即使核心返回的字段与插件结构体不匹配也能取到。
|
||||
pub fn get_mode(&self) -> Option<String> {
|
||||
self.0.get("mode").and_then(|value| match value {
|
||||
Value::String(val_str) => Some(val_str.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn guard_redir_port(config: &Mapping) -> u16 {
|
||||
let mut port = config
|
||||
@@ -267,11 +279,11 @@ impl IClashTemp {
|
||||
let val_str = val_str.trim();
|
||||
|
||||
let val = match val_str.starts_with(':') {
|
||||
true => format!("127.0.0.1{val_str}"),
|
||||
false => val_str.to_owned(),
|
||||
true => Cow::Owned(format!("127.0.0.1{val_str}")),
|
||||
false => Cow::Borrowed(val_str),
|
||||
};
|
||||
|
||||
SocketAddr::from_str(val.as_str()).ok().map(|s| s.to_string())
|
||||
SocketAddr::from_str(&val).ok().map(|s| s.to_string())
|
||||
}
|
||||
None => None,
|
||||
})
|
||||
@@ -421,6 +433,7 @@ pub struct IClashDNS {
|
||||
pub default_nameserver: Option<Vec<String>>,
|
||||
pub enhanced_mode: Option<String>,
|
||||
pub fake_ip_range: Option<String>,
|
||||
pub fake_ip_range6: Option<String>,
|
||||
pub use_hosts: Option<bool>,
|
||||
pub fake_ip_filter: Option<Vec<String>>,
|
||||
pub nameserver: Option<Vec<String>>,
|
||||
|
||||
@@ -63,6 +63,11 @@ impl Config {
|
||||
|
||||
/// 初始化订阅
|
||||
pub async fn init_config() -> Result<()> {
|
||||
Self::init_config_before_window().await?;
|
||||
Self::init_runtime_config().await
|
||||
}
|
||||
|
||||
pub async fn init_config_before_window() -> Result<()> {
|
||||
Self::ensure_default_profile_items().await?;
|
||||
|
||||
let verge = Self::verge().await.latest_arc();
|
||||
@@ -85,6 +90,10 @@ impl Config {
|
||||
logging_error!(Type::Core, verge_data.save_file().await);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn init_runtime_config() -> Result<()> {
|
||||
let validation_result = Self::generate_and_validate().await?;
|
||||
|
||||
if let Some((msg_type, msg_content)) = validation_result {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::utils::dirs::get_encryption_key;
|
||||
use aes_gcm::{
|
||||
Aes256Gcm, Key,
|
||||
Aes256Gcm,
|
||||
aead::{Aead as _, KeyInit as _},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
@@ -16,33 +16,29 @@ tokio::task_local! {
|
||||
}
|
||||
|
||||
/// Encrypt data
|
||||
#[allow(deprecated)]
|
||||
pub fn encrypt_data(data: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let encryption_key = get_encryption_key()?;
|
||||
let key = Key::<Aes256Gcm>::from_slice(&encryption_key);
|
||||
let cipher = Aes256Gcm::new(key);
|
||||
let cipher = Aes256Gcm::new_from_slice(&encryption_key)?;
|
||||
|
||||
// Generate random nonce
|
||||
let mut nonce = vec![0u8; NONCE_LENGTH];
|
||||
let mut nonce = [0u8; NONCE_LENGTH];
|
||||
getrandom::fill(&mut nonce)?;
|
||||
|
||||
// Encrypt data
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce.as_slice().into(), data.as_bytes())
|
||||
.encrypt((&nonce).into(), data.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {e}"))?;
|
||||
|
||||
// Concatenate nonce and ciphertext and encode them in base64
|
||||
let mut combined = nonce;
|
||||
let mut combined = nonce.to_vec();
|
||||
combined.extend(ciphertext);
|
||||
Ok(STANDARD.encode(combined))
|
||||
}
|
||||
|
||||
/// Decrypt data
|
||||
#[allow(deprecated)]
|
||||
pub fn decrypt_data(encrypted: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let encryption_key = get_encryption_key()?;
|
||||
let key = Key::<Aes256Gcm>::from_slice(&encryption_key);
|
||||
let cipher = Aes256Gcm::new(key);
|
||||
let cipher = Aes256Gcm::new_from_slice(&encryption_key)?;
|
||||
// Decode from base64
|
||||
let data = STANDARD.decode(encrypted)?;
|
||||
if data.len() < NONCE_LENGTH {
|
||||
@@ -51,6 +47,7 @@ pub fn decrypt_data(encrypted: &str) -> Result<String, Box<dyn std::error::Error
|
||||
|
||||
// Separate nonce and ciphertext
|
||||
let (nonce, ciphertext) = data.split_at(NONCE_LENGTH);
|
||||
let nonce: &[u8; NONCE_LENGTH] = nonce.try_into()?;
|
||||
|
||||
// Decrypt data
|
||||
let plaintext = cipher
|
||||
|
||||
@@ -63,7 +63,7 @@ pub struct PrfItem {
|
||||
pub file_data: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct PrfSelected {
|
||||
pub name: Option<String>,
|
||||
pub now: Option<String>,
|
||||
@@ -262,7 +262,7 @@ impl PrfItem {
|
||||
let with_proxy = option.is_some_and(|o| o.with_proxy.unwrap_or(false));
|
||||
let self_proxy = option.is_some_and(|o| o.self_proxy.unwrap_or(false));
|
||||
let accept_invalid_certs = option.is_some_and(|o| o.danger_accept_invalid_certs.unwrap_or(false));
|
||||
let allow_auto_update = option.map(|o| o.allow_auto_update.unwrap_or(true));
|
||||
let allow_auto_update = Some(allow_auto_update_enabled(option));
|
||||
let user_agent = option.and_then(|o| o.user_agent.clone());
|
||||
let update_interval = option.and_then(|o| o.update_interval);
|
||||
let timeout = option.and_then(|o| o.timeout_seconds).unwrap_or(20);
|
||||
@@ -297,7 +297,7 @@ impl PrfItem {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
bail!("failed to fetch remote profile: {}", e);
|
||||
return Err(e).context("failed to fetch remote profile");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -533,7 +533,9 @@ impl PrfItem {
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("could not find the file"))?;
|
||||
let path = dirs::app_profiles_dir()?.join(file.as_str());
|
||||
let content = fs::read_to_string(path).await.context("failed to read the file")?;
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.with_context(|| format!("failed to read the file \"{}\"", path.display()))?;
|
||||
Ok(content.into())
|
||||
}
|
||||
|
||||
@@ -583,6 +585,10 @@ const fn default_allow_auto_update() -> Option<bool> {
|
||||
Some(true)
|
||||
}
|
||||
|
||||
fn allow_auto_update_enabled(option: Option<&PrfOption>) -> bool {
|
||||
option.and_then(|o| o.allow_auto_update).unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Fix URLs where query parameters are incorrectly appended to the path segment
|
||||
///
|
||||
/// Incorrect Example: https://example.com/path¶m1=value1
|
||||
@@ -611,3 +617,19 @@ fn fix_dirty_url(input: &str) -> Result<Url> {
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PrfOption, allow_auto_update_enabled};
|
||||
|
||||
#[test]
|
||||
fn auto_update_defaults_to_enabled_and_preserves_explicit_false() {
|
||||
assert!(allow_auto_update_enabled(None));
|
||||
|
||||
let disabled = PrfOption {
|
||||
allow_auto_update: Some(false),
|
||||
..PrfOption::default()
|
||||
};
|
||||
assert!(!allow_auto_update_enabled(Some(&disabled)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,52 @@
|
||||
use super::{PrfOption, prfitem::PrfItem};
|
||||
use crate::utils::{
|
||||
dirs::{self, PathBufExec as _},
|
||||
help,
|
||||
use super::{
|
||||
PrfOption,
|
||||
prfitem::{PrfItem, PrfSelected},
|
||||
};
|
||||
use crate::{
|
||||
core::{handle, tray::Tray},
|
||||
utils::{
|
||||
dirs::{self, PathBufExec as _},
|
||||
help,
|
||||
},
|
||||
};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml_ng::Mapping;
|
||||
use smartstring::alias::String;
|
||||
use std::collections::HashSet;
|
||||
use tokio::fs;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Component, Path},
|
||||
sync::{
|
||||
LazyLock,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tauri_plugin_mihomo::models::{Proxies, ProxyType};
|
||||
use tokio::{fs, task::JoinHandle};
|
||||
|
||||
/// Regex to check profile file names, eg.
|
||||
/// R12345678.yaml (remote)
|
||||
/// L12345678.yaml (local)
|
||||
/// m12345678.yaml (merge)
|
||||
/// s12345678.js (script)
|
||||
/// r12345678.yaml (rules)
|
||||
/// p12345678.yaml (proxies)
|
||||
/// g12345678.yaml (groups)
|
||||
#[allow(clippy::unwrap_used)]
|
||||
static REGEX_PROFILE_FILE: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"^(?:[RLmrpg][a-zA-Z0-9]+\.yaml|s[a-zA-Z0-9]+\.js)$").unwrap());
|
||||
|
||||
// activate selected nodes task handle
|
||||
static ACTIVATE_SELECTED_TASK: LazyLock<Mutex<Option<JoinHandle<()>>>> = LazyLock::new(|| Mutex::new(None));
|
||||
static ACTIVATE_SELECTED_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
// The plugin already limits the request/response phase to 5 seconds. This outer timeout also covers
|
||||
// lock acquisition, connection-pool waiting, and local-socket connection establishment.
|
||||
const MIHOMO_OPERATION_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const SELECTED_NODES_RECHECK_DELAY: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Define the `profiles.yaml` schema
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -191,6 +228,10 @@ impl IProfiles {
|
||||
|
||||
/// update the item value
|
||||
pub async fn patch_item(&mut self, uid: &String, item: &PrfItem) -> Result<()> {
|
||||
if let Some(file) = &item.file {
|
||||
Self::validate_profile_file(file)?;
|
||||
}
|
||||
|
||||
let mut items = self.items.take().unwrap_or_default();
|
||||
|
||||
for each in items.iter_mut() {
|
||||
@@ -214,6 +255,22 @@ impl IProfiles {
|
||||
bail!("failed to find the profile item \"uid:{uid}\"")
|
||||
}
|
||||
|
||||
fn validate_profile_file(file: &str) -> Result<()> {
|
||||
let mut components = Path::new(file).components();
|
||||
if file.is_empty()
|
||||
|| file.contains('/')
|
||||
|| file.contains('\\')
|
||||
|| !matches!(
|
||||
(components.next(), components.next()),
|
||||
(Some(Component::Normal(_)), None)
|
||||
)
|
||||
{
|
||||
bail!("profile file must be a single filename");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// be used to update the remote item
|
||||
/// only patch `updated` `extra` `file_data`
|
||||
pub async fn update_item(&mut self, uid: &String, item: &mut PrfItem) -> Result<()> {
|
||||
@@ -238,7 +295,7 @@ impl IProfiles {
|
||||
if let Some(file_data) = item.file_data.take() {
|
||||
let file = each.file.take();
|
||||
let file =
|
||||
file.unwrap_or_else(|| item.file.take().unwrap_or_else(|| format!("{}.yaml", &uid).into()));
|
||||
file.unwrap_or_else(|| item.file.take().unwrap_or_else(|| format!("{}.yaml", uid).into()));
|
||||
|
||||
// the file must exists
|
||||
each.file = Some(file.clone());
|
||||
@@ -501,27 +558,7 @@ impl IProfiles {
|
||||
|
||||
/// 检查文件名是否符合 profile 文件的命名规则
|
||||
fn is_profile_file(filename: &str) -> bool {
|
||||
// 匹配各种 profile 文件格式
|
||||
// R12345678.yaml (remote)
|
||||
// L12345678.yaml (local)
|
||||
// m12345678.yaml (merge)
|
||||
// s12345678.js (script)
|
||||
// r12345678.yaml (rules)
|
||||
// p12345678.yaml (proxies)
|
||||
// g12345678.yaml (groups)
|
||||
|
||||
let patterns = [
|
||||
r"^[RL][a-zA-Z0-9]+\.yaml$", // Remote/Local profiles
|
||||
r"^m[a-zA-Z0-9]+\.yaml$", // Merge files
|
||||
r"^s[a-zA-Z0-9]+\.js$", // Script files
|
||||
r"^[rpg][a-zA-Z0-9]+\.yaml$", // Rules/Proxies/Groups files
|
||||
];
|
||||
|
||||
patterns.iter().any(|pattern| {
|
||||
regex::Regex::new(pattern)
|
||||
.map(|re| re.is_match(filename))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
REGEX_PROFILE_FILE.is_match(filename)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,3 +629,570 @@ pub async fn profiles_draft_update_item_safe(index: &String, item: &mut PrfItem)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct SelectedNodesPlan {
|
||||
selected: Vec<PrfSelected>,
|
||||
activations: Vec<(String, String)>,
|
||||
repaired_count: usize,
|
||||
}
|
||||
|
||||
fn node_is_available(available_nodes: &[std::string::String], node: &str) -> bool {
|
||||
available_nodes.iter().any(|available| available == node)
|
||||
}
|
||||
|
||||
fn selected_nodes_need_confirmation(selected: &[PrfSelected], proxies: &Proxies) -> bool {
|
||||
selected.iter().any(|selected_item| {
|
||||
let (Some(group_name), Some(node)) = (&selected_item.name, &selected_item.now) else {
|
||||
return false;
|
||||
};
|
||||
let Some(group) = proxies.proxies.get(group_name.as_str()) else {
|
||||
return true;
|
||||
};
|
||||
let Some(available_nodes) = group.all.as_deref().filter(|nodes| !nodes.is_empty()) else {
|
||||
return true;
|
||||
};
|
||||
!node_is_available(available_nodes, node)
|
||||
})
|
||||
}
|
||||
|
||||
fn reconcile_selected_nodes(
|
||||
selected: &[PrfSelected],
|
||||
previous: Option<&Proxies>,
|
||||
proxies: &Proxies,
|
||||
) -> SelectedNodesPlan {
|
||||
let mut plan = SelectedNodesPlan {
|
||||
selected: Vec::with_capacity(selected.len()),
|
||||
activations: Vec::new(),
|
||||
repaired_count: 0,
|
||||
};
|
||||
let mut seen_groups = HashSet::new();
|
||||
let mut unique_selected = selected
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|item| item.name.as_ref().is_some_and(|name| seen_groups.insert(name.clone())))
|
||||
.collect::<Vec<_>>();
|
||||
unique_selected.reverse();
|
||||
plan.repaired_count += selected.len() - unique_selected.len();
|
||||
|
||||
for selected_item in unique_selected {
|
||||
let (Some(group_name), Some(node)) = (&selected_item.name, &selected_item.now) else {
|
||||
plan.repaired_count += 1;
|
||||
continue;
|
||||
};
|
||||
let Some(group) = proxies.proxies.get(group_name.as_str()) else {
|
||||
if previous.is_some_and(|snapshot| !snapshot.proxies.contains_key(group_name.as_str())) {
|
||||
plan.repaired_count += 1;
|
||||
} else {
|
||||
plan.selected.push(selected_item.clone());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some(available_nodes) = group.all.as_deref().filter(|nodes| !nodes.is_empty()) else {
|
||||
// Provider-backed groups can be temporarily incomplete immediately after a reload.
|
||||
plan.selected.push(selected_item.clone());
|
||||
continue;
|
||||
};
|
||||
let is_selectable_group = matches!(
|
||||
&group.proxy_type,
|
||||
ProxyType::Selector | ProxyType::URLTest | ProxyType::Fallback | ProxyType::LoadBalance
|
||||
);
|
||||
if !is_selectable_group {
|
||||
let preferred_node = group
|
||||
.now
|
||||
.as_deref()
|
||||
.filter(|current| node_is_available(available_nodes, current))
|
||||
.or_else(|| node_is_available(available_nodes, node).then_some(node.as_str()));
|
||||
if let Some(preferred_node) = preferred_node {
|
||||
if preferred_node != node.as_str() {
|
||||
plan.repaired_count += 1;
|
||||
}
|
||||
plan.selected.push(PrfSelected {
|
||||
name: Some(group_name.clone()),
|
||||
now: Some(preferred_node.into()),
|
||||
});
|
||||
} else {
|
||||
plan.repaired_count += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if node_is_available(available_nodes, node) {
|
||||
plan.selected.push(selected_item.clone());
|
||||
if group.now.as_deref() != Some(node.as_str()) {
|
||||
plan.activations.push((group_name.clone(), node.clone()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let missing_was_confirmed = previous
|
||||
.and_then(|snapshot| snapshot.proxies.get(group_name.as_str()))
|
||||
.and_then(|group| group.all.as_deref())
|
||||
.filter(|nodes| !nodes.is_empty())
|
||||
.is_some_and(|nodes| !node_is_available(nodes, node));
|
||||
if !missing_was_confirmed {
|
||||
plan.selected.push(selected_item.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
plan.repaired_count += 1;
|
||||
if let Some(current_node) = group
|
||||
.now
|
||||
.as_deref()
|
||||
.filter(|current| node_is_available(available_nodes, current))
|
||||
{
|
||||
plan.selected.push(PrfSelected {
|
||||
name: Some(group_name.clone()),
|
||||
now: Some(current_node.into()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
plan
|
||||
}
|
||||
|
||||
fn is_activation_current(generation: u64) -> bool {
|
||||
ACTIVATE_SELECTED_GENERATION.load(Ordering::Acquire) == generation
|
||||
}
|
||||
|
||||
async fn fetch_proxies_with_timeout() -> Result<Proxies> {
|
||||
tokio::time::timeout(MIHOMO_OPERATION_TIMEOUT, async {
|
||||
loop {
|
||||
match handle::Handle::mihomo().await.get_proxies().await {
|
||||
Ok(proxies) => return proxies,
|
||||
Err(err) => {
|
||||
logging!(debug, Type::Config, "mihomo proxies are not ready yet: {err}");
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.context("timed out while waiting for mihomo proxies")
|
||||
}
|
||||
|
||||
async fn select_node_with_timeout(group_name: &String, node: &String) -> Result<()> {
|
||||
tokio::time::timeout(MIHOMO_OPERATION_TIMEOUT, async {
|
||||
handle::Handle::mihomo()
|
||||
.await
|
||||
.select_node_for_group(group_name, node)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.with_context(|| format!("timed out while selecting node [{node}] for group [{group_name}]"))?
|
||||
.with_context(|| format!("failed to select node [{node}] for group [{group_name}]"))
|
||||
}
|
||||
|
||||
fn remaining_activations(
|
||||
activations: &[(String, String)],
|
||||
completed: &HashMap<String, String>,
|
||||
) -> Vec<(String, String)> {
|
||||
activations
|
||||
.iter()
|
||||
.filter(|(group_name, node)| completed.get(group_name) != Some(node))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn apply_activations(
|
||||
activations: &[(String, String)],
|
||||
completed: &mut HashMap<String, String>,
|
||||
generation: u64,
|
||||
) -> Option<usize> {
|
||||
let mut activated_count = 0;
|
||||
for (group_name, node) in remaining_activations(activations, completed) {
|
||||
if !is_activation_current(generation) {
|
||||
return None;
|
||||
}
|
||||
match select_node_with_timeout(&group_name, &node).await {
|
||||
Ok(()) => {
|
||||
if !is_activation_current(generation) {
|
||||
return None;
|
||||
}
|
||||
logging!(
|
||||
info,
|
||||
Type::Config,
|
||||
"Selected node for proxy: {group_name}, node: {node}"
|
||||
);
|
||||
completed.insert(group_name, node);
|
||||
activated_count += 1;
|
||||
}
|
||||
Err(err) => logging!(error, Type::Config, "{err:#}"),
|
||||
}
|
||||
if !is_activation_current(generation) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(activated_count)
|
||||
}
|
||||
|
||||
async fn update_tray_after_activation(generation: u64) {
|
||||
if !is_activation_current(generation) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = Tray::global().update_tooltip().await {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"failed to update tray tooltip after profile switch: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
if !is_activation_current(generation) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = Tray::global().update_menu().await {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"failed to update tray menu after profile switch: {err:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_reconciled_selected(
|
||||
profile_uid: &String,
|
||||
original_selected: &[PrfSelected],
|
||||
selected: Vec<PrfSelected>,
|
||||
generation: u64,
|
||||
) -> Result<()> {
|
||||
if !is_activation_current(generation) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let profiles = Config::profiles().await;
|
||||
let profile_uid = profile_uid.clone();
|
||||
let original_selected = original_selected.to_vec();
|
||||
let updated = profiles
|
||||
.with_data_modify(move |mut profiles| async move {
|
||||
if !is_activation_current(generation) || profiles.current.as_ref() != Some(&profile_uid) {
|
||||
return Ok((profiles, false));
|
||||
}
|
||||
|
||||
let profile = profiles
|
||||
.items
|
||||
.as_mut()
|
||||
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&profile_uid)))
|
||||
.with_context(|| format!("failed to find the profile item \"uid:{profile_uid}\""))?;
|
||||
if profile.selected.as_deref().unwrap_or(&[]) != original_selected.as_slice() {
|
||||
return Ok((profiles, false));
|
||||
}
|
||||
|
||||
profile.selected = (!selected.is_empty()).then_some(selected);
|
||||
profiles.save_file().await?;
|
||||
Ok((profiles, true))
|
||||
})
|
||||
.await?;
|
||||
|
||||
if updated {
|
||||
handle::Handle::refresh_profiles();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn activate_selected_nodes_worker(
|
||||
profile_uid: String,
|
||||
selected: Vec<PrfSelected>,
|
||||
generation: u64,
|
||||
) -> Result<()> {
|
||||
let first_snapshot = fetch_proxies_with_timeout().await?;
|
||||
if !is_activation_current(generation) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let needs_confirmation = selected_nodes_need_confirmation(&selected, &first_snapshot);
|
||||
let immediate_plan = reconcile_selected_nodes(&selected, None, &first_snapshot);
|
||||
logging!(
|
||||
debug,
|
||||
Type::Config,
|
||||
"immediate selected nodes activation plan: {immediate_plan:?}"
|
||||
);
|
||||
|
||||
let mut completed_activations = HashMap::new();
|
||||
if apply_activations(&immediate_plan.activations, &mut completed_activations, generation)
|
||||
.await
|
||||
.is_none()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if is_activation_current(generation) {
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
|
||||
let plan = if needs_confirmation {
|
||||
tokio::time::sleep(SELECTED_NODES_RECHECK_DELAY).await;
|
||||
if !is_activation_current(generation) {
|
||||
return Ok(());
|
||||
}
|
||||
let second_snapshot = fetch_proxies_with_timeout().await?;
|
||||
if !is_activation_current(generation) {
|
||||
return Ok(());
|
||||
}
|
||||
let confirmed_plan = reconcile_selected_nodes(&selected, Some(&first_snapshot), &second_snapshot);
|
||||
logging!(
|
||||
debug,
|
||||
Type::Config,
|
||||
"confirmed selected nodes activation plan: {confirmed_plan:?}"
|
||||
);
|
||||
let Some(confirmed_activated_count) =
|
||||
apply_activations(&confirmed_plan.activations, &mut completed_activations, generation).await
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if confirmed_activated_count > 0 && is_activation_current(generation) {
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
confirmed_plan
|
||||
} else {
|
||||
immediate_plan
|
||||
};
|
||||
if !is_activation_current(generation) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if plan.repaired_count > 0 && is_activation_current(generation) {
|
||||
logging!(
|
||||
info,
|
||||
Type::Config,
|
||||
"repairing {} invalid selected node record(s) for profile {profile_uid}",
|
||||
plan.repaired_count
|
||||
);
|
||||
persist_reconciled_selected(&profile_uid, &selected, plan.selected, generation).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn activate_selected_nodes() -> Result<()> {
|
||||
logging!(info, Type::Config, "starting activating selected nodes");
|
||||
let mut active_task = ACTIVATE_SELECTED_TASK.lock();
|
||||
let generation = ACTIVATE_SELECTED_GENERATION.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
let previous_task = active_task.take();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Some(previous_task) = previous_task {
|
||||
let _ = previous_task.await;
|
||||
}
|
||||
if !is_activation_current(generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
let result = async {
|
||||
let profiles = Config::profiles().await.latest_arc();
|
||||
let current = profiles.get_current().context("no current profile running")?.clone();
|
||||
let selected = profiles
|
||||
.get_item(¤t)
|
||||
.context("failed to get current profile")?
|
||||
.selected
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
|
||||
if selected.is_empty() {
|
||||
if is_activation_current(generation) {
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
activate_selected_nodes_worker(current, selected, generation).await
|
||||
}
|
||||
.await;
|
||||
|
||||
if is_activation_current(generation) {
|
||||
if let Err(err) = result {
|
||||
logging!(error, Type::Config, "failed to activate selected nodes: {err:#}");
|
||||
// The profile itself is already active even if node restoration failed.
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
update_tray_after_activation(generation).await;
|
||||
logging!(info, Type::Config, "activating selected nodes done!");
|
||||
}
|
||||
});
|
||||
*active_task = Some(handle);
|
||||
drop(active_task);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tauri_plugin_mihomo::models::Proxy;
|
||||
|
||||
fn selected(group: &str, node: &str) -> PrfSelected {
|
||||
PrfSelected {
|
||||
name: Some(group.into()),
|
||||
now: Some(node.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn proxies(groups: Vec<(&str, &[&str], Option<&str>)>) -> Proxies {
|
||||
Proxies {
|
||||
proxies: groups
|
||||
.into_iter()
|
||||
.map(|(name, all, now)| {
|
||||
(
|
||||
name.to_owned(),
|
||||
Proxy {
|
||||
name: name.to_owned(),
|
||||
all: Some(all.iter().map(|node| (*node).to_owned()).collect()),
|
||||
now: now.map(str::to_owned),
|
||||
proxy_type: ProxyType::Selector,
|
||||
..Proxy::default()
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_valid_selection_and_activates_when_needed() {
|
||||
let saved = vec![selected("group", "saved")];
|
||||
let plan = reconcile_selected_nodes(
|
||||
&saved,
|
||||
None,
|
||||
&proxies(vec![("group", &["current", "saved"], Some("current"))]),
|
||||
);
|
||||
|
||||
assert_eq!(plan.selected, saved);
|
||||
assert_eq!(plan.activations, vec![("group".into(), "saved".into())]);
|
||||
assert_eq!(plan.repaired_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_missing_node_with_valid_current_node() {
|
||||
let snapshot = proxies(vec![("group", &["current"], Some("current"))]);
|
||||
let plan = reconcile_selected_nodes(&[selected("group", "renamed-node")], Some(&snapshot), &snapshot);
|
||||
|
||||
assert_eq!(plan.selected, vec![selected("group", "current")]);
|
||||
assert!(plan.activations.is_empty());
|
||||
assert_eq!(plan.repaired_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_membership_in_group_not_global_existence() {
|
||||
let snapshot = proxies(vec![
|
||||
("group", &["current"], Some("current")),
|
||||
("other-node", &[], None),
|
||||
]);
|
||||
let plan = reconcile_selected_nodes(&[selected("group", "other-node")], Some(&snapshot), &snapshot);
|
||||
|
||||
assert_eq!(plan.selected, vec![selected("group", "current")]);
|
||||
assert!(plan.activations.is_empty());
|
||||
assert_eq!(plan.repaired_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_activate_non_selectable_groups() {
|
||||
let snapshot = Proxies {
|
||||
proxies: HashMap::from([(
|
||||
"group".to_owned(),
|
||||
Proxy {
|
||||
name: "group".to_owned(),
|
||||
all: Some(vec!["current".to_owned(), "saved".to_owned()]),
|
||||
now: Some("current".to_owned()),
|
||||
proxy_type: ProxyType::Direct,
|
||||
..Proxy::default()
|
||||
},
|
||||
)]),
|
||||
};
|
||||
|
||||
let plan = reconcile_selected_nodes(&[selected("group", "saved")], None, &snapshot);
|
||||
|
||||
assert_eq!(plan.selected, vec![selected("group", "current")]);
|
||||
assert!(plan.activations.is_empty());
|
||||
assert_eq!(plan.repaired_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_selection_when_group_or_fallback_is_invalid() {
|
||||
let snapshot = proxies(vec![("group", &["valid"], Some("invalid-current"))]);
|
||||
let plan = reconcile_selected_nodes(
|
||||
&[
|
||||
selected("missing-group", "node"),
|
||||
selected("group", "missing-node"),
|
||||
PrfSelected::default(),
|
||||
],
|
||||
Some(&snapshot),
|
||||
&snapshot,
|
||||
);
|
||||
|
||||
assert!(plan.selected.is_empty());
|
||||
assert!(plan.activations.is_empty());
|
||||
assert_eq!(plan.repaired_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_selection_until_missing_node_is_confirmed() {
|
||||
let saved = vec![selected("group", "saved")];
|
||||
let incomplete = proxies(vec![("group", &[], None)]);
|
||||
let complete = proxies(vec![("group", &["current"], Some("current"))]);
|
||||
|
||||
let incomplete_plan = reconcile_selected_nodes(&saved, None, &incomplete);
|
||||
let one_snapshot_plan = reconcile_selected_nodes(&saved, None, &complete);
|
||||
|
||||
assert_eq!(incomplete_plan.selected, saved);
|
||||
assert_eq!(incomplete_plan.repaired_count, 0);
|
||||
assert_eq!(one_snapshot_plan.selected, saved);
|
||||
assert_eq!(one_snapshot_plan.repaired_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_when_group_appears_in_second_snapshot() {
|
||||
let saved = vec![selected("group", "saved")];
|
||||
let incomplete = Proxies::default();
|
||||
let complete = proxies(vec![("group", &["current", "saved"], Some("current"))]);
|
||||
|
||||
let plan = reconcile_selected_nodes(&saved, Some(&incomplete), &complete);
|
||||
|
||||
assert_eq!(plan.selected, saved);
|
||||
assert_eq!(plan.activations, vec![("group".into(), "saved".into())]);
|
||||
assert_eq!(plan.repaired_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_last_selection_for_duplicate_group_entries() {
|
||||
let saved = vec![selected("group", "old"), selected("group", "new")];
|
||||
let snapshot = proxies(vec![("group", &["old", "new"], Some("old"))]);
|
||||
|
||||
let plan = reconcile_selected_nodes(&saved, None, &snapshot);
|
||||
|
||||
assert_eq!(plan.selected, vec![selected("group", "new")]);
|
||||
assert_eq!(plan.activations, vec![("group".into(), "new".into())]);
|
||||
assert_eq!(plan.repaired_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activates_valid_nodes_before_confirming_invalid_records() {
|
||||
let saved = vec![selected("valid-group", "saved"), selected("stale-group", "missing")];
|
||||
let first_snapshot = proxies(vec![
|
||||
("valid-group", &["current", "saved"], Some("current")),
|
||||
("stale-group", &["fallback"], Some("fallback")),
|
||||
]);
|
||||
|
||||
assert!(selected_nodes_need_confirmation(&saved, &first_snapshot));
|
||||
let immediate_plan = reconcile_selected_nodes(&saved, None, &first_snapshot);
|
||||
|
||||
assert_eq!(immediate_plan.selected, saved);
|
||||
assert_eq!(immediate_plan.activations, vec![("valid-group".into(), "saved".into())]);
|
||||
assert_eq!(immediate_plan.repaired_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_only_activations_that_already_succeeded() {
|
||||
let activations = vec![
|
||||
("first-group".into(), "saved".into()),
|
||||
("second-group".into(), "new".into()),
|
||||
("first-group".into(), "replacement".into()),
|
||||
];
|
||||
let completed = HashMap::from([("first-group".into(), "saved".into())]);
|
||||
|
||||
assert_eq!(
|
||||
remaining_activations(&activations, &completed),
|
||||
vec![
|
||||
("second-group".into(), "new".into()),
|
||||
("first-group".into(), "replacement".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,23 +37,22 @@ impl IRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
let patch_tun = patch.get("tun");
|
||||
if let Some(patch_tun_value) = patch_tun {
|
||||
let mut tun = config
|
||||
.get("tun")
|
||||
.and_then(|val| val.as_mapping())
|
||||
.cloned()
|
||||
.unwrap_or_else(Mapping::new);
|
||||
let Some(patch_tun) = patch.get("tun") else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(patch_tun_mapping) = patch_tun_value.as_mapping() {
|
||||
for key in use_keys(patch_tun_mapping) {
|
||||
if let Some(value) = patch_tun_mapping.get(key.as_str()) {
|
||||
tun.insert(Value::from(key.as_str()), value.clone());
|
||||
}
|
||||
let tun_key = Value::from("tun");
|
||||
if !matches!(config.get(&tun_key), Some(Value::Mapping(_))) {
|
||||
config.insert(tun_key.clone(), Value::Mapping(Mapping::new()));
|
||||
}
|
||||
|
||||
if let (Some(patch_tun_mapping), Some(Value::Mapping(tun))) = (patch_tun.as_mapping(), config.get_mut(&tun_key))
|
||||
{
|
||||
for key in use_keys(patch_tun_mapping) {
|
||||
if let Some(value) = patch_tun_mapping.get(key.as_str()) {
|
||||
tun.insert(Value::from(key.as_str()), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
config.insert("tun".into(), Value::from(tun));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,10 +25,23 @@ pub mod timing {
|
||||
pub const CONFIG_UPDATE_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
pub const STARTUP_ERROR_DELAY: Duration = Duration::from_secs(2);
|
||||
|
||||
// Windows 服务冷启动较慢,避免过早回退 sidecar。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub const SERVICE_WAIT_MAX: Duration = Duration::from_millis(3000);
|
||||
pub const SERVICE_WAIT_MAX: Duration = Duration::from_millis(30000);
|
||||
#[cfg(target_os = "windows")]
|
||||
pub const SERVICE_WAIT_INTERVAL: Duration = Duration::from_millis(200);
|
||||
|
||||
// 回退 sidecar 后继续等待服务就绪并尝试交接。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub const SERVICE_HANDOFF_WINDOW: Duration = Duration::from_secs(120);
|
||||
#[cfg(target_os = "windows")]
|
||||
pub const SERVICE_HANDOFF_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
// 交接时等待 sidecar 释放 ext-controller 通道。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub const SERVICE_START_RETRIES: usize = 5;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub const SERVICE_START_RETRY_DELAY: Duration = Duration::from_millis(300);
|
||||
}
|
||||
|
||||
pub mod files {
|
||||
|
||||
@@ -5,7 +5,7 @@ use arc_swap::{ArcSwap, ArcSwapOption};
|
||||
use backon::{ConstantBuilder, Retryable as _};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use once_cell::sync::OnceCell;
|
||||
use reqwest_dav::list_cmd::{ListEntity, ListFile};
|
||||
use reqwest_dav::list_cmd::{ListEntity, ListFile, ListMultiStatus};
|
||||
use smartstring::alias::String;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
@@ -18,13 +18,12 @@ use std::{
|
||||
use tokio::{fs, time::timeout};
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
// 应用版本常量,来自 tauri.conf.json
|
||||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
const TIMEOUT_UPLOAD: u64 = 300; // 上传超时 5 分钟
|
||||
const TIMEOUT_DOWNLOAD: u64 = 300; // 下载超时 5 分钟
|
||||
const TIMEOUT_LIST: u64 = 3; // 列表超时 30 秒
|
||||
const TIMEOUT_DELETE: u64 = 3; // 删除超时 30 秒
|
||||
const TIMEOUT_UPLOAD: u64 = 300;
|
||||
const TIMEOUT_DOWNLOAD: u64 = 300;
|
||||
const TIMEOUT_LIST: u64 = 30;
|
||||
const TIMEOUT_DELETE: u64 = 30;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct WebDavConfig {
|
||||
@@ -67,7 +66,6 @@ impl WebDavClient {
|
||||
}
|
||||
|
||||
async fn get_client(&self, op: Operation) -> Result<reqwest_dav::Client, Error> {
|
||||
// 先尝试从缓存获取
|
||||
{
|
||||
let clients_map = self.clients.load();
|
||||
if let Some(client) = clients_map.get(&op) {
|
||||
@@ -75,15 +73,12 @@ impl WebDavClient {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取或创建配置
|
||||
let config = {
|
||||
// 首先检查是否已有配置
|
||||
let existing_config = self.config.load();
|
||||
|
||||
if let Some(cfg_arc) = existing_config.clone() {
|
||||
(*cfg_arc).clone()
|
||||
} else {
|
||||
// 释放锁后获取异步配置
|
||||
let verge = Config::verge().await.data_arc();
|
||||
if verge.webdav_url.is_none() || verge.webdav_username.is_none() || verge.webdav_password.is_none() {
|
||||
let msg: String =
|
||||
@@ -102,13 +97,11 @@ impl WebDavClient {
|
||||
password: verge.webdav_password.clone().unwrap_or_default(),
|
||||
};
|
||||
|
||||
// 存储配置到 ArcSwapOption
|
||||
self.config.store(Some(Arc::new(config.clone())));
|
||||
config
|
||||
}
|
||||
};
|
||||
|
||||
// 创建新的客户端
|
||||
let client = reqwest_dav::ClientBuilder::new()
|
||||
.set_agent(
|
||||
reqwest::Client::builder()
|
||||
@@ -117,7 +110,6 @@ impl WebDavClient {
|
||||
.timeout(Duration::from_secs(op.timeout()))
|
||||
.user_agent(format!("clash-verge/{APP_VERSION} ({OS} WebDAV-Client)"))
|
||||
.redirect(reqwest::redirect::Policy::custom(|attempt| {
|
||||
// 允许所有请求类型的重定向,包括PUT
|
||||
if attempt.previous().len() >= 5 {
|
||||
attempt.error("重定向次数过多")
|
||||
} else {
|
||||
@@ -130,21 +122,48 @@ impl WebDavClient {
|
||||
.set_auth(reqwest_dav::Auth::Basic(config.username.into(), config.password.into()))
|
||||
.build()?;
|
||||
|
||||
// 尝试检查目录是否存在,如果不存在尝试创建
|
||||
if client
|
||||
.list(dirs::BACKUP_DIR, reqwest_dav::Depth::Number(0))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
match client.mkcol(dirs::BACKUP_DIR).await {
|
||||
Ok(_) => logging!(info, Type::Backup, "Successfully created backup directory"),
|
||||
Err(e) => {
|
||||
logging!(warn, Type::Backup, "Warning: Failed to create backup directory: {}", e);
|
||||
// 清除缓存,强制下次重新尝试
|
||||
self.reset();
|
||||
return Err(anyhow::Error::msg(format!("Failed to create backup directory: {}", e)));
|
||||
// 直接使用 MKCOL;部分服务器的 depth-0 PROPFIND 会误报解码错误。
|
||||
if let Err(e) = client.mkcol(dirs::BACKUP_DIR).await {
|
||||
let (status_code, message) = match &e {
|
||||
reqwest_dav::Error::Decode(reqwest_dav::DecodeError::Server(server_err)) => {
|
||||
(Some(server_err.response_code), Some(server_err.message.as_str()))
|
||||
}
|
||||
reqwest_dav::Error::Decode(reqwest_dav::DecodeError::StatusMismatched(status_err)) => {
|
||||
(Some(status_err.response_code), None)
|
||||
}
|
||||
reqwest_dav::Error::Reqwest(http_err) => (http_err.status().map(|s| s.as_u16()), None),
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
// 409 表示父目录不存在,不能按消息启发式处理。
|
||||
if status_code == Some(409) {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Backup,
|
||||
"Backup directory cannot be created because its parent folder does not exist"
|
||||
);
|
||||
self.reset();
|
||||
return Err(anyhow::Error::msg(
|
||||
"Failed to create backup directory: parent directory does not exist",
|
||||
));
|
||||
}
|
||||
|
||||
// 405 是标准的已存在响应;部分服务器只在消息里说明。
|
||||
let already_exists = status_code == Some(405)
|
||||
|| message.is_some_and(|m| {
|
||||
let m = m.to_ascii_lowercase();
|
||||
m.contains("already exist") || m.contains("already taken")
|
||||
});
|
||||
|
||||
if already_exists {
|
||||
logging!(info, Type::Backup, "Backup directory already exists");
|
||||
} else {
|
||||
logging!(warn, Type::Backup, "Failed to create backup directory: {}", e);
|
||||
self.reset();
|
||||
return Err(anyhow::Error::msg(format!("Failed to create backup directory: {}", e)));
|
||||
}
|
||||
} else {
|
||||
logging!(info, Type::Backup, "Successfully created backup directory");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -208,17 +227,29 @@ impl WebDavClient {
|
||||
let path = format!("{}/", dirs::BACKUP_DIR);
|
||||
|
||||
let fut = async {
|
||||
let files = client.list(path.as_str(), reqwest_dav::Depth::Number(1)).await?;
|
||||
let response = client.list_raw(path.as_str(), reqwest_dav::Depth::Number(1)).await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(reqwest_dav::Error::Decode(reqwest_dav::DecodeError::StatusMismatched(
|
||||
reqwest_dav::StatusMismatchedError {
|
||||
response_code: status.as_u16(),
|
||||
expected_code: 207,
|
||||
},
|
||||
)));
|
||||
}
|
||||
|
||||
let xml = response.text().await?;
|
||||
let files = parse_webdav_list(&xml)?;
|
||||
let mut final_files = Vec::new();
|
||||
for file in files {
|
||||
if let ListEntity::File(file) = file {
|
||||
final_files.push(file);
|
||||
}
|
||||
}
|
||||
Ok::<Vec<ListFile>, Error>(final_files)
|
||||
Ok::<Vec<ListFile>, reqwest_dav::Error>(final_files)
|
||||
};
|
||||
|
||||
timeout(Duration::from_secs(TIMEOUT_LIST), fut).await?
|
||||
Ok(timeout(Duration::from_secs(TIMEOUT_LIST), fut).await??)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, file_name: String) -> Result<(), Error> {
|
||||
@@ -231,6 +262,13 @@ impl WebDavClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_webdav_list(xml: &str) -> Result<Vec<ListEntity>, reqwest_dav::Error> {
|
||||
// Some WebDAV servers emit RFC 2822's numeric UTC offset instead of HTTP-date's `GMT`.
|
||||
let normalized_xml = xml.replace(" +0000</", " GMT</");
|
||||
let multi_status: ListMultiStatus = serde_xml_rs::from_str(&normalized_xml)?;
|
||||
multi_status.responses.into_iter().map(ListEntity::try_from).collect()
|
||||
}
|
||||
|
||||
pub async fn create_backup() -> Result<(String, PathBuf), Error> {
|
||||
let now = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S").to_string();
|
||||
let zip_file_name: String = format!("{OS}-backup-{now}.zip").into();
|
||||
|
||||
@@ -44,6 +44,10 @@ impl Handle {
|
||||
Self::send_event(FrontendEvent::RefreshVerge);
|
||||
}
|
||||
|
||||
pub fn refresh_profiles() {
|
||||
Self::send_event(FrontendEvent::RefreshProfiles);
|
||||
}
|
||||
|
||||
pub fn notify_profile_changed(profile_id: &String) {
|
||||
Self::send_event(FrontendEvent::ProfileChanged {
|
||||
current_profile_id: profile_id,
|
||||
@@ -86,7 +90,7 @@ impl Handle {
|
||||
return;
|
||||
}
|
||||
|
||||
NotificationSystem::send_event(event);
|
||||
NotificationSystem::send_event(Self::app_handle().clone(), event);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,19 +125,22 @@ impl Hotkey {
|
||||
}
|
||||
HotkeyFunction::ClashModeRule => {
|
||||
AsyncHandler::spawn(async move || {
|
||||
feat::change_clash_mode("rule".into()).await;
|
||||
// 错误已在 change_clash_mode 内部记录,此处显式忽略返回值
|
||||
let _ = feat::change_clash_mode("rule".into()).await;
|
||||
notify_event(NotificationEvent::ClashModeChanged { mode: "Rule" }).await;
|
||||
});
|
||||
}
|
||||
HotkeyFunction::ClashModeGlobal => {
|
||||
AsyncHandler::spawn(async move || {
|
||||
feat::change_clash_mode("global".into()).await;
|
||||
// 错误已在 change_clash_mode 内部记录,此处显式忽略返回值
|
||||
let _ = feat::change_clash_mode("global".into()).await;
|
||||
notify_event(NotificationEvent::ClashModeChanged { mode: "Global" }).await;
|
||||
});
|
||||
}
|
||||
HotkeyFunction::ClashModeDirect => {
|
||||
AsyncHandler::spawn(async move || {
|
||||
feat::change_clash_mode("direct".into()).await;
|
||||
// 错误已在 change_clash_mode 内部记录,此处显式忽略返回值
|
||||
let _ = feat::change_clash_mode("direct".into()).await;
|
||||
notify_event(NotificationEvent::ClashModeChanged { mode: "Direct" }).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,10 +101,14 @@ impl Logger {
|
||||
*self.sidecar_file_writer.write() = Some(sidecar_file_writer);
|
||||
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
// Capture both common panic payload types instead of logging String payloads as unknown.
|
||||
// This global hook covers panics after logger init; early setup panics are handled separately.
|
||||
let payload = info
|
||||
.payload()
|
||||
.downcast_ref::<&str>()
|
||||
.unwrap_or(&"Unknown panic payload");
|
||||
.map(|s| (*s).to_string())
|
||||
.or_else(|| info.payload().downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "Unknown panic payload".to_string());
|
||||
let location = info
|
||||
.location()
|
||||
.map(|loc| format!("{}:{}", loc.file(), loc.line()))
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::{
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use scopeguard::defer;
|
||||
use smartstring::alias::String;
|
||||
use std::{collections::HashSet, path::PathBuf, time::Instant};
|
||||
use tauri_plugin_mihomo::Error as MihomoError;
|
||||
@@ -45,6 +46,14 @@ impl CoreManager {
|
||||
});
|
||||
}
|
||||
|
||||
if !self.try_start_config_update() {
|
||||
logging!(info, Type::Core, "Configuration update is already running");
|
||||
return Ok(ValidationOutcome::Busy);
|
||||
}
|
||||
defer! {
|
||||
self.finish_config_update();
|
||||
}
|
||||
|
||||
if !force && !self.should_update_config() {
|
||||
logging!(debug, Type::Core, "Skipping config update due to debounce");
|
||||
return Ok(ValidationOutcome::Skipped {
|
||||
@@ -89,10 +98,26 @@ impl CoreManager {
|
||||
return Ok(ValidationOutcome::invalid_from_message(message));
|
||||
}
|
||||
|
||||
self.apply_generate_config().await
|
||||
self.apply_generate_config_inner().await
|
||||
}
|
||||
|
||||
pub async fn apply_generate_config(&self) -> Result<ValidationOutcome> {
|
||||
pub(crate) async fn update_runtime_config<F>(&self, f: F) -> Result<ValidationOutcome>
|
||||
where
|
||||
F: FnOnce(&mut IRuntime),
|
||||
{
|
||||
if !self.try_start_config_update() {
|
||||
logging!(info, Type::Core, "Configuration update is already running");
|
||||
return Ok(ValidationOutcome::Busy);
|
||||
}
|
||||
defer! {
|
||||
self.finish_config_update();
|
||||
}
|
||||
|
||||
Config::runtime().await.edit_draft(f);
|
||||
self.apply_generate_config_inner().await
|
||||
}
|
||||
|
||||
async fn apply_generate_config_inner(&self) -> Result<ValidationOutcome> {
|
||||
match CoreConfigValidator::global().validate_config_outcome().await {
|
||||
Ok(outcome) if outcome.is_valid() => {
|
||||
let run_path = Config::generate_file(ConfigType::Run).await?;
|
||||
|
||||
@@ -9,21 +9,86 @@ use clash_verge_logging::{Type, logging};
|
||||
use scopeguard::defer;
|
||||
use smartstring::alias::String;
|
||||
use tauri_plugin_clash_verge_sysinfo;
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const fn should_wait_for_service(tun_enabled: bool, service_ready: bool, is_admin: bool) -> bool {
|
||||
tun_enabled && !service_ready && !is_admin
|
||||
}
|
||||
|
||||
/// sidecar→service 交接结果
|
||||
#[cfg(target_os = "windows")]
|
||||
enum HandoffOutcome {
|
||||
/// 服务尚未就绪
|
||||
NotReady,
|
||||
/// 已完成或无需交接
|
||||
Done,
|
||||
/// 交接失败并已回退
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl CoreManager {
|
||||
pub async fn start_core(&self) -> Result<()> {
|
||||
self.prepare_startup().await?;
|
||||
let _life = self.lifecycle_lock.lock().await;
|
||||
self.start_core_inner().await
|
||||
}
|
||||
|
||||
/// 调用者须已持有 `lifecycle_lock`。
|
||||
async fn start_core_inner(&self) -> Result<()> {
|
||||
// 退出中不再启动新内核。
|
||||
if Handle::global().is_exiting() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 已有内核运行时保持幂等,重启请走 restart_core。
|
||||
if !matches!(*self.get_running_mode(), RunningMode::NotRunning) {
|
||||
logging!(
|
||||
info,
|
||||
Type::Core,
|
||||
"start_core called while a core is running; treated as no-op"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.prepare_startup().await;
|
||||
defer! {
|
||||
self.after_core_process();
|
||||
}
|
||||
|
||||
match *self.get_running_mode() {
|
||||
// 等待服务期间可能进入退出;未真正启动时回滚状态。
|
||||
if Handle::global().is_exiting() {
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = match *self.get_running_mode() {
|
||||
RunningMode::Service => self.start_core_by_service().await,
|
||||
RunningMode::NotRunning | RunningMode::Sidecar => self.start_core_by_sidecar().await,
|
||||
};
|
||||
|
||||
// 启动失败时回滚 mode,允许后续重试。
|
||||
if result.is_err() {
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 回退 sidecar 后,后台等待服务就绪再交接
|
||||
#[cfg(target_os = "windows")]
|
||||
if matches!(*self.get_running_mode(), RunningMode::Sidecar) {
|
||||
self.spawn_service_handoff_watcher().await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn stop_core(&self) -> Result<()> {
|
||||
let _life = self.lifecycle_lock.lock().await;
|
||||
self.stop_core_inner().await
|
||||
}
|
||||
|
||||
/// 调用者须已持有 `lifecycle_lock`。
|
||||
async fn stop_core_inner(&self) -> Result<()> {
|
||||
CLASH_LOGGER.clear_logs().await;
|
||||
defer! {
|
||||
self.after_core_process();
|
||||
@@ -40,9 +105,11 @@ impl CoreManager {
|
||||
}
|
||||
|
||||
pub async fn restart_core(&self) -> Result<()> {
|
||||
// 持锁覆盖 stop+start,避免生命周期操作插入。
|
||||
let _life = self.lifecycle_lock.lock().await;
|
||||
logging!(info, Type::Core, "Restarting core");
|
||||
self.stop_core().await?;
|
||||
self.start_core().await
|
||||
self.stop_core_inner().await?;
|
||||
self.start_core_inner().await
|
||||
}
|
||||
|
||||
pub async fn change_core(&self, clash_core: &String) -> Result<(), String> {
|
||||
@@ -62,18 +129,13 @@ impl CoreManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn prepare_startup(&self) -> Result<()> {
|
||||
async fn prepare_startup(&self) {
|
||||
#[cfg(target_os = "windows")]
|
||||
self.wait_for_service_if_needed().await;
|
||||
|
||||
let value = SERVICE_MANAGER.lock().await.current();
|
||||
let mode = match value {
|
||||
self.set_running_mode(match SERVICE_MANAGER.current().await {
|
||||
ServiceStatus::Ready => RunningMode::Service,
|
||||
_ => RunningMode::Sidecar,
|
||||
};
|
||||
|
||||
self.set_running_mode(mode);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
fn after_core_process(&self) {
|
||||
@@ -86,9 +148,18 @@ impl CoreManager {
|
||||
use crate::{config::Config, constants::timing, core::service};
|
||||
use backon::{ConstantBuilder, Retryable as _};
|
||||
|
||||
let needs_service = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
|
||||
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
|
||||
let service_ready = matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready);
|
||||
let is_admin = is_current_app_handle_admin(Handle::app_handle());
|
||||
|
||||
if !needs_service {
|
||||
if !should_wait_for_service(tun_enabled, service_ready, is_admin) {
|
||||
if tun_enabled && !service_ready && is_admin {
|
||||
logging!(
|
||||
info,
|
||||
Type::Core,
|
||||
"service unavailable while app is elevated; starting sidecar immediately"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,9 +169,7 @@ impl CoreManager {
|
||||
.with_max_times(max_times as usize);
|
||||
|
||||
let _ = (|| async {
|
||||
let mut manager = SERVICE_MANAGER.lock().await;
|
||||
|
||||
if matches!(manager.current(), ServiceStatus::Ready) {
|
||||
if matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -110,10 +179,10 @@ impl CoreManager {
|
||||
return Err(anyhow::anyhow!("Service IPC not ready"));
|
||||
}
|
||||
|
||||
manager.init().await?;
|
||||
let _ = manager.refresh().await;
|
||||
SERVICE_MANAGER.init().await?;
|
||||
let _ = SERVICE_MANAGER.refresh().await;
|
||||
|
||||
if matches!(manager.current(), ServiceStatus::Ready) {
|
||||
if matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Service not ready"))
|
||||
@@ -122,4 +191,142 @@ impl CoreManager {
|
||||
.retry(backoff)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 在窗口内等待服务就绪,再从 sidecar 交接到 service
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn spawn_service_handoff_watcher(&self) {
|
||||
use crate::constants::timing;
|
||||
use crate::process::AsyncHandler;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
|
||||
// 仅 TUN 模式需要服务交接
|
||||
let needs_service = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
|
||||
if !needs_service {
|
||||
return;
|
||||
}
|
||||
|
||||
// 单实例,避免并发交接
|
||||
if self.handoff_watcher_running.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
|
||||
logging!(
|
||||
info,
|
||||
Type::Core,
|
||||
"service not ready at startup; sidecar active, watching for handoff"
|
||||
);
|
||||
|
||||
AsyncHandler::spawn(|| async move {
|
||||
let manager = Self::global();
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
if started.elapsed() >= timing::SERVICE_HANDOFF_WINDOW {
|
||||
logging!(
|
||||
info,
|
||||
Type::Core,
|
||||
"service handoff window elapsed; staying in sidecar mode"
|
||||
);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(timing::SERVICE_HANDOFF_INTERVAL).await;
|
||||
|
||||
// 模式已变更时退出
|
||||
if !matches!(*manager.get_running_mode(), RunningMode::Sidecar) {
|
||||
break;
|
||||
}
|
||||
match manager.try_handoff_sidecar_to_service().await {
|
||||
// 已交接或无需交接
|
||||
HandoffOutcome::Done => break,
|
||||
// 已回退 sidecar,停止重试
|
||||
HandoffOutcome::Failed => {
|
||||
logging!(warn, Type::Core, "handoff attempt failed; staying in sidecar mode");
|
||||
break;
|
||||
}
|
||||
HandoffOutcome::NotReady => {}
|
||||
}
|
||||
}
|
||||
manager.handoff_watcher_running.store(false, Ordering::Release);
|
||||
});
|
||||
}
|
||||
|
||||
/// 服务就绪后停止 sidecar,再以 service 重启内核
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn try_handoff_sidecar_to_service(&self) -> HandoffOutcome {
|
||||
use crate::core::service;
|
||||
|
||||
// 主动刷新服务状态,避免缓存状态阻止交接
|
||||
if !service::is_service_ipc_path_exists() {
|
||||
return HandoffOutcome::NotReady;
|
||||
}
|
||||
if SERVICE_MANAGER.init().await.is_err() {
|
||||
return HandoffOutcome::NotReady;
|
||||
}
|
||||
let _ = SERVICE_MANAGER.refresh().await;
|
||||
if !matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready) {
|
||||
return HandoffOutcome::NotReady;
|
||||
}
|
||||
|
||||
// 先抢 config 锁;失败则让位给正在进行的更新。
|
||||
if !self.try_start_config_update() {
|
||||
return HandoffOutcome::NotReady;
|
||||
}
|
||||
defer! {
|
||||
self.finish_config_update();
|
||||
}
|
||||
|
||||
// 再取 lifecycle 锁;锁序固定为 config→lifecycle。
|
||||
let _life = self.lifecycle_lock.lock().await;
|
||||
|
||||
// 持锁后复检运行模式和 TUN 状态
|
||||
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|
||||
|| !Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false)
|
||||
{
|
||||
return HandoffOutcome::Done;
|
||||
}
|
||||
|
||||
logging!(
|
||||
info,
|
||||
Type::Core,
|
||||
"service became ready; handing off from sidecar to service"
|
||||
);
|
||||
self.stop_core_by_sidecar();
|
||||
|
||||
match self.start_core_by_service().await {
|
||||
Ok(()) => {
|
||||
logging!(info, Type::Core, "handoff to service mode succeeded");
|
||||
HandoffOutcome::Done
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(
|
||||
error,
|
||||
Type::Core,
|
||||
"handoff to service failed: {}; restarting sidecar",
|
||||
e
|
||||
);
|
||||
if let Err(e2) = self.start_core_by_sidecar().await {
|
||||
logging!(
|
||||
error,
|
||||
Type::Core,
|
||||
"failed to restart sidecar after handoff failure: {}",
|
||||
e2
|
||||
);
|
||||
}
|
||||
HandoffOutcome::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_wait_for_service;
|
||||
|
||||
#[test]
|
||||
fn service_wait_is_only_required_for_non_admin_tun() {
|
||||
assert!(should_wait_for_service(true, false, false));
|
||||
assert!(!should_wait_for_service(true, false, true));
|
||||
assert!(!should_wait_for_service(true, true, false));
|
||||
assert!(!should_wait_for_service(false, false, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,19 @@ use anyhow::Result;
|
||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||
use clash_verge_logger::AsyncLogger;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::{fmt, sync::Arc, time::Instant};
|
||||
use std::{
|
||||
fmt,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
use tauri_plugin_shell::process::CommandChild;
|
||||
|
||||
use crate::singleton;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::io::OwnedHandle;
|
||||
|
||||
pub(crate) static CLASH_LOGGER: Lazy<Arc<AsyncLogger>> = Lazy::new(|| Arc::new(AsyncLogger::new()));
|
||||
|
||||
@@ -34,6 +43,15 @@ impl fmt::Display for RunningMode {
|
||||
pub struct CoreManager {
|
||||
state: ArcSwap<State>,
|
||||
last_update: ArcSwapOption<Instant>,
|
||||
#[cfg(target_os = "windows")]
|
||||
job_handle: ArcSwapOption<OwnedHandle>,
|
||||
config_update_in_progress: AtomicBool,
|
||||
// 串行化 start/stop/restart 和 sidecar→service 交接。
|
||||
// 锁序固定为 config_update_in_progress → lifecycle_lock。
|
||||
lifecycle_lock: tokio::sync::Mutex<()>,
|
||||
// sidecar→service 交接 watcher 单实例标志。
|
||||
#[cfg(target_os = "windows")]
|
||||
handoff_watcher_running: AtomicBool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -56,6 +74,12 @@ impl Default for CoreManager {
|
||||
Self {
|
||||
state: ArcSwap::new(Arc::new(State::default())),
|
||||
last_update: ArcSwapOption::new(None),
|
||||
#[cfg(target_os = "windows")]
|
||||
job_handle: ArcSwapOption::new(None),
|
||||
config_update_in_progress: AtomicBool::new(false),
|
||||
lifecycle_lock: tokio::sync::Mutex::new(()),
|
||||
#[cfg(target_os = "windows")]
|
||||
handoff_watcher_running: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +119,25 @@ impl CoreManager {
|
||||
self.last_update.store(Some(Arc::new(time)));
|
||||
}
|
||||
|
||||
/// Replaces the Windows Job Object handle owned by the core manager
|
||||
///
|
||||
/// Passing `None` drops the current handle, which closes the Job Object
|
||||
/// and terminates its assigned processes due to `KILL_ON_JOB_CLOSE`.
|
||||
///
|
||||
/// This method is currently only used on Windows.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_job_handle(&self, handle: Option<OwnedHandle>) {
|
||||
self.job_handle.store(handle.map(Arc::new));
|
||||
}
|
||||
|
||||
fn try_start_config_update(&self) -> bool {
|
||||
!self.config_update_in_progress.swap(true, Ordering::AcqRel)
|
||||
}
|
||||
|
||||
fn finish_config_update(&self) {
|
||||
self.config_update_in_progress.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
pub async fn init(&self) -> Result<()> {
|
||||
self.start_core().await?;
|
||||
Ok(())
|
||||
|
||||
@@ -13,6 +13,21 @@ use log::Level;
|
||||
use scopeguard::defer;
|
||||
use tauri_plugin_shell::ShellExt as _;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use {
|
||||
std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle},
|
||||
windows_sys::Win32::{
|
||||
Foundation::HANDLE,
|
||||
System::{
|
||||
JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, SetInformationJobObject,
|
||||
},
|
||||
Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_SET_QUOTA, PROCESS_TERMINATE},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
impl CoreManager {
|
||||
pub async fn get_clash_logs(&self) -> Result<Vec<CompactString>> {
|
||||
match *self.get_running_mode() {
|
||||
@@ -48,6 +63,28 @@ impl CoreManager {
|
||||
&IClashTemp::guard_external_controller_ipc(),
|
||||
])
|
||||
.spawn()?;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let job = match create_and_assign_sidecar_job(child.pid()) {
|
||||
Ok(job) => job,
|
||||
Err(job_error) => {
|
||||
let pid = child.pid();
|
||||
|
||||
let error = match child.kill() {
|
||||
Ok(()) => job_error,
|
||||
Err(kill_error) => anyhow::anyhow!(
|
||||
"failed to configure Job Object for sidecar PID {pid}: \
|
||||
{job_error:#}; failed to terminate child: {kill_error:#}"
|
||||
),
|
||||
};
|
||||
|
||||
logging!(error, Type::Core, "Failed to start sidecar: {error:#}");
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.set_job_handle(Some(job));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
tauri_plugin_clash_verge_sysinfo::libc::umask(previous_mask)
|
||||
@@ -95,6 +132,20 @@ impl CoreManager {
|
||||
}
|
||||
if let Some(child) = self.take_child_sidecar() {
|
||||
let pid = child.pid();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Setting the job handle to None clears the stored handle and
|
||||
// closes the previous Windows job handle in `set_job_handle`.
|
||||
self.set_job_handle(None);
|
||||
logging!(
|
||||
trace,
|
||||
Type::Core,
|
||||
"Closed job handle for sidecar process (PID: {})",
|
||||
pid
|
||||
);
|
||||
}
|
||||
|
||||
let result = child.kill();
|
||||
logging!(
|
||||
trace,
|
||||
@@ -109,9 +160,41 @@ impl CoreManager {
|
||||
pub(super) async fn start_core_by_service(&self) -> Result<()> {
|
||||
logging!(info, Type::Core, "Starting core in service mode");
|
||||
let config_file = Config::generate_file(crate::config::ConfigType::Run).await?;
|
||||
service::run_core_by_service(&config_file).await?;
|
||||
self.set_running_mode(RunningMode::Service);
|
||||
Ok(())
|
||||
|
||||
// 交接时等待 sidecar 释放 ext-controller 通道。
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use crate::constants::timing;
|
||||
let mut last_err = None;
|
||||
for attempt in 0..timing::SERVICE_START_RETRIES {
|
||||
match service::run_core_by_service(&config_file).await {
|
||||
Ok(()) => {
|
||||
self.set_running_mode(RunningMode::Service);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"service start attempt {}/{} failed: {}",
|
||||
attempt + 1,
|
||||
timing::SERVICE_START_RETRIES,
|
||||
e
|
||||
);
|
||||
last_err = Some(e);
|
||||
tokio::time::sleep(timing::SERVICE_START_RETRY_DELAY).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
service::run_core_by_service(&config_file).await?;
|
||||
self.set_running_mode(RunningMode::Service);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn stop_core_by_service(&self) -> Result<()> {
|
||||
@@ -123,3 +206,117 @@ impl CoreManager {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn create_and_assign_sidecar_job(child_pid: u32) -> Result<OwnedHandle> {
|
||||
unsafe {
|
||||
let raw_job: HANDLE = CreateJobObjectW(std::ptr::null(), std::ptr::null());
|
||||
if raw_job.is_null() {
|
||||
return Err(last_win32_error("CreateJobObjectW failed"));
|
||||
}
|
||||
let job = OwnedHandle::from_raw_handle(raw_job);
|
||||
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
|
||||
let set_info_result = SetInformationJobObject(
|
||||
job.as_raw_handle() as HANDLE,
|
||||
JobObjectExtendedLimitInformation,
|
||||
&mut info as *mut _ as *mut _,
|
||||
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
);
|
||||
if set_info_result == 0 {
|
||||
return Err(last_win32_error("SetInformationJobObject failed"));
|
||||
}
|
||||
|
||||
let raw_process_handle = OpenProcess(
|
||||
PROCESS_SET_QUOTA | PROCESS_TERMINATE | PROCESS_QUERY_INFORMATION,
|
||||
0,
|
||||
child_pid,
|
||||
);
|
||||
if raw_process_handle.is_null() {
|
||||
return Err(last_win32_error("OpenProcess failed"));
|
||||
}
|
||||
let process_handle = OwnedHandle::from_raw_handle(raw_process_handle);
|
||||
|
||||
let assign_result = AssignProcessToJobObject(job.as_raw_handle(), process_handle.as_raw_handle());
|
||||
if assign_result == 0 {
|
||||
return Err(last_win32_error("AssignProcessToJobObject failed"));
|
||||
}
|
||||
|
||||
Ok(job)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn last_win32_error(operation: &'static str) -> anyhow::Error {
|
||||
anyhow::Error::new(std::io::Error::last_os_error()).context(operation)
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "windows"))]
|
||||
mod tests {
|
||||
use super::create_and_assign_sidecar_job;
|
||||
use anyhow::Result;
|
||||
use std::{
|
||||
process::{Child, Command, Stdio},
|
||||
thread::sleep,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
// 起一个长命子进程用于验证 Job Object 的生命周期绑定。
|
||||
// 直接使用 System32 下的 ping.exe,避免 cmd 中间层。
|
||||
fn spawn_long_lived() -> Result<Child> {
|
||||
let child = Command::new("ping")
|
||||
.args(["-n", "999", "127.0.0.1"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
// 在超时内轮询子进程是否退出,返回是否已退出。
|
||||
fn wait_until_exited(child: &mut Child, timeout: Duration) -> Result<bool> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if child.try_wait()?.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Ok(false);
|
||||
}
|
||||
sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
// 成功路径:进程被分配进 Job Object 后仍存活;drop Job 句柄触发
|
||||
// KILL_ON_JOB_CLOSE,进程应在超时内被 OS 终止。
|
||||
#[test]
|
||||
fn job_kills_child_on_handle_drop() -> Result<()> {
|
||||
let mut child = spawn_long_lived()?;
|
||||
|
||||
let job = create_and_assign_sidecar_job(child.id())?;
|
||||
|
||||
// 分配后进程应仍在运行。
|
||||
assert!(
|
||||
child.try_wait()?.is_none(),
|
||||
"child should still be running after being assigned to the job"
|
||||
);
|
||||
|
||||
// 关闭 Job 句柄,OS 应连带终止其成员进程。
|
||||
drop(job);
|
||||
|
||||
assert!(
|
||||
wait_until_exited(&mut child, Duration::from_secs(5))?,
|
||||
"child should be terminated after the job handle is dropped"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 失败路径:对一个不存在的 PID 调用时 OpenProcess 应失败,函数返回 Err。
|
||||
#[test]
|
||||
fn returns_err_for_invalid_pid() {
|
||||
// PID 必须为 4 的倍数且极不可能存在;0xFFFF_FFFC 对应不到真实进程。
|
||||
let result = create_and_assign_sidecar_job(0xFFFF_FFFC);
|
||||
assert!(result.is_err(), "expected Err for a non-existent PID");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use crate::utils::window_manager::WindowManager;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use serde_json::json;
|
||||
use smartstring::alias::String;
|
||||
|
||||
use tauri::{Emitter as _, WebviewWindow};
|
||||
use tauri::{AppHandle, Emitter as _, Manager as _, WebviewWindow};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FrontendEvent<'a> {
|
||||
RefreshClash,
|
||||
RefreshVerge,
|
||||
RefreshProfiles,
|
||||
NoticeMessage { status: &'a str, message: String },
|
||||
ProfileChanged { current_profile_id: &'a String },
|
||||
TimerUpdated { profile_index: &'a String },
|
||||
@@ -20,11 +20,7 @@ pub enum FrontendEvent<'a> {
|
||||
pub struct NotificationSystem {}
|
||||
|
||||
impl NotificationSystem {
|
||||
fn emit_to_window(window: &WebviewWindow, event: FrontendEvent) {
|
||||
let (event_name, Ok(payload)) = Self::serialize_event(event) else {
|
||||
return;
|
||||
};
|
||||
|
||||
fn emit_to_window(window: &WebviewWindow, event_name: &'static str, payload: serde_json::Value) {
|
||||
if let Err(e) = window.emit(event_name, payload) {
|
||||
logging!(warn, Type::Frontend, "Event emit failed: {}", e);
|
||||
}
|
||||
@@ -34,6 +30,7 @@ impl NotificationSystem {
|
||||
match event {
|
||||
FrontendEvent::RefreshClash => ("verge://refresh-clash-config", Ok(json!("yes"))),
|
||||
FrontendEvent::RefreshVerge => ("verge://refresh-verge-config", Ok(json!("yes"))),
|
||||
FrontendEvent::RefreshProfiles => ("verge://refresh-profiles", Ok(json!("yes"))),
|
||||
FrontendEvent::NoticeMessage { status, message } => {
|
||||
("verge://notice-message", serde_json::to_value((status, message)))
|
||||
}
|
||||
@@ -44,9 +41,19 @@ impl NotificationSystem {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn send_event(event: FrontendEvent) {
|
||||
if let Some(window) = WindowManager::get_main_window() {
|
||||
Self::emit_to_window(&window, event);
|
||||
pub(crate) fn send_event(app_handle: AppHandle, event: FrontendEvent) {
|
||||
let (event_name, Ok(payload)) = Self::serialize_event(event) else {
|
||||
return;
|
||||
};
|
||||
let dispatch_handle = app_handle.clone();
|
||||
// Emitting from a runtime worker can deadlock on macOS when WebKit's protocol handler
|
||||
// waits for Tauri's webview lock while emit waits synchronously for the main thread.
|
||||
if let Err(err) = app_handle.run_on_main_thread(move || {
|
||||
if let Some(window) = dispatch_handle.get_webview_window("main") {
|
||||
Self::emit_to_window(&window, event_name, payload);
|
||||
}
|
||||
}) {
|
||||
logging!(warn, Type::Frontend, "Failed to dispatch event on main thread: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+299
-101
@@ -3,20 +3,24 @@ use crate::{
|
||||
core::{logger::Logger, tray::Tray},
|
||||
utils::dirs,
|
||||
};
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use backon::{ConstantBuilder, Retryable as _};
|
||||
use clash_verge_logging::{Type, logging, logging_error};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use clash_verge_service_ipc::CoreConfig;
|
||||
use compact_str::CompactString;
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
use scopeguard::defer;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
env::current_exe,
|
||||
future::Future,
|
||||
path::{Path, PathBuf},
|
||||
process::Command as StdCommand,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ServiceStatus {
|
||||
@@ -29,8 +33,146 @@ pub enum ServiceStatus {
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServiceManager(ServiceStatus);
|
||||
pub struct ServiceManager {
|
||||
status: Mutex<ServiceStatus>,
|
||||
operation_running: AtomicBool,
|
||||
operation_done: Notify,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn service_core_path(clash_core: &str, bin_ext: &str) -> Result<PathBuf> {
|
||||
Ok(current_exe()?.with_file_name(format!("{clash_core}{bin_ext}")))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn service_core_path(clash_core: &str, bin_ext: &str) -> Result<PathBuf> {
|
||||
let binary_name = format!("{clash_core}{bin_ext}");
|
||||
let exe_path = current_exe()?;
|
||||
let candidate = exe_path.with_file_name(&binary_name);
|
||||
|
||||
if !is_macos_app_translocated(&exe_path) {
|
||||
return Ok(candidate);
|
||||
}
|
||||
|
||||
if let Some(stable_path) = stable_macos_core_path_for_translocated_app(&exe_path, &binary_name) {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"macOS App Translocation detected for core path {:?}; using stable installed path {:?}",
|
||||
candidate,
|
||||
stable_path
|
||||
);
|
||||
return Ok(stable_path);
|
||||
}
|
||||
|
||||
// 给用户一个可操作的提示,再 bail 让服务启动失败 —— 避免用临时路径起内核。
|
||||
notify_translocated_core_path();
|
||||
bail!(
|
||||
"macOS App Translocation detected; refusing to start service with temporary core path {:?}",
|
||||
candidate
|
||||
)
|
||||
}
|
||||
|
||||
/// 发送 translocation 用户提示。**延迟**发送:app 启动期会自动尝试起 core,此时前端的
|
||||
/// `verge://notice-message` 监听器(随 React 布局挂载后才注册)可能尚未就绪,而后端 emit
|
||||
/// 没有重放队列 —— 立即发会丢失。延迟到前端挂载后再发,既覆盖"启动自动起 core 失败"、
|
||||
/// 也兼顾手动启动(错误提示略迟可接受)。复用前端 `set_config::error` 处理器直接展示该消息。
|
||||
#[cfg(target_os = "macos")]
|
||||
fn notify_translocated_core_path() {
|
||||
crate::process::AsyncHandler::spawn(|| async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
crate::core::handle::Handle::notice_message(
|
||||
"set_config::error",
|
||||
clash_verge_i18n::t!("service.translocatedCorePath").to_string(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn is_macos_app_translocated(path: &Path) -> bool {
|
||||
path.components()
|
||||
.any(|component| component.as_os_str() == "AppTranslocation")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn stable_macos_core_path_for_translocated_app(exe_path: &Path, binary_name: &str) -> Option<PathBuf> {
|
||||
let bundle_name = macos_app_bundle_name(exe_path)?;
|
||||
macos_core_path_in_install_roots(
|
||||
&bundle_name,
|
||||
binary_name,
|
||||
[Path::new("/Applications"), Path::new("/Applications/Utilities")],
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_app_bundle_name(path: &Path) -> Option<std::ffi::OsString> {
|
||||
path.ancestors().find_map(|ancestor| {
|
||||
let is_app_bundle = ancestor
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("app"));
|
||||
|
||||
if is_app_bundle {
|
||||
ancestor.file_name().map(std::ffi::OsString::from)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_core_path_in_install_roots<'a>(
|
||||
bundle_name: &std::ffi::OsStr,
|
||||
binary_name: &str,
|
||||
install_roots: impl IntoIterator<Item = &'a Path>,
|
||||
) -> Option<PathBuf> {
|
||||
install_roots.into_iter().find_map(|root| {
|
||||
let core_path = root
|
||||
.join(Path::new(bundle_name))
|
||||
.join("Contents")
|
||||
.join("MacOS")
|
||||
.join(binary_name);
|
||||
|
||||
core_path.is_file().then_some(core_path)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const fn macos_cleanup_translocated_desired_state_shell() -> &'static str {
|
||||
"for f in '/var/root/.local/state/clash-verge-service/desired-state.json' '/var/lib/clash-verge-service/desired-state.json'; do if [ -f \"$f\" ] && /usr/bin/grep -q AppTranslocation \"$f\"; then backup=\"$f.apptranslocation.bak\"; if [ -e \"$backup\" ]; then backup=\"$f.apptranslocation.$(/bin/date +%s).bak\"; fi; /bin/mv \"$f\" \"$backup\"; fi; done"
|
||||
}
|
||||
|
||||
/// 卸载服务前以 root 清理残留 core 和 IPC 套接字。
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_force_stop_core_shell() -> String {
|
||||
use crate::config::IVerge;
|
||||
|
||||
// 只清理 root 拥有的服务内核。
|
||||
let mut parts: Vec<String> = IVerge::VALID_CLASH_CORES
|
||||
.iter()
|
||||
.map(|core| format!("/usr/bin/pkill -U root -x {core} 2>/dev/null || true"))
|
||||
.collect();
|
||||
|
||||
if let Ok(ipc) = dirs::ipc_path()
|
||||
&& let Ok(ipc_str) = dirs::path_to_str(&ipc)
|
||||
{
|
||||
// 转义单引号,避免破坏 shell 参数。
|
||||
let escaped = ipc_str.replace('\'', r"'\''");
|
||||
parts.push(format!("/bin/rm -f '{escaped}' 2>/dev/null || true"));
|
||||
}
|
||||
|
||||
parts.join("; ")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn escape_osascript_double_quoted_string(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn shell_single_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn uninstall_service() -> Result<()> {
|
||||
@@ -121,17 +263,11 @@ fn uninstall_service() -> Result<()> {
|
||||
bail!(format!("uninstaller not found: {uninstall_path:?}"));
|
||||
}
|
||||
|
||||
let uninstall_shell: String = uninstall_path.to_string_lossy().replace(" ", "\\ ");
|
||||
|
||||
let elevator = crate::utils::help::linux_elevator();
|
||||
let status = if linux_running_as_root() {
|
||||
StdCommand::new(&uninstall_path).status()?
|
||||
} else {
|
||||
let result = StdCommand::new(&elevator)
|
||||
.arg("sh")
|
||||
.arg("-c")
|
||||
.arg(&uninstall_shell)
|
||||
.status()?;
|
||||
let result = StdCommand::new(&elevator).arg(&uninstall_path).status()?;
|
||||
|
||||
// 如果 pkexec 执行失败,回退到 sudo
|
||||
if !result.success() && elevator.contains("pkexec") {
|
||||
@@ -141,11 +277,7 @@ fn uninstall_service() -> Result<()> {
|
||||
"pkexec failed with code {}, falling back to sudo",
|
||||
result.code().unwrap_or(-1)
|
||||
);
|
||||
StdCommand::new("sudo")
|
||||
.arg("sh")
|
||||
.arg("-c")
|
||||
.arg(&uninstall_shell)
|
||||
.status()?
|
||||
StdCommand::new("sudo").arg(&uninstall_path).status()?
|
||||
} else {
|
||||
result
|
||||
}
|
||||
@@ -177,17 +309,11 @@ fn install_service() -> Result<()> {
|
||||
bail!(format!("installer not found: {install_path:?}"));
|
||||
}
|
||||
|
||||
let install_shell: String = install_path.to_string_lossy().replace(" ", "\\ ");
|
||||
|
||||
let elevator = crate::utils::help::linux_elevator();
|
||||
let output = if linux_running_as_root() {
|
||||
StdCommand::new(&install_path).output()?
|
||||
} else {
|
||||
let result = StdCommand::new(&elevator)
|
||||
.arg("sh")
|
||||
.arg("-c")
|
||||
.arg(&install_shell)
|
||||
.output()?;
|
||||
let result = StdCommand::new(&elevator).arg(&install_path).output()?;
|
||||
|
||||
// 如果 pkexec 执行失败,回退到 sudo
|
||||
if !result.status.success() && elevator.contains("pkexec") {
|
||||
@@ -197,11 +323,7 @@ fn install_service() -> Result<()> {
|
||||
"pkexec failed with code {}, falling back to sudo",
|
||||
result.status.code().unwrap_or(-1)
|
||||
);
|
||||
StdCommand::new("sudo")
|
||||
.arg("sh")
|
||||
.arg("-c")
|
||||
.arg(&install_shell)
|
||||
.output()?
|
||||
StdCommand::new("sudo").arg(&install_path).output()?
|
||||
} else {
|
||||
result
|
||||
}
|
||||
@@ -245,8 +367,11 @@ fn uninstall_service() -> Result<()> {
|
||||
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
|
||||
|
||||
let prompt = clash_verge_i18n::t!("service.adminUninstallPrompt");
|
||||
let command =
|
||||
format!(r#"do shell script "sudo '{uninstall_shell}'" with administrator privileges with prompt "{prompt}""#);
|
||||
// 先清理服务残留,再执行卸载器。
|
||||
let uninstall_quoted = shell_single_quote(&uninstall_shell);
|
||||
let shell = format!("{}; sudo {uninstall_quoted}", macos_force_stop_core_shell());
|
||||
let shell = escape_osascript_double_quoted_string(&shell);
|
||||
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
|
||||
|
||||
// logging!(debug, Type::Service, "uninstall command: {}", command);
|
||||
|
||||
@@ -279,9 +404,13 @@ fn install_service() -> Result<()> {
|
||||
|
||||
let gid = tauri_plugin_clash_verge_sysinfo::current_gid();
|
||||
let prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
|
||||
let command = format!(
|
||||
r#"do shell script "sudo CLASH_VERGE_SERVICE_GID={gid} '{install_shell}'" with administrator privileges with prompt "{prompt}""#
|
||||
let install_quoted = shell_single_quote(&install_shell);
|
||||
let shell = format!(
|
||||
"{}; sudo CLASH_VERGE_SERVICE_GID={gid} {install_quoted}",
|
||||
macos_cleanup_translocated_desired_state_shell()
|
||||
);
|
||||
let shell = escape_osascript_double_quoted_string(&shell);
|
||||
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
|
||||
|
||||
let output = StdCommand::new("osascript").args(vec!["-e", &command]).output()?;
|
||||
if let Some((code, err)) = check_output_error(&output) {
|
||||
@@ -349,7 +478,7 @@ pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result
|
||||
drop(verge_config);
|
||||
|
||||
let bin_ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
let bin_path = current_exe()?.with_file_name(format!("{clash_core}{bin_ext}"));
|
||||
let bin_path = service_core_path(&clash_core, bin_ext)?;
|
||||
|
||||
let payload = clash_verge_service_ipc::ClashConfig {
|
||||
core_config: CoreConfig {
|
||||
@@ -379,7 +508,7 @@ pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result
|
||||
pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
|
||||
logging!(info, Type::Service, "正在尝试通过服务启动核心");
|
||||
|
||||
SERVICE_MANAGER.lock().await.refresh().await?;
|
||||
SERVICE_MANAGER.refresh().await?;
|
||||
|
||||
logging!(info, Type::Service, "服务已运行且版本匹配,直接使用");
|
||||
start_with_existing_service(config_file).await
|
||||
@@ -435,24 +564,7 @@ pub async fn is_service_available() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn wait_and_check_service_available(status: &mut ServiceManager) -> Result<()> {
|
||||
wait_for_service_ipc(status, "Waiting for service to be available").await
|
||||
}
|
||||
|
||||
async fn wait_and_check_service_version(status: &mut ServiceManager) -> Result<()> {
|
||||
wait_and_check_service_available(status).await?;
|
||||
|
||||
if clash_verge_service_ipc::is_reinstall_service_needed().await {
|
||||
logging!(info, Type::Service, "服务版本不匹配,执行重装流程");
|
||||
reinstall_service()?;
|
||||
wait_and_check_service_available(status).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_service_ipc(status: &mut ServiceManager, reason: &str) -> Result<()> {
|
||||
status.0 = ServiceStatus::Unavailable(reason.into());
|
||||
async fn wait_for_service_ipc(manager: &ServiceManager) -> Result<()> {
|
||||
let config = ServiceManager::config();
|
||||
|
||||
let backoff = ConstantBuilder::default()
|
||||
@@ -460,18 +572,18 @@ async fn wait_for_service_ipc(status: &mut ServiceManager, reason: &str) -> Resu
|
||||
.with_max_times(config.max_retries);
|
||||
|
||||
let result = (|| async {
|
||||
if Path::new(clash_verge_service_ipc::IPC_PATH).exists() {
|
||||
clash_verge_service_ipc::connect().await?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("IPC path not ready"))
|
||||
if !is_service_ipc_path_exists() {
|
||||
bail!("IPC path not ready");
|
||||
}
|
||||
clash_verge_service_ipc::connect().await.map(drop)
|
||||
})
|
||||
.retry(backoff)
|
||||
.await;
|
||||
|
||||
if result.is_ok() {
|
||||
status.0 = ServiceStatus::Ready;
|
||||
manager.set_status(ServiceStatus::Ready);
|
||||
} else {
|
||||
manager.set_status(ServiceStatus::Unavailable("Waiting for service to be available".into()));
|
||||
}
|
||||
|
||||
result
|
||||
@@ -482,10 +594,6 @@ pub fn is_service_ipc_path_exists() -> bool {
|
||||
}
|
||||
|
||||
impl ServiceManager {
|
||||
pub fn default() -> Self {
|
||||
Self(ServiceStatus::Unavailable("Need Checks".into()))
|
||||
}
|
||||
|
||||
pub const fn config() -> clash_verge_service_ipc::IpcConfig {
|
||||
clash_verge_service_ipc::IpcConfig {
|
||||
default_timeout: Duration::from_millis(150),
|
||||
@@ -494,72 +602,162 @@ impl ServiceManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn init(&mut self) -> Result<()> {
|
||||
pub async fn init(&self) -> Result<()> {
|
||||
if let Err(e) = clash_verge_service_ipc::connect().await {
|
||||
self.0 = ServiceStatus::Unavailable("服务连接失败: {e}".to_string());
|
||||
self.set_status(ServiceStatus::Unavailable("服务连接失败: {e}".to_string()));
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn current(&self) -> ServiceStatus {
|
||||
self.0.clone()
|
||||
}
|
||||
|
||||
pub async fn refresh(&mut self) -> Result<()> {
|
||||
let status = self.check_service_comprehensive().await;
|
||||
self.0 = status.clone();
|
||||
logging_error!(Type::Service, self.handle_service_status(&status).await);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 综合服务状态检查(一次性完成所有检查)
|
||||
pub async fn check_service_comprehensive(&self) -> ServiceStatus {
|
||||
if clash_verge_service_ipc::is_reinstall_service_needed().await {
|
||||
ServiceStatus::NeedsReinstall
|
||||
} else {
|
||||
ServiceStatus::Ready
|
||||
pub async fn current(&self) -> ServiceStatus {
|
||||
loop {
|
||||
let notified = self.operation_done.notified();
|
||||
if !self.operation_running.load(Ordering::Acquire) {
|
||||
let status = self.status.lock().clone();
|
||||
if !self.operation_running.load(Ordering::Acquire) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据服务状态执行相应操作
|
||||
pub async fn handle_service_status(&mut self, status: &ServiceStatus) -> Result<()> {
|
||||
match status {
|
||||
ServiceStatus::Ready => {
|
||||
logging!(info, Type::Service, "服务就绪,直接启动");
|
||||
self.0 = ServiceStatus::Ready;
|
||||
fn set_status(&self, status: ServiceStatus) {
|
||||
*self.status.lock() = status;
|
||||
}
|
||||
|
||||
async fn run_operation(&self, operation: impl Future<Output = Result<()>>) -> Result<()> {
|
||||
{
|
||||
if self.operation_running.swap(true, Ordering::AcqRel) {
|
||||
bail!("service operation already running");
|
||||
}
|
||||
defer! {
|
||||
self.operation_running.store(false, Ordering::Release);
|
||||
self.operation_done.notify_waiters();
|
||||
}
|
||||
|
||||
operation.await?;
|
||||
}
|
||||
|
||||
Tray::global().update_menu().await
|
||||
}
|
||||
|
||||
pub async fn refresh(&self) -> Result<()> {
|
||||
self.run_operation(async {
|
||||
self.apply_service_status(if clash_verge_service_ipc::is_reinstall_service_needed().await {
|
||||
ServiceStatus::NeedsReinstall
|
||||
} else {
|
||||
ServiceStatus::Ready
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn handle_service_status(&self, status: ServiceStatus) -> Result<()> {
|
||||
self.run_operation(self.apply_service_status(status)).await
|
||||
}
|
||||
|
||||
async fn apply_service_status(&self, status: ServiceStatus) -> Result<()> {
|
||||
self.set_status(status.clone());
|
||||
match status {
|
||||
ServiceStatus::Ready => logging!(info, Type::Service, "服务就绪,直接启动"),
|
||||
ServiceStatus::NeedsReinstall | ServiceStatus::ReinstallRequired => {
|
||||
logging!(info, Type::Service, "服务需要重装,执行重装流程");
|
||||
reinstall_service()?;
|
||||
wait_and_check_service_available(self).await?;
|
||||
run_service_command(reinstall_service, "reinstall service")?;
|
||||
wait_for_service_ipc(self).await?;
|
||||
}
|
||||
ServiceStatus::ForceReinstallRequired => {
|
||||
logging!(info, Type::Service, "服务需要强制重装,执行强制重装流程");
|
||||
force_reinstall_service()?;
|
||||
wait_and_check_service_available(self).await?;
|
||||
run_service_command(force_reinstall_service, "force reinstall service")?;
|
||||
wait_for_service_ipc(self).await?;
|
||||
}
|
||||
ServiceStatus::InstallRequired => {
|
||||
logging!(info, Type::Service, "需要安装服务,执行安装流程");
|
||||
install_service()?;
|
||||
wait_and_check_service_version(self).await?;
|
||||
run_service_command(install_service, "install service")?;
|
||||
wait_for_service_ipc(self).await?;
|
||||
if clash_verge_service_ipc::is_reinstall_service_needed().await {
|
||||
logging!(info, Type::Service, "服务版本不匹配,执行重装流程");
|
||||
self.set_status(ServiceStatus::NeedsReinstall);
|
||||
run_service_command(reinstall_service, "reinstall service")?;
|
||||
wait_for_service_ipc(self).await?;
|
||||
}
|
||||
}
|
||||
ServiceStatus::UninstallRequired => {
|
||||
logging!(info, Type::Service, "服务需要卸载,执行卸载流程");
|
||||
uninstall_service()?;
|
||||
self.0 = ServiceStatus::Unavailable("Service Uninstalled".into());
|
||||
run_service_command(uninstall_service, "uninstall service")?;
|
||||
self.set_status(ServiceStatus::Unavailable("Service Uninstalled".into()));
|
||||
}
|
||||
ServiceStatus::Unavailable(reason) => {
|
||||
logging!(info, Type::Service, "服务不可用: {},将使用Sidecar模式", reason);
|
||||
self.0 = ServiceStatus::Unavailable(reason.clone());
|
||||
return Err(anyhow::anyhow!("服务不可用: {}", reason));
|
||||
bail!("服务不可用: {}", reason);
|
||||
}
|
||||
}
|
||||
|
||||
// 防止服务安装成功后,内核未完全启动导致系统托盘无法获取代理节点信息
|
||||
Tray::global().update_menu().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub static SERVICE_MANAGER: Lazy<Mutex<ServiceManager>> = Lazy::new(|| Mutex::new(ServiceManager::default()));
|
||||
fn run_service_command(operation: impl FnOnce() -> Result<()>, label: &'static str) -> Result<()> {
|
||||
tokio::task::block_in_place(operation).with_context(|| format!("{label} failed"))
|
||||
}
|
||||
|
||||
pub static SERVICE_MANAGER: Lazy<ServiceManager> = Lazy::new(|| ServiceManager {
|
||||
status: Mutex::new(ServiceStatus::Unavailable("Need Checks".into())),
|
||||
operation_running: AtomicBool::new(false),
|
||||
operation_done: Notify::new(),
|
||||
});
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn test_dir(name: &str) -> std::io::Result<PathBuf> {
|
||||
let path = std::env::temp_dir().join(format!("clash-verge-service-path-test-{}-{name}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_app_translocation_paths() {
|
||||
let path = Path::new(
|
||||
"/private/var/folders/example/T/AppTranslocation/123/d/Clash Verge.app/Contents/MacOS/Clash Verge",
|
||||
);
|
||||
|
||||
assert!(is_macos_app_translocated(path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_app_bundle_name_from_executable_path() {
|
||||
let path = Path::new("/Applications/Clash Verge.app/Contents/MacOS/Clash Verge");
|
||||
|
||||
assert_eq!(
|
||||
macos_app_bundle_name(path).as_deref(),
|
||||
Some(std::ffi::OsStr::new("Clash Verge.app"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_existing_core_path_from_install_roots() -> std::io::Result<()> {
|
||||
let root = test_dir("resolve-existing-core-path")?;
|
||||
let core_dir = root.join("Clash Verge.app").join("Contents").join("MacOS");
|
||||
let core_path = core_dir.join("verge-mihomo");
|
||||
|
||||
fs::create_dir_all(&core_dir)?;
|
||||
fs::write(&core_path, b"")?;
|
||||
|
||||
let resolved = macos_core_path_in_install_roots(
|
||||
std::ffi::OsStr::new("Clash Verge.app"),
|
||||
"verge-mihomo",
|
||||
[root.as_path()],
|
||||
);
|
||||
|
||||
assert_eq!(resolved, Some(core_path));
|
||||
|
||||
fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,23 +60,16 @@ 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>";
|
||||
|
||||
async fn get_bypass() -> String {
|
||||
let use_default = Config::verge().await.latest_arc().use_default_bypass.unwrap_or(true);
|
||||
let res = {
|
||||
let verge = Config::verge().await;
|
||||
let verge = verge.latest_arc();
|
||||
verge.system_proxy_bypass.clone()
|
||||
};
|
||||
let custom_bypass = match res {
|
||||
Some(bypass) => bypass,
|
||||
None => "".into(),
|
||||
};
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let use_default = verge.use_default_bypass.unwrap_or(true);
|
||||
let custom_bypass = verge.system_proxy_bypass.as_deref().unwrap_or("");
|
||||
|
||||
if custom_bypass.is_empty() {
|
||||
DEFAULT_BYPASS.into()
|
||||
} else if use_default {
|
||||
format!("{DEFAULT_BYPASS},{custom_bypass}").into()
|
||||
} else {
|
||||
custom_bypass
|
||||
custom_bypass.into()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,18 +134,19 @@ impl Sysopt {
|
||||
None => Config::clash().await.latest_arc().get_mixed_port(),
|
||||
};
|
||||
let pac_port = IVerge::get_singleton_port();
|
||||
let (sys_enable, pac_enable, proxy_host, proxy_guard) = (
|
||||
verge.enable_system_proxy.unwrap_or_default(),
|
||||
verge.proxy_auto_config.unwrap_or_default(),
|
||||
verge.proxy_host.clone().unwrap_or_else(|| String::from("127.0.0.1")),
|
||||
verge.enable_proxy_guard.unwrap_or_default(),
|
||||
);
|
||||
// 先 await, 避免持有锁导致的 Send 问题
|
||||
let bypass = get_bypass().await;
|
||||
|
||||
let (sys_enable, pac_enable, proxy_host, proxy_guard) = (
|
||||
verge.enable_system_proxy.unwrap_or_default(),
|
||||
verge.proxy_auto_config.unwrap_or_default(),
|
||||
verge.proxy_host.as_deref().unwrap_or("127.0.0.1"),
|
||||
verge.enable_proxy_guard.unwrap_or_default(),
|
||||
);
|
||||
|
||||
let (sys, auto, guard_type) = {
|
||||
let (sys, auto) = &mut *self.inner_proxy.write();
|
||||
sys.host = proxy_host.clone().into();
|
||||
sys.host = proxy_host.into();
|
||||
sys.port = port;
|
||||
sys.bypass = bypass.into();
|
||||
auto.url = format!("http://{proxy_host}:{pac_port}/commands/pac");
|
||||
|
||||
+346
-357
@@ -1,60 +1,74 @@
|
||||
use crate::{config::Config, feat, singleton, utils::resolve::is_resolve_done};
|
||||
use anyhow::{Context as _, Result};
|
||||
use crate::{
|
||||
config::{Config, PrfItem},
|
||||
feat,
|
||||
process::AsyncHandler,
|
||||
singleton,
|
||||
utils::resolve::is_resolve_done,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use clash_verge_logging::{Type, logging, logging_error};
|
||||
use delay_timer::prelude::{DelayTimer, DelayTimerBuilder, TaskBuilder};
|
||||
use parking_lot::RwLock;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use smartstring::alias::String;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
pin::Pin,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::time::{sleep, timeout};
|
||||
use tokio::{
|
||||
sync::mpsc,
|
||||
time::{sleep, timeout},
|
||||
};
|
||||
use tokio_stream::StreamExt as _;
|
||||
use tokio_util::time::{DelayQueue, delay_queue::Key};
|
||||
|
||||
type TaskID = u64;
|
||||
enum TimerCommand {
|
||||
Apply(HashMap<String, u64>),
|
||||
RunNow(String),
|
||||
TaskFinished(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimerTask {
|
||||
pub task_id: TaskID,
|
||||
pub interval_minutes: u64,
|
||||
#[allow(unused)]
|
||||
pub last_run: i64, // Timestamp of last execution
|
||||
struct TaskState {
|
||||
key: Option<Key>,
|
||||
interval_minutes: u64,
|
||||
running: bool,
|
||||
}
|
||||
|
||||
impl TaskState {
|
||||
const fn new(key: Key, interval_minutes: u64) -> Self {
|
||||
Self {
|
||||
key: Some(key),
|
||||
interval_minutes,
|
||||
running: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Timer {
|
||||
/// cron manager
|
||||
pub delay_timer: Arc<RwLock<DelayTimer>>,
|
||||
|
||||
/// save the current state - using RwLock for better read concurrency
|
||||
pub timer_map: Arc<RwLock<HashMap<String, TimerTask>>>,
|
||||
|
||||
/// increment id - atomic counter for better performance
|
||||
pub timer_count: AtomicU64,
|
||||
|
||||
/// Flag to mark if timer is initialized - atomic for better performance
|
||||
command_tx: mpsc::UnboundedSender<TimerCommand>,
|
||||
command_rx: Mutex<Option<mpsc::UnboundedReceiver<TimerCommand>>>,
|
||||
refresh_lock: tokio::sync::Mutex<()>,
|
||||
pub timer_map: Arc<RwLock<HashMap<String, u64>>>,
|
||||
pub initialized: AtomicBool,
|
||||
}
|
||||
|
||||
// Use singleton macro
|
||||
singleton!(Timer, TIMER_INSTANCE);
|
||||
|
||||
impl Timer {
|
||||
fn new() -> Self {
|
||||
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||
Self {
|
||||
delay_timer: Arc::new(RwLock::new(DelayTimerBuilder::default().build())),
|
||||
command_tx,
|
||||
command_rx: Mutex::new(Some(command_rx)),
|
||||
refresh_lock: tokio::sync::Mutex::new(()),
|
||||
timer_map: Arc::new(RwLock::new(HashMap::new())),
|
||||
timer_count: AtomicU64::new(1),
|
||||
initialized: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize timer with better error handling and atomic operations
|
||||
pub async fn init(&self) -> Result<()> {
|
||||
// Use compare_exchange for thread-safe initialization check
|
||||
if self
|
||||
.initialized
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
@@ -64,76 +78,47 @@ impl Timer {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Initialize timer tasks
|
||||
let command_rx = { self.command_rx.lock().take() };
|
||||
if let Some(command_rx) = command_rx {
|
||||
let command_tx = self.command_tx.clone();
|
||||
AsyncHandler::spawn(move || async move {
|
||||
Self::run_scheduler(command_rx, command_tx).await;
|
||||
});
|
||||
}
|
||||
|
||||
if let Err(e) = self.refresh().await {
|
||||
// Reset initialization flag on error
|
||||
self.initialized.store(false, Ordering::SeqCst);
|
||||
logging_error!(Type::Timer, "Failed to initialize timer: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Log timer info first
|
||||
{
|
||||
let timer_map = self.timer_map.read();
|
||||
logging!(info, Type::Timer, "已注册的定时任务数量: {}", timer_map.len());
|
||||
|
||||
for (uid, task) in timer_map.iter() {
|
||||
logging!(debug, Type::Timer, "Registered timer task count: {}", timer_map.len());
|
||||
for (uid, interval) in timer_map.iter() {
|
||||
logging!(
|
||||
info,
|
||||
debug,
|
||||
Type::Timer,
|
||||
"注册了定时任务 - uid={}, interval={}min, task_id={}",
|
||||
"Registered timer task: uid={}, interval={}min",
|
||||
uid,
|
||||
task.interval_minutes,
|
||||
task.task_id
|
||||
interval
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let cur_timestamp = chrono::Local::now().timestamp();
|
||||
|
||||
// Collect profiles that need immediate update
|
||||
let profiles_to_update = if let Some(items) = Config::profiles().await.latest_arc().get_items() {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let allow_auto_update = item.option.as_ref()?.allow_auto_update.unwrap_or_default();
|
||||
if !allow_auto_update {
|
||||
return None;
|
||||
}
|
||||
|
||||
let interval = item.option.as_ref()?.update_interval? as i64;
|
||||
let updated = item.updated? as i64;
|
||||
let uid = item.uid.as_ref()?;
|
||||
|
||||
if interval > 0 && cur_timestamp - updated >= interval * 60 {
|
||||
logging!(info, Type::Timer, "需要立即更新的配置: uid={}", uid);
|
||||
Some(uid.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Advance tasks outside of locks to minimize lock contention
|
||||
if !profiles_to_update.is_empty() {
|
||||
logging!(
|
||||
info,
|
||||
Type::Timer,
|
||||
"需要立即更新的配置数量: {}",
|
||||
profiles_to_update.len()
|
||||
);
|
||||
let timer_map = self.timer_map.read();
|
||||
|
||||
for uid in profiles_to_update {
|
||||
if let Some(task) = timer_map.get(&uid) {
|
||||
logging!(info, Type::Timer, "立即执行任务: uid={}", uid);
|
||||
let delay_timer = self.delay_timer.write();
|
||||
if let Err(e) = delay_timer.advance_task(task.task_id) {
|
||||
logging!(warn, Type::Timer, "Failed to advance task {}: {}", uid, e);
|
||||
}
|
||||
if let Some(items) = Config::profiles().await.data_arc().get_items() {
|
||||
for item in items.iter() {
|
||||
if let Some(option) = item.option.as_ref()
|
||||
&& option.allow_auto_update.unwrap_or(true)
|
||||
&& let Some(interval) = option.update_interval
|
||||
&& interval > 0
|
||||
&& let Some(uid) = item.uid.as_ref()
|
||||
&& let Some(updated) = item.updated
|
||||
&& cur_timestamp - (updated as i64) >= (interval as i64) * 60
|
||||
{
|
||||
logging!(info, Type::Timer, "Running overdue timer task immediately: uid={}", uid);
|
||||
let _ = self.command_tx.send(TimerCommand::RunNow(uid.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,319 +127,294 @@ impl Timer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh timer tasks with better error handling
|
||||
pub async fn refresh(&self) -> Result<()> {
|
||||
// Generate diff outside of lock to minimize lock contention
|
||||
let diff_map = self.gen_diff().await;
|
||||
let _refresh_guard = self.refresh_lock.lock().await;
|
||||
let new_map = self.gen_map().await;
|
||||
|
||||
if diff_map.is_empty() {
|
||||
let mut cache = self.timer_map.write();
|
||||
if *cache == new_map {
|
||||
logging!(debug, Type::Timer, "No timer changes needed");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
logging!(info, Type::Timer, "Refreshing {} timer tasks", diff_map.len());
|
||||
|
||||
// Apply changes - first collect operations to perform without holding locks
|
||||
let mut operations_to_add: Vec<(String, TaskID, u64)> = Vec::new();
|
||||
let _operations_to_remove: Vec<String> = Vec::new();
|
||||
|
||||
// Perform sync operations while holding locks
|
||||
{
|
||||
for (uid, diff) in diff_map {
|
||||
match diff {
|
||||
DiffFlag::Del(tid) => {
|
||||
self.timer_map.write().remove(&uid);
|
||||
let value = self.delay_timer.write().remove_task(tid);
|
||||
if let Err(e) = value {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Timer,
|
||||
"Failed to remove task {} for uid {}: {}",
|
||||
tid,
|
||||
uid,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
logging!(debug, Type::Timer, "Removed task {} for uid {}", tid, uid);
|
||||
}
|
||||
}
|
||||
DiffFlag::Add(tid, interval) => {
|
||||
let task = TimerTask {
|
||||
task_id: tid,
|
||||
interval_minutes: interval,
|
||||
last_run: chrono::Local::now().timestamp(),
|
||||
};
|
||||
|
||||
self.timer_map.write().insert(uid.clone(), task);
|
||||
operations_to_add.push((uid, tid, interval));
|
||||
}
|
||||
DiffFlag::Mod(tid, interval) => {
|
||||
// Remove old task first
|
||||
let value = self.delay_timer.write().remove_task(tid);
|
||||
if let Err(e) = value {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Timer,
|
||||
"Failed to remove old task {} for uid {}: {}",
|
||||
tid,
|
||||
uid,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Then add the new one
|
||||
let task = TimerTask {
|
||||
task_id: tid,
|
||||
interval_minutes: interval,
|
||||
last_run: chrono::Local::now().timestamp(),
|
||||
};
|
||||
|
||||
self.timer_map.write().insert(uid.clone(), task);
|
||||
operations_to_add.push((uid, tid, interval));
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Locks are dropped here
|
||||
|
||||
// Now perform async operations without holding locks
|
||||
let delay_timer = self.delay_timer.write();
|
||||
for (uid, tid, interval) in operations_to_add {
|
||||
if let Err(e) = self.add_task(&delay_timer, uid.clone(), tid, interval) {
|
||||
logging_error!(Type::Timer, "Failed to add task for uid {}: {}", uid, e);
|
||||
// Rollback on failure - remove from timer_map
|
||||
self.timer_map.write().remove(&uid);
|
||||
} else {
|
||||
logging!(debug, Type::Timer, "Added task {} for uid {}", tid, uid);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate map of profile UIDs to update intervals
|
||||
async fn gen_map(&self) -> HashMap<String, u64> {
|
||||
let mut new_map = HashMap::new();
|
||||
|
||||
if let Some(items) = Config::profiles().await.latest_arc().get_items() {
|
||||
for item in items.iter() {
|
||||
if let Some(option) = item.option.as_ref()
|
||||
&& let Some(allow_auto_update) = option.allow_auto_update
|
||||
&& let (Some(interval), Some(uid)) = (option.update_interval, &item.uid)
|
||||
&& allow_auto_update
|
||||
&& interval > 0
|
||||
{
|
||||
logging!(
|
||||
debug,
|
||||
Type::Timer,
|
||||
"找到定时更新配置: uid={}, interval={}min",
|
||||
uid,
|
||||
interval
|
||||
);
|
||||
new_map.insert(uid.clone(), interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logging!(debug, Type::Timer, "生成的定时更新配置数量: {}", new_map.len());
|
||||
new_map
|
||||
}
|
||||
|
||||
/// Generate differences between current and new timer configuration
|
||||
async fn gen_diff(&self) -> HashMap<String, DiffFlag> {
|
||||
let mut diff_map = HashMap::new();
|
||||
let new_map = self.gen_map().await;
|
||||
|
||||
// Read lock for comparing current state
|
||||
let timer_map = self.timer_map.read();
|
||||
logging!(debug, Type::Timer, "当前 timer_map 大小: {}", timer_map.len());
|
||||
|
||||
// Find tasks to modify or delete
|
||||
for (uid, task) in timer_map.iter() {
|
||||
match new_map.get(uid) {
|
||||
Some(&interval) if interval != task.interval_minutes => {
|
||||
// Task exists but interval changed
|
||||
logging!(
|
||||
debug,
|
||||
Type::Timer,
|
||||
"定时任务间隔变更: uid={}, 旧={}, 新={}",
|
||||
uid,
|
||||
task.interval_minutes,
|
||||
interval
|
||||
);
|
||||
diff_map.insert(uid.clone(), DiffFlag::Mod(task.task_id, interval));
|
||||
}
|
||||
None => {
|
||||
// Task no longer needed
|
||||
logging!(debug, Type::Timer, "定时任务已删除: uid={}", uid);
|
||||
diff_map.insert(uid.clone(), DiffFlag::Del(task.task_id));
|
||||
}
|
||||
_ => {
|
||||
// Task exists with same interval, no change needed
|
||||
logging!(debug, Type::Timer, "定时任务保持不变: uid={}", uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find new tasks to add
|
||||
let mut next_id = self.timer_count.load(Ordering::Relaxed);
|
||||
let original_id = next_id;
|
||||
|
||||
for (uid, &interval) in new_map.iter() {
|
||||
if !timer_map.contains_key(uid) {
|
||||
logging!(
|
||||
debug,
|
||||
Type::Timer,
|
||||
"新增定时任务: uid={}, interval={}min",
|
||||
uid,
|
||||
interval
|
||||
);
|
||||
diff_map.insert(uid.clone(), DiffFlag::Add(next_id, interval));
|
||||
next_id += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Update counter only if we added new tasks
|
||||
if next_id > original_id {
|
||||
self.timer_count.store(next_id, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
logging!(debug, Type::Timer, "定时任务变更数量: {}", diff_map.len());
|
||||
diff_map
|
||||
}
|
||||
|
||||
/// Add a timer task with better error handling
|
||||
fn add_task(&self, delay_timer: &DelayTimer, uid: String, tid: TaskID, minutes: u64) -> Result<()> {
|
||||
logging!(
|
||||
info,
|
||||
Type::Timer,
|
||||
"Adding task: uid={}, id={}, interval={}min",
|
||||
uid,
|
||||
tid,
|
||||
minutes
|
||||
"Refreshing timer tasks map, count: {}",
|
||||
new_map.len()
|
||||
);
|
||||
*cache = new_map.clone();
|
||||
drop(cache);
|
||||
|
||||
// Create a task with reasonable retries and backoff
|
||||
let task = TaskBuilder::default()
|
||||
.set_task_id(tid)
|
||||
.set_maximum_parallel_runnable_num(1)
|
||||
.set_frequency_repeated_by_minutes(minutes)
|
||||
.spawn_async_routine(move || {
|
||||
let uid = uid.clone();
|
||||
Box::pin(async move {
|
||||
Self::wait_until_resolve_done(Duration::from_millis(5000)).await;
|
||||
Self::async_task(&uid).await;
|
||||
}) as Pin<Box<dyn std::future::Future<Output = ()> + Send>>
|
||||
})
|
||||
.context("failed to create timer task")?;
|
||||
|
||||
delay_timer.add_task(task).context("failed to add timer task")?;
|
||||
let _ = self.command_tx.send(TimerCommand::Apply(new_map));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get next update time for a profile
|
||||
async fn gen_map(&self) -> HashMap<String, u64> {
|
||||
if let Some(items) = Config::profiles().await.data_arc().get_items() {
|
||||
return Self::gen_map_from_items(items);
|
||||
}
|
||||
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
fn gen_map_from_items(items: &[PrfItem]) -> HashMap<String, u64> {
|
||||
let mut new_map = HashMap::new();
|
||||
|
||||
for item in items {
|
||||
if let Some(option) = item.option.as_ref()
|
||||
&& let (Some(interval), Some(uid)) = (option.update_interval, &item.uid)
|
||||
&& option.allow_auto_update.unwrap_or(true)
|
||||
&& interval > 0
|
||||
{
|
||||
new_map.insert(uid.clone(), interval);
|
||||
}
|
||||
}
|
||||
|
||||
new_map
|
||||
}
|
||||
|
||||
async fn run_scheduler(
|
||||
mut command_rx: mpsc::UnboundedReceiver<TimerCommand>,
|
||||
command_tx: mpsc::UnboundedSender<TimerCommand>,
|
||||
) {
|
||||
let mut queue = DelayQueue::new();
|
||||
let mut tasks = HashMap::new();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(expired) = queue.next() => {
|
||||
let uid = expired.into_inner();
|
||||
Self::run_expired_task(&mut tasks, uid, command_tx.clone());
|
||||
}
|
||||
|
||||
command = command_rx.recv() => {
|
||||
match command {
|
||||
Some(TimerCommand::Apply(new_map)) => {
|
||||
Self::apply_timer_map(&mut queue, &mut tasks, new_map);
|
||||
}
|
||||
Some(TimerCommand::RunNow(uid)) => {
|
||||
Self::run_task_now(&mut queue, &mut tasks, uid, command_tx.clone());
|
||||
}
|
||||
Some(TimerCommand::TaskFinished(uid)) => {
|
||||
Self::finish_task(&mut queue, &mut tasks, uid);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_timer_map(
|
||||
queue: &mut DelayQueue<String>,
|
||||
tasks: &mut HashMap<String, TaskState>,
|
||||
new_map: HashMap<String, u64>,
|
||||
) {
|
||||
tasks.retain(|uid, state| {
|
||||
if new_map.contains_key(uid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(key) = state.key.take() {
|
||||
queue.remove(&key);
|
||||
}
|
||||
logging!(debug, Type::Timer, "Removed timer task for uid={}", uid);
|
||||
false
|
||||
});
|
||||
|
||||
for (uid, interval_minutes) in new_map {
|
||||
let Some(state) = tasks.get_mut(&uid) else {
|
||||
Self::insert_task(queue, tasks, uid, interval_minutes);
|
||||
continue;
|
||||
};
|
||||
|
||||
if state.interval_minutes == interval_minutes {
|
||||
continue;
|
||||
}
|
||||
|
||||
Self::update_task_interval(queue, &uid, state, interval_minutes);
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_task(
|
||||
queue: &mut DelayQueue<String>,
|
||||
tasks: &mut HashMap<String, TaskState>,
|
||||
uid: String,
|
||||
interval_minutes: u64,
|
||||
) {
|
||||
let key = Self::schedule_task(queue, &uid, interval_minutes);
|
||||
logging!(
|
||||
debug,
|
||||
Type::Timer,
|
||||
"Added timer task: uid={}, interval={}min",
|
||||
uid,
|
||||
interval_minutes
|
||||
);
|
||||
tasks.insert(uid, TaskState::new(key, interval_minutes));
|
||||
}
|
||||
|
||||
fn update_task_interval(queue: &mut DelayQueue<String>, uid: &str, state: &mut TaskState, interval_minutes: u64) {
|
||||
state.interval_minutes = interval_minutes;
|
||||
|
||||
if let Some(key) = state.key.as_ref() {
|
||||
queue.reset(key, Self::interval_duration(interval_minutes));
|
||||
} else if !state.running {
|
||||
state.key = Some(Self::schedule_task(queue, uid, interval_minutes));
|
||||
}
|
||||
|
||||
logging!(
|
||||
debug,
|
||||
Type::Timer,
|
||||
"Updated timer task interval: uid={}, interval={}min",
|
||||
uid,
|
||||
interval_minutes
|
||||
);
|
||||
}
|
||||
|
||||
fn run_expired_task(
|
||||
tasks: &mut HashMap<String, TaskState>,
|
||||
uid: String,
|
||||
command_tx: mpsc::UnboundedSender<TimerCommand>,
|
||||
) {
|
||||
let Some(state) = tasks.get_mut(&uid) else {
|
||||
return;
|
||||
};
|
||||
|
||||
state.key = None;
|
||||
if !Self::mark_task_running(state, &uid, false) {
|
||||
return;
|
||||
}
|
||||
|
||||
Self::spawn_update_task(uid, command_tx);
|
||||
}
|
||||
|
||||
fn run_task_now(
|
||||
queue: &mut DelayQueue<String>,
|
||||
tasks: &mut HashMap<String, TaskState>,
|
||||
uid: String,
|
||||
command_tx: mpsc::UnboundedSender<TimerCommand>,
|
||||
) {
|
||||
let Some(state) = tasks.get_mut(&uid) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !Self::mark_task_running(state, &uid, true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(key) = state.key.take() {
|
||||
queue.remove(&key);
|
||||
}
|
||||
Self::spawn_update_task(uid, command_tx);
|
||||
}
|
||||
|
||||
fn mark_task_running(state: &mut TaskState, uid: &str, immediate: bool) -> bool {
|
||||
if !state.running {
|
||||
state.running = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if immediate {
|
||||
logging!(
|
||||
debug,
|
||||
Type::Timer,
|
||||
"Timer task already running, skip immediate uid={}",
|
||||
uid
|
||||
);
|
||||
} else {
|
||||
logging!(debug, Type::Timer, "Timer task already running, skip uid={}", uid);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn finish_task(queue: &mut DelayQueue<String>, tasks: &mut HashMap<String, TaskState>, uid: String) {
|
||||
let Some(state) = tasks.get_mut(&uid) else {
|
||||
return;
|
||||
};
|
||||
|
||||
state.running = false;
|
||||
let key = Self::schedule_task(queue, &uid, state.interval_minutes);
|
||||
state.key = Some(key);
|
||||
}
|
||||
|
||||
fn spawn_update_task(uid: String, command_tx: mpsc::UnboundedSender<TimerCommand>) {
|
||||
logging!(info, Type::Timer, "Starting timer task: uid={}", uid);
|
||||
AsyncHandler::spawn(move || async move {
|
||||
Self::wait_until_resolve_done(Duration::from_millis(5000)).await;
|
||||
Self::async_task(&uid).await;
|
||||
let _ = command_tx.send(TimerCommand::TaskFinished(uid));
|
||||
});
|
||||
}
|
||||
|
||||
const fn interval_duration(interval_minutes: u64) -> Duration {
|
||||
Duration::from_secs(interval_minutes.saturating_mul(60))
|
||||
}
|
||||
|
||||
fn schedule_task(queue: &mut DelayQueue<String>, uid: &str, interval_minutes: u64) -> Key {
|
||||
queue.insert(String::from(uid), Self::interval_duration(interval_minutes))
|
||||
}
|
||||
|
||||
pub async fn get_next_update_time(&self, uid: &str) -> Option<i64> {
|
||||
logging!(info, Type::Timer, "获取下次更新时间,uid={}", uid);
|
||||
logging!(debug, Type::Timer, "Getting next update time, uid={}", uid);
|
||||
|
||||
// First extract timer task data without holding the lock across await
|
||||
let task_interval = {
|
||||
let timer_map = self.timer_map.read();
|
||||
match timer_map.get(uid) {
|
||||
Some(t) => t.interval_minutes,
|
||||
None => {
|
||||
logging!(warn, Type::Timer, "找不到对应的定时任务,uid={}", uid);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Get the profile updated timestamp - now safe to await
|
||||
let items = {
|
||||
let profiles = Config::profiles().await;
|
||||
let profiles_guard = profiles.latest_arc();
|
||||
match profiles_guard.get_items() {
|
||||
Some(i) => i.clone(),
|
||||
None => {
|
||||
logging!(warn, Type::Timer, "获取配置列表失败");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let profile = match items.iter().find(|item| item.uid.as_deref() == Some(uid)) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
logging!(warn, Type::Timer, "找不到对应的配置,uid={}", uid);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let task_interval = *self.timer_map.read().get(uid)?;
|
||||
let profiles = Config::profiles().await;
|
||||
let profiles_guard = profiles.latest_arc();
|
||||
let items = profiles_guard.get_items()?;
|
||||
|
||||
let profile = items.iter().find(|item| item.uid.as_deref() == Some(uid))?;
|
||||
let updated = profile.updated.unwrap_or(0) as i64;
|
||||
|
||||
// Calculate next update time
|
||||
if updated > 0 && task_interval > 0 {
|
||||
let next_time = updated + (task_interval as i64 * 60);
|
||||
logging!(info, Type::Timer, "计算得到下次更新时间: {}, uid={}", next_time, uid);
|
||||
Some(next_time)
|
||||
if updated > 0 {
|
||||
Some(updated + (task_interval as i64 * 60))
|
||||
} else {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Timer,
|
||||
"更新时间或间隔无效,updated={}, interval={}",
|
||||
updated,
|
||||
task_interval
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit update events for frontend notification
|
||||
fn emit_update_event(uid: &String, is_start: bool) {
|
||||
{
|
||||
if is_start {
|
||||
super::handle::Handle::notify_profile_update_started(uid);
|
||||
} else {
|
||||
super::handle::Handle::notify_profile_update_completed(uid);
|
||||
}
|
||||
if is_start {
|
||||
super::handle::Handle::notify_profile_update_started(uid);
|
||||
} else {
|
||||
super::handle::Handle::notify_profile_update_completed(uid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Async task with better error handling and logging
|
||||
async fn async_task(uid: &String) {
|
||||
let task_start = std::time::Instant::now();
|
||||
logging!(info, Type::Timer, "Running timer task for profile: {}", uid);
|
||||
logging!(debug, Type::Timer, "Running timer task for profile: {}", uid);
|
||||
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(40), async {
|
||||
Self::emit_update_event(uid, true);
|
||||
|
||||
let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid);
|
||||
logging!(info, Type::Timer, "配置 {} 是否为当前激活配置: {}", uid, is_current);
|
||||
logging!(
|
||||
debug,
|
||||
Type::Timer,
|
||||
"Profile {} is current active profile: {}",
|
||||
uid,
|
||||
is_current
|
||||
);
|
||||
|
||||
feat::update_profile(uid, None, is_current, false, false).await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => match result {
|
||||
Ok(_) => {
|
||||
let duration = task_start.elapsed().as_millis();
|
||||
logging!(
|
||||
info,
|
||||
Type::Timer,
|
||||
"Timer task completed successfully for uid: {} (took {}ms)",
|
||||
uid,
|
||||
duration
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e);
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
logging_error!(Type::Timer, "Timer task timed out for uid: {}", uid);
|
||||
Ok(Ok(_)) => {
|
||||
logging!(
|
||||
info,
|
||||
Type::Timer,
|
||||
"Timer task completed for uid: {} (took {}ms)",
|
||||
uid,
|
||||
task_start.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
Ok(Err(e)) => logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e),
|
||||
Err(_) => logging_error!(Type::Timer, "Timer task timed out for uid: {}", uid),
|
||||
}
|
||||
|
||||
// Emit completed event
|
||||
Self::emit_update_event(uid, false);
|
||||
}
|
||||
|
||||
@@ -469,9 +429,38 @@ impl Timer {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum DiffFlag {
|
||||
Del(TaskID),
|
||||
Add(TaskID, u64),
|
||||
Mod(TaskID, u64),
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Timer;
|
||||
use crate::config::{PrfItem, PrfOption};
|
||||
|
||||
fn remote_profile(uid: &str, allow_auto_update: Option<bool>, update_interval: Option<u64>) -> PrfItem {
|
||||
PrfItem {
|
||||
uid: Some(uid.into()),
|
||||
itype: Some("remote".into()),
|
||||
option: Some(PrfOption {
|
||||
allow_auto_update,
|
||||
update_interval,
|
||||
..PrfOption::default()
|
||||
}),
|
||||
..PrfItem::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_map_only_contains_enabled_profiles_with_positive_intervals() {
|
||||
let items = vec![
|
||||
remote_profile("enabled", Some(true), Some(30)),
|
||||
remote_profile("disabled", Some(false), Some(30)),
|
||||
remote_profile("missing-flag", None, Some(30)),
|
||||
remote_profile("zero-interval", Some(true), Some(0)),
|
||||
remote_profile("missing-interval", Some(true), None),
|
||||
];
|
||||
|
||||
let map = Timer::gen_map_from_items(&items);
|
||||
|
||||
assert_eq!(map.len(), 2);
|
||||
assert_eq!(map.get("enabled"), Some(&30));
|
||||
assert_eq!(map.get("missing-flag"), Some(&30));
|
||||
}
|
||||
}
|
||||
|
||||
+124
-86
@@ -6,8 +6,11 @@ use crate::process::AsyncHandler;
|
||||
use crate::singleton;
|
||||
use crate::utils::window_manager::WindowManager;
|
||||
use crate::{
|
||||
Type, cmd, config::Config, feat, logging, module::lightweight::is_in_lightweight_mode,
|
||||
utils::dirs::find_target_icons,
|
||||
Type, cmd,
|
||||
config::Config,
|
||||
feat, logging,
|
||||
module::lightweight::is_in_lightweight_mode,
|
||||
utils::{dirs::find_target_icons, help},
|
||||
};
|
||||
use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
|
||||
use clash_verge_logging::logging_error;
|
||||
@@ -19,6 +22,7 @@ use tokio::fs;
|
||||
use super::handle;
|
||||
use anyhow::Result;
|
||||
use smartstring::alias::String;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tauri::{
|
||||
@@ -36,6 +40,13 @@ use menu_def::{MenuIds, MenuTexts};
|
||||
type ProxyMenuItem = (Option<Submenu<Wry>>, Vec<Box<dyn IsMenuItem<Wry>>>);
|
||||
|
||||
const TRAY_CLICK_DEBOUNCE_MS: u64 = 300;
|
||||
pub const TRAY_ID: &str = "clash-verge-rev-tray";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TrayMenuOptions {
|
||||
is_lightweight_mode: bool,
|
||||
include_proxy_groups: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TrayState {}
|
||||
@@ -53,7 +64,7 @@ pub struct Tray {
|
||||
}
|
||||
|
||||
impl TrayState {
|
||||
async fn get_tray_icon(verge: &IVerge) -> (bool, Vec<u8>) {
|
||||
async fn get_tray_icon(verge: &IVerge) -> (bool, Cow<'_, [u8]>) {
|
||||
let tun_mode = verge.enable_tun_mode.unwrap_or(false);
|
||||
let system_mode = verge.enable_system_proxy.unwrap_or(false);
|
||||
let kind = if tun_mode {
|
||||
@@ -66,7 +77,7 @@ impl TrayState {
|
||||
Self::load_icon(verge, kind).await
|
||||
}
|
||||
|
||||
async fn load_icon(verge: &IVerge, kind: IconKind) -> (bool, Vec<u8>) {
|
||||
async fn load_icon(verge: &IVerge, kind: IconKind) -> (bool, Cow<'_, [u8]>) {
|
||||
let (custom_enabled, icon_name) = match kind {
|
||||
IconKind::Common => (verge.common_tray_icon.unwrap_or(false), "common"),
|
||||
IconKind::SysProxy => (verge.sysproxy_tray_icon.unwrap_or(false), "sysproxy"),
|
||||
@@ -77,13 +88,14 @@ impl TrayState {
|
||||
&& let Ok(Some(path)) = find_target_icons(icon_name)
|
||||
&& let Ok(data) = fs::read(path).await
|
||||
{
|
||||
return (true, data);
|
||||
return (true, Cow::Owned(data));
|
||||
}
|
||||
|
||||
Self::default_icon(verge, kind)
|
||||
}
|
||||
|
||||
fn default_icon(verge: &IVerge, kind: IconKind) -> (bool, Vec<u8>) {
|
||||
#[allow(clippy::missing_const_for_fn)]
|
||||
fn default_icon(verge: &IVerge, kind: IconKind) -> (bool, Cow<'_, [u8]>) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let is_mono = verge.tray_icon.as_deref().unwrap_or("monochrome") == "monochrome";
|
||||
@@ -91,9 +103,11 @@ impl TrayState {
|
||||
return (
|
||||
false,
|
||||
match kind {
|
||||
IconKind::Common => include_bytes!("../../../icons/tray-icon-mono.ico").to_vec(),
|
||||
IconKind::SysProxy => include_bytes!("../../../icons/tray-icon-sys-mono-new.ico").to_vec(),
|
||||
IconKind::Tun => include_bytes!("../../../icons/tray-icon-tun-mono-new.ico").to_vec(),
|
||||
IconKind::Common => Cow::Borrowed(include_bytes!("../../../icons/tray-icon-mono.ico")),
|
||||
IconKind::SysProxy => {
|
||||
Cow::Borrowed(include_bytes!("../../../icons/tray-icon-sys-mono-new.ico"))
|
||||
}
|
||||
IconKind::Tun => Cow::Borrowed(include_bytes!("../../../icons/tray-icon-tun-mono-new.ico")),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -105,9 +119,9 @@ impl TrayState {
|
||||
(
|
||||
false,
|
||||
match kind {
|
||||
IconKind::Common => include_bytes!("../../../icons/tray-icon.ico").to_vec(),
|
||||
IconKind::SysProxy => include_bytes!("../../../icons/tray-icon-sys.ico").to_vec(),
|
||||
IconKind::Tun => include_bytes!("../../../icons/tray-icon-tun.ico").to_vec(),
|
||||
IconKind::Common => Cow::Borrowed(include_bytes!("../../../icons/tray-icon.ico")),
|
||||
IconKind::SysProxy => Cow::Borrowed(include_bytes!("../../../icons/tray-icon-sys.ico")),
|
||||
IconKind::Tun => Cow::Borrowed(include_bytes!("../../../icons/tray-icon-tun.ico")),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -166,7 +180,7 @@ impl Tray {
|
||||
let tray_event = { Config::verge().await.latest_arc().tray_event.clone() };
|
||||
let tray_event = TrayAction::from(tray_event.as_deref().unwrap_or("main_window"));
|
||||
let tray = app_handle
|
||||
.tray_by_id("main")
|
||||
.tray_by_id(TRAY_ID)
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get main tray"))?;
|
||||
match tray_event {
|
||||
TrayAction::TrayMenu => tray.set_show_menu_on_left_click(true)?,
|
||||
@@ -182,11 +196,11 @@ impl Tray {
|
||||
return Ok(());
|
||||
}
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
self.update_menu_internal(app_handle).await
|
||||
self.update_menu_internal(app_handle, true).await
|
||||
}
|
||||
|
||||
async fn update_menu_internal(&self, app_handle: &AppHandle) -> Result<()> {
|
||||
let Some(tray) = app_handle.tray_by_id("main") else {
|
||||
async fn update_menu_internal(&self, app_handle: &AppHandle, include_proxy_groups: bool) -> Result<()> {
|
||||
let Some(tray) = app_handle.tray_by_id(TRAY_ID) else {
|
||||
logging!(warn, Type::Tray, "Failed to update tray menu: tray not found");
|
||||
return Ok(());
|
||||
};
|
||||
@@ -221,7 +235,10 @@ impl Tray {
|
||||
*tun_mode,
|
||||
tun_mode_available,
|
||||
profiles_preview,
|
||||
is_lightweight_mode,
|
||||
TrayMenuOptions {
|
||||
is_lightweight_mode,
|
||||
include_proxy_groups,
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
@@ -240,23 +257,26 @@ impl Tray {
|
||||
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
|
||||
let Some(tray) = app_handle.tray_by_id("main") else {
|
||||
let Some(tray) = app_handle.tray_by_id(TRAY_ID) else {
|
||||
logging!(warn, Type::Tray, "Failed to update tray icon: tray not found");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let (_is_custom_icon, icon_bytes) = TrayState::get_tray_icon(verge).await;
|
||||
|
||||
logging_error!(
|
||||
Type::Tray,
|
||||
tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?))
|
||||
);
|
||||
let template = {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
verge.tray_icon.as_ref().is_none_or(|v| v == "monochrome")
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
};
|
||||
let icon = Some(tauri::image::Image::from_bytes(&icon_bytes)?);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let is_colorful = verge.tray_icon.as_deref().unwrap_or("monochrome") == "colorful";
|
||||
logging_error!(Type::Tray, tray.set_icon_as_template(!is_colorful));
|
||||
}
|
||||
logging_error!(Type::Tray, tray.set_icon_with_as_template(icon, template));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -314,7 +334,7 @@ impl Tray {
|
||||
current_profile_name
|
||||
);
|
||||
|
||||
let Some(tray) = app_handle.tray_by_id("main") else {
|
||||
let Some(tray) = app_handle.tray_by_id(TRAY_ID) else {
|
||||
logging!(warn, Type::Tray, "Failed to update tray tooltip: tray not found");
|
||||
return Ok(());
|
||||
};
|
||||
@@ -330,7 +350,11 @@ impl Tray {
|
||||
return Ok(());
|
||||
}
|
||||
let verge = Config::verge().await.data_arc();
|
||||
self.update_menu().await?;
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
self.update_menu_internal(app_handle, false).await?;
|
||||
AsyncHandler::spawn(|| async {
|
||||
logging_error!(Type::Tray, Self::global().update_menu().await);
|
||||
});
|
||||
self.update_icon(&verge).await?;
|
||||
#[cfg(target_os = "macos")]
|
||||
self.update_speed_task(verge.enable_tray_speed.unwrap_or(false));
|
||||
@@ -358,13 +382,13 @@ impl Tray {
|
||||
let icon = tauri::image::Image::from_bytes(&icon_bytes)?;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let builder = TrayIconBuilder::with_id("main").icon(icon).icon_as_template(false);
|
||||
let builder = TrayIconBuilder::with_id(TRAY_ID).icon(icon).icon_as_template(false);
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
let show_menu_on_left_click = verge.tray_event.as_ref().is_some_and(|v| v == "tray_menu");
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let mut builder = TrayIconBuilder::with_id("main").icon(icon).icon_as_template(false);
|
||||
let mut builder = TrayIconBuilder::with_id(TRAY_ID).icon(icon).icon_as_template(false);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let is_monochrome = verge.tray_icon.as_ref().is_none_or(|v| v == "monochrome");
|
||||
@@ -399,7 +423,7 @@ impl Tray {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_hotkeys(hotkeys: &Option<Vec<String>>) -> HashMap<String, String> {
|
||||
fn create_hotkeys(hotkeys: &Option<Vec<String>>) -> HashMap<&str, &str> {
|
||||
hotkeys
|
||||
.as_ref()
|
||||
.map(|h| {
|
||||
@@ -412,13 +436,13 @@ fn create_hotkeys(hotkeys: &Option<Vec<String>>) -> HashMap<String, String> {
|
||||
if key.to_uppercase().contains("NUMPAD") {
|
||||
None
|
||||
} else {
|
||||
Some((func.into(), key.into()))
|
||||
Some((func, key))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect::<std::collections::HashMap<String, String>>()
|
||||
.collect::<HashMap<&str, &str>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -582,43 +606,49 @@ async fn create_tray_menu(
|
||||
tun_mode_enabled: bool,
|
||||
tun_mode_available: bool,
|
||||
profiles_preview: Vec<IProfilePreview<'_>>,
|
||||
is_lightweight_mode: bool,
|
||||
options: TrayMenuOptions,
|
||||
) -> Result<tauri::menu::Menu<Wry>> {
|
||||
let current_proxy_mode = mode.unwrap_or("");
|
||||
|
||||
// TODO: should update tray menu again when it was timeout error
|
||||
let proxy_nodes_data = tokio::time::timeout(
|
||||
Duration::from_millis(1000),
|
||||
handle::Handle::mihomo().await.get_proxies(),
|
||||
)
|
||||
.await
|
||||
.map_or(None, |res| res.ok());
|
||||
|
||||
let runtime_proxy_groups_order = cmd::get_runtime_config()
|
||||
let (proxy_nodes_data, runtime_proxy_groups_order) = if options.include_proxy_groups {
|
||||
let proxy_nodes_data = tokio::time::timeout(
|
||||
Duration::from_millis(1000),
|
||||
handle::Handle::mihomo().await.get_proxies(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
logging!(
|
||||
error,
|
||||
Type::Cmd,
|
||||
"Failed to fetch runtime proxy groups for tray menu: {e}"
|
||||
);
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.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())
|
||||
.map(|name| name.into())
|
||||
.collect::<Vec<String>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
.map_or(None, |res| res.ok());
|
||||
|
||||
let runtime_proxy_groups_order = cmd::get_runtime_config()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
logging!(
|
||||
error,
|
||||
Type::Cmd,
|
||||
"Failed to fetch runtime proxy groups for tray menu: {e}"
|
||||
);
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.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())
|
||||
.map(|name| name.into())
|
||||
.collect::<Vec<String>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
(proxy_nodes_data, runtime_proxy_groups_order)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let proxy_group_order_map: Option<HashMap<smartstring::SmartString<smartstring::LazyCompact>, usize>> =
|
||||
runtime_proxy_groups_order.as_ref().map(|group_names| {
|
||||
@@ -655,7 +685,7 @@ async fn create_tray_menu(
|
||||
MenuIds::DASHBOARD,
|
||||
&texts.dashboard,
|
||||
true,
|
||||
hotkeys.get("open_or_close_dashboard").map(|s| s.as_str()),
|
||||
hotkeys.get("open_or_close_dashboard").copied(),
|
||||
)?;
|
||||
|
||||
let rule_mode = &CheckMenuItem::with_id(
|
||||
@@ -664,7 +694,7 @@ async fn create_tray_menu(
|
||||
&texts.rule_mode,
|
||||
true,
|
||||
current_proxy_mode == "rule",
|
||||
hotkeys.get("clash_mode_rule").map(|s| s.as_str()),
|
||||
hotkeys.get("clash_mode_rule").copied(),
|
||||
)?;
|
||||
|
||||
let global_mode = &CheckMenuItem::with_id(
|
||||
@@ -673,7 +703,7 @@ async fn create_tray_menu(
|
||||
&texts.global_mode,
|
||||
true,
|
||||
current_proxy_mode == "global",
|
||||
hotkeys.get("clash_mode_global").map(|s| s.as_str()),
|
||||
hotkeys.get("clash_mode_global").copied(),
|
||||
)?;
|
||||
|
||||
let direct_mode = &CheckMenuItem::with_id(
|
||||
@@ -682,7 +712,7 @@ async fn create_tray_menu(
|
||||
&texts.direct_mode,
|
||||
true,
|
||||
current_proxy_mode == "direct",
|
||||
hotkeys.get("clash_mode_direct").map(|s| s.as_str()),
|
||||
hotkeys.get("clash_mode_direct").copied(),
|
||||
)?;
|
||||
|
||||
let outbound_modes = if show_outbound_modes_inline {
|
||||
@@ -715,13 +745,17 @@ async fn create_tray_menu(
|
||||
&profile_menu_items_refs,
|
||||
)?;
|
||||
|
||||
let proxy_sub_menus =
|
||||
create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data);
|
||||
let (proxies_menu, inline_proxy_items) = if options.include_proxy_groups {
|
||||
let proxy_sub_menus =
|
||||
create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data);
|
||||
|
||||
let (proxies_menu, inline_proxy_items) = match tray_proxy_groups_display_mode {
|
||||
"default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?,
|
||||
"inline" => create_proxy_menu_item(app_handle, true, proxy_sub_menus, &texts.proxies)?,
|
||||
_ => (None, Vec::new()),
|
||||
match tray_proxy_groups_display_mode {
|
||||
"default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?,
|
||||
"inline" => create_proxy_menu_item(app_handle, true, proxy_sub_menus, &texts.proxies)?,
|
||||
_ => (None, Vec::new()),
|
||||
}
|
||||
} else {
|
||||
(None, Vec::new())
|
||||
};
|
||||
|
||||
let system_proxy = &CheckMenuItem::with_id(
|
||||
@@ -730,7 +764,7 @@ async fn create_tray_menu(
|
||||
&texts.system_proxy,
|
||||
true,
|
||||
system_proxy_enabled,
|
||||
hotkeys.get("toggle_system_proxy").map(|s| s.as_str()),
|
||||
hotkeys.get("toggle_system_proxy").copied(),
|
||||
)?;
|
||||
|
||||
let tun_mode = &CheckMenuItem::with_id(
|
||||
@@ -739,7 +773,7 @@ async fn create_tray_menu(
|
||||
&texts.tun_mode,
|
||||
tun_mode_available,
|
||||
tun_mode_enabled,
|
||||
hotkeys.get("toggle_tun_mode").map(|s| s.as_str()),
|
||||
hotkeys.get("toggle_tun_mode").copied(),
|
||||
)?;
|
||||
|
||||
let close_all_connections = &MenuItem::with_id(
|
||||
@@ -755,8 +789,8 @@ async fn create_tray_menu(
|
||||
MenuIds::LIGHTWEIGHT_MODE,
|
||||
&texts.lightweight_mode,
|
||||
true,
|
||||
is_lightweight_mode,
|
||||
hotkeys.get("entry_lightweight_mode").map(|s| s.as_str()),
|
||||
options.is_lightweight_mode,
|
||||
hotkeys.get("entry_lightweight_mode").copied(),
|
||||
)?;
|
||||
|
||||
let copy_env = &MenuItem::with_id(app_handle, MenuIds::COPY_ENV, &texts.copy_env, true, None::<&str>)?;
|
||||
@@ -792,7 +826,7 @@ async fn create_tray_menu(
|
||||
let app_version = &MenuItem::with_id(
|
||||
app_handle,
|
||||
MenuIds::VERGE_VERSION,
|
||||
format!("{} {version}", &texts.verge_version),
|
||||
format!("{} {version}", texts.verge_version),
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
@@ -811,7 +845,7 @@ async fn create_tray_menu(
|
||||
],
|
||||
)?;
|
||||
|
||||
let quit_accelerator = hotkeys.get("quit").map(|s| s.as_str());
|
||||
let quit_accelerator = hotkeys.get("quit").copied();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let quit_accelerator = quit_accelerator.or(Some("Cmd+Q"));
|
||||
@@ -899,7 +933,10 @@ fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) {
|
||||
WindowManager::show_main_window().await;
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
// tray_menu 模式下菜单由系统原生展示(见 set_show_menu_on_left_click),
|
||||
// 左键点击事件无需额外处理
|
||||
TrayAction::TrayMenu => {}
|
||||
TrayAction::Unknown => {
|
||||
logging!(warn, Type::Tray, "invalid tray event: {}", verge_tray_event);
|
||||
}
|
||||
};
|
||||
@@ -922,7 +959,8 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
||||
&& let Some(final_mode) = stripped.strip_suffix("_mode")
|
||||
{
|
||||
logging!(info, Type::ProxyMode, "Switch Proxy Mode To: {}", final_mode);
|
||||
feat::change_clash_mode(final_mode.into()).await;
|
||||
// 错误已在 change_clash_mode 内部记录,此处显式忽略返回值
|
||||
let _ = feat::change_clash_mode(final_mode.into()).await;
|
||||
}
|
||||
}
|
||||
MenuIds::DASHBOARD => {
|
||||
@@ -953,10 +991,10 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
||||
let _ = cmd::open_logs_dir().await;
|
||||
}
|
||||
MenuIds::APP_LOG => {
|
||||
let _ = cmd::open_app_log().await;
|
||||
let _ = help::open_app_latest_log();
|
||||
}
|
||||
MenuIds::CORE_LOG => {
|
||||
let _ = cmd::open_core_log().await;
|
||||
let _ = help::open_core_latest_log();
|
||||
}
|
||||
MenuIds::RESTART_CLASH => feat::restart_clash_core().await,
|
||||
MenuIds::RESTART_APP => feat::restart_app().await,
|
||||
|
||||
@@ -6,7 +6,7 @@ use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::async_runtime::JoinHandle;
|
||||
use tauri_plugin_mihomo::models::WebSocketConnectionId;
|
||||
use tauri_plugin_mihomo::models::ConnectionId;
|
||||
|
||||
/// 托盘速率流异常后的重连间隔。
|
||||
const TRAY_SPEED_RETRY_DELAY: Duration = Duration::from_secs(1);
|
||||
@@ -19,7 +19,7 @@ const TRAY_SPEED_STALE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
#[derive(Clone)]
|
||||
pub struct TraySpeedController {
|
||||
speed_task: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
speed_connection_id: Arc<Mutex<Option<WebSocketConnectionId>>>,
|
||||
speed_connection_id: Arc<Mutex<Option<ConnectionId>>>,
|
||||
}
|
||||
|
||||
impl Default for TraySpeedController {
|
||||
@@ -145,7 +145,7 @@ impl TraySpeedController {
|
||||
});
|
||||
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
if let Some(tray) = app_handle.tray_by_id("main") {
|
||||
if let Some(tray) = app_handle.tray_by_id(super::TRAY_ID) {
|
||||
let result = tray.with_inner_tray_icon(|inner| {
|
||||
if let Some(status_item) = inner.ns_status_item() {
|
||||
tray_speed::clear_speed_attributed_title(&status_item);
|
||||
@@ -158,23 +158,21 @@ impl TraySpeedController {
|
||||
}
|
||||
|
||||
fn has_main_tray() -> bool {
|
||||
handle::Handle::app_handle().tray_by_id("main").is_some()
|
||||
handle::Handle::app_handle().tray_by_id(super::TRAY_ID).is_some()
|
||||
}
|
||||
|
||||
fn set_speed_connection_id(
|
||||
speed_connection_id: &Arc<Mutex<Option<WebSocketConnectionId>>>,
|
||||
connection_id: Option<WebSocketConnectionId>,
|
||||
speed_connection_id: &Arc<Mutex<Option<ConnectionId>>>,
|
||||
connection_id: Option<ConnectionId>,
|
||||
) {
|
||||
*speed_connection_id.lock() = connection_id;
|
||||
}
|
||||
|
||||
fn take_speed_connection_id(
|
||||
speed_connection_id: &Arc<Mutex<Option<WebSocketConnectionId>>>,
|
||||
) -> Option<WebSocketConnectionId> {
|
||||
fn take_speed_connection_id(speed_connection_id: &Arc<Mutex<Option<ConnectionId>>>) -> Option<ConnectionId> {
|
||||
speed_connection_id.lock().take()
|
||||
}
|
||||
|
||||
async fn disconnect_speed_connection(speed_connection_id: &Arc<Mutex<Option<WebSocketConnectionId>>>) {
|
||||
async fn disconnect_speed_connection(speed_connection_id: &Arc<Mutex<Option<ConnectionId>>>) {
|
||||
if let Some(connection_id) = Self::take_speed_connection_id(speed_connection_id) {
|
||||
connections_stream::disconnect_connection(connection_id).await;
|
||||
}
|
||||
@@ -182,7 +180,7 @@ impl TraySpeedController {
|
||||
|
||||
fn apply_tray_speed(up: u64, down: u64) {
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
if let Some(tray) = app_handle.tray_by_id("main") {
|
||||
if let Some(tray) = app_handle.tray_by_id(super::TRAY_ID) {
|
||||
let result = tray.with_inner_tray_icon(move |inner| {
|
||||
if let Some(status_item) = inner.ns_status_item() {
|
||||
tray_speed::set_speed_attributed_title(&status_item, up, down);
|
||||
|
||||
@@ -244,6 +244,14 @@ impl CoreConfigValidator {
|
||||
use boa_engine::{Context, Source};
|
||||
|
||||
let mut context = Context::default();
|
||||
let _ = context.eval(Source::from_bytes(
|
||||
"var console = Object.freeze({
|
||||
log(...data){},
|
||||
info(...data){},
|
||||
error(...data){},
|
||||
debug(...data){},
|
||||
});",
|
||||
));
|
||||
let result = context.eval(Source::from_bytes(&content));
|
||||
|
||||
match result {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use serde_yaml_ng::{Mapping, Value};
|
||||
use smartstring::alias::String;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub const HANDLE_FIELDS: [&str; 12] = [
|
||||
"mode",
|
||||
@@ -19,46 +18,62 @@ pub const HANDLE_FIELDS: [&str; 12] = [
|
||||
|
||||
pub const DEFAULT_FIELDS: [&str; 5] = ["proxies", "proxy-providers", "proxy-groups", "rule-providers", "rules"];
|
||||
|
||||
fn lowercase_key(key: &str) -> Value {
|
||||
let mut key = String::from(key);
|
||||
key.make_ascii_lowercase();
|
||||
Value::from(key.as_str())
|
||||
}
|
||||
|
||||
pub fn use_lowercase(config: &Mapping) -> Mapping {
|
||||
let mut ret = Mapping::new();
|
||||
let mut lowercased = Mapping::new();
|
||||
|
||||
for (key, value) in config.into_iter() {
|
||||
if let Some(key_str) = key.as_str() {
|
||||
let mut key_str = String::from(key_str);
|
||||
key_str.make_ascii_lowercase();
|
||||
ret.insert(Value::from(key_str.as_str()), value.clone());
|
||||
lowercased.insert(lowercase_key(key_str), value.clone());
|
||||
}
|
||||
}
|
||||
ret
|
||||
lowercased
|
||||
}
|
||||
|
||||
pub fn use_sort(config: Mapping) -> Mapping {
|
||||
let mut ret = Mapping::new();
|
||||
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 {
|
||||
let mut sorted = Mapping::new();
|
||||
HANDLE_FIELDS.into_iter().for_each(|key| {
|
||||
let key = Value::from(key);
|
||||
if let Some(value) = config.get(&key) {
|
||||
ret.insert(key, value.clone());
|
||||
if let Some(value) = config.remove(&key) {
|
||||
sorted.insert(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
let supported_keys: HashSet<&str> = HANDLE_FIELDS.into_iter().chain(DEFAULT_FIELDS).collect();
|
||||
|
||||
let config_keys: HashSet<&str> = config.keys().filter_map(|e| e.as_str()).collect();
|
||||
|
||||
config_keys.difference(&supported_keys).for_each(|&key| {
|
||||
let key = Value::from(key);
|
||||
if let Some(value) = config.get(&key) {
|
||||
ret.insert(key, value.clone());
|
||||
let mut default_field_values = Mapping::new();
|
||||
for (key, value) in config {
|
||||
if let Some(key_str) = key.as_str() {
|
||||
if DEFAULT_FIELDS.contains(&key_str) {
|
||||
default_field_values.insert(key, value);
|
||||
} else if !HANDLE_FIELDS.contains(&key_str) {
|
||||
sorted.insert(key, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
DEFAULT_FIELDS.into_iter().for_each(|key| {
|
||||
let key = Value::from(key);
|
||||
if let Some(value) = config.get(&key) {
|
||||
ret.insert(key, value.clone());
|
||||
if let Some(value) = default_field_values.remove(&key) {
|
||||
sorted.insert(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
ret
|
||||
sorted
|
||||
}
|
||||
|
||||
#[inline]
|
||||
|
||||
+498
-141
@@ -14,8 +14,11 @@ use self::{
|
||||
tun::use_tun,
|
||||
};
|
||||
use crate::utils::dirs;
|
||||
use crate::{config::Config, utils::tmpl};
|
||||
use crate::{config::IVerge, constants};
|
||||
use crate::{
|
||||
config::{Config, IVerge, PrfItem},
|
||||
constants,
|
||||
utils::tmpl,
|
||||
};
|
||||
use anyhow::{Context as _, Result};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use serde_yaml_ng::{Mapping, Value};
|
||||
@@ -89,6 +92,14 @@ impl Default for ProfileItems {
|
||||
}
|
||||
}
|
||||
|
||||
async fn chain_item_or_default(item: Option<&PrfItem>, default_item: impl FnOnce() -> ChainItem) -> ChainItem {
|
||||
if let Some(item) = item {
|
||||
<Option<ChainItem>>::from_async(item).await.unwrap_or_else(default_item)
|
||||
} else {
|
||||
default_item()
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_config_values() -> ConfigValues {
|
||||
let clash = Config::clash().await;
|
||||
let clash_arc = clash.latest_arc();
|
||||
@@ -106,7 +117,7 @@ async fn get_config_values() -> ConfigValues {
|
||||
ref verge_http_enabled,
|
||||
ref enable_dns_settings,
|
||||
..
|
||||
} = *verge_arc;
|
||||
} = **verge_arc;
|
||||
|
||||
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
|
||||
Some(verge_arc.get_valid_clash_core()),
|
||||
@@ -184,96 +195,36 @@ async fn collect_profile_items() -> Result<ProfileItems> {
|
||||
|
||||
let name = current_item.name.clone().unwrap_or_default();
|
||||
|
||||
let merge_item = {
|
||||
let item = profiles_arc.get_item(&merge_uid).ok().cloned();
|
||||
if let Some(item) = item {
|
||||
<Option<ChainItem>>::from_async(&item).await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
.unwrap_or_else(|| ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Merge(Mapping::new()),
|
||||
});
|
||||
|
||||
let script_item = {
|
||||
let item = profiles_arc.get_item(&script_uid).ok().cloned();
|
||||
if let Some(item) = item {
|
||||
<Option<ChainItem>>::from_async(&item).await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
.unwrap_or_else(|| ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
|
||||
});
|
||||
|
||||
let rules_item = {
|
||||
let item = profiles_arc.get_item(&rules_uid).ok().cloned();
|
||||
if let Some(item) = item {
|
||||
<Option<ChainItem>>::from_async(&item).await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
.unwrap_or_else(|| ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Rules(SeqMap::default()),
|
||||
});
|
||||
|
||||
let proxies_item = {
|
||||
let item = profiles_arc.get_item(&proxies_uid).ok().cloned();
|
||||
if let Some(item) = item {
|
||||
<Option<ChainItem>>::from_async(&item).await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
.unwrap_or_else(|| ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Proxies(SeqMap::default()),
|
||||
});
|
||||
|
||||
let groups_item = {
|
||||
let item = profiles_arc.get_item(&groups_uid).ok().cloned();
|
||||
if let Some(item) = item {
|
||||
<Option<ChainItem>>::from_async(&item).await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
.unwrap_or_else(|| ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Groups(SeqMap::default()),
|
||||
});
|
||||
|
||||
let global_merge = {
|
||||
let item = profiles_arc.get_item("Merge").ok().cloned();
|
||||
if let Some(item) = item {
|
||||
<Option<ChainItem>>::from_async(&item).await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
.unwrap_or_else(|| ChainItem {
|
||||
uid: "Merge".into(),
|
||||
data: ChainType::Merge(Mapping::new()),
|
||||
});
|
||||
|
||||
let global_script = {
|
||||
let item = profiles_arc.get_item("Script").ok().cloned();
|
||||
if let Some(item) = item {
|
||||
<Option<ChainItem>>::from_async(&item).await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
.unwrap_or_else(|| ChainItem {
|
||||
uid: "Script".into(),
|
||||
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
|
||||
});
|
||||
let (merge_item, script_item, rules_item, proxies_item, groups_item, global_merge, global_script) = tokio::join!(
|
||||
chain_item_or_default(profiles_arc.get_item(&merge_uid).ok(), || ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Merge(Mapping::new()),
|
||||
},),
|
||||
chain_item_or_default(profiles_arc.get_item(&script_uid).ok(), || ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
|
||||
},),
|
||||
chain_item_or_default(profiles_arc.get_item(&rules_uid).ok(), || ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Rules(SeqMap::default()),
|
||||
},),
|
||||
chain_item_or_default(profiles_arc.get_item(&proxies_uid).ok(), || ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Proxies(SeqMap::default()),
|
||||
},),
|
||||
chain_item_or_default(profiles_arc.get_item(&groups_uid).ok(), || ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Groups(SeqMap::default()),
|
||||
},),
|
||||
chain_item_or_default(profiles_arc.get_item("Merge").ok(), || ChainItem {
|
||||
uid: "Merge".into(),
|
||||
data: ChainType::Merge(Mapping::new()),
|
||||
},),
|
||||
chain_item_or_default(profiles_arc.get_item("Script").ok(), || ChainItem {
|
||||
uid: "Script".into(),
|
||||
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
|
||||
},),
|
||||
);
|
||||
|
||||
drop(profiles_arc);
|
||||
|
||||
@@ -292,23 +243,22 @@ async fn collect_profile_items() -> Result<ProfileItems> {
|
||||
|
||||
async fn process_global_items(
|
||||
mut config: Mapping,
|
||||
mut exists_keys: Vec<String>,
|
||||
mut result_map: HashMap<String, ResultLog>,
|
||||
global_merge: ChainItem,
|
||||
global_script: ChainItem,
|
||||
profile_name: &String,
|
||||
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
||||
let mut result_map = HashMap::new();
|
||||
let mut exists_keys = use_keys(&config).collect::<Vec<_>>();
|
||||
|
||||
if let ChainType::Merge(merge) = global_merge.data {
|
||||
exists_keys.extend(use_keys(&merge));
|
||||
config = use_merge(&merge, config.to_owned());
|
||||
config = use_merge(&merge, config);
|
||||
}
|
||||
|
||||
if let ChainType::Script(script) = global_script.data {
|
||||
let mut logs = vec![];
|
||||
match use_script(script, config.clone(), profile_name.clone()).await {
|
||||
Ok((res_config, res_logs)) => {
|
||||
exists_keys.extend(use_keys(&res_config));
|
||||
extend_changed_keys(&mut exists_keys, &config, &res_config);
|
||||
config = res_config;
|
||||
logs.extend(res_logs);
|
||||
}
|
||||
@@ -320,40 +270,164 @@ async fn process_global_items(
|
||||
(config, exists_keys, result_map)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_seq_items(
|
||||
mut config: Mapping,
|
||||
rules_item: ChainItem,
|
||||
proxies_item: ChainItem,
|
||||
groups_item: ChainItem,
|
||||
) -> Mapping {
|
||||
if let ChainType::Rules(rules) = rules_item.data {
|
||||
config = use_seq(rules, config, "rules");
|
||||
}
|
||||
|
||||
if let ChainType::Proxies(proxies) = proxies_item.data {
|
||||
config = use_seq(proxies, config, "proxies");
|
||||
}
|
||||
|
||||
if let ChainType::Groups(groups) = groups_item.data {
|
||||
config = use_seq(groups, config, "proxy-groups");
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
fn extend_changed_keys(exists_keys: &mut Vec<String>, config: &Mapping, res_config: &Mapping) {
|
||||
exists_keys.extend(res_config.iter().filter_map(|(key, value)| {
|
||||
if config.get(key) == Some(value) {
|
||||
return None;
|
||||
}
|
||||
|
||||
key.as_str().map(|key| {
|
||||
let mut key: String = key.into();
|
||||
key.make_ascii_lowercase();
|
||||
key
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
/// App 权威的顶层控制面键:核心连接、监听端口、UI/托盘开关。
|
||||
/// 平台键随 cfg 门控;`dns.ipv6` 单独处理。
|
||||
const CONTROL_PLANE_KEYS: &[&str] = &[
|
||||
"external-controller",
|
||||
#[cfg(unix)]
|
||||
"external-controller-unix",
|
||||
#[cfg(windows)]
|
||||
"external-controller-pipe",
|
||||
"external-controller-cors",
|
||||
"secret",
|
||||
"mixed-port",
|
||||
"socks-port",
|
||||
"port",
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
"redir-port",
|
||||
#[cfg(target_os = "linux")]
|
||||
"tproxy-port",
|
||||
"mode",
|
||||
"allow-lan",
|
||||
"log-level",
|
||||
"ipv6",
|
||||
"unified-delay",
|
||||
];
|
||||
|
||||
/// 手动 merge/script 前保存 app 最终控制面值,只记录当前存在的键。
|
||||
fn snapshot_control_plane(config: &Mapping) -> Mapping {
|
||||
let mut snapshot = Mapping::new();
|
||||
for &key in CONTROL_PLANE_KEYS {
|
||||
let key = Value::from(key);
|
||||
if let Some(value) = config.get(&key) {
|
||||
snapshot.insert(key, value.clone());
|
||||
}
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// 手动覆盖后恢复控制面快照;快照缺失的控制面键从最终配置删除。
|
||||
fn enforce_control_plane(mut config: Mapping, snapshot: Mapping) -> Mapping {
|
||||
for &key in CONTROL_PLANE_KEYS {
|
||||
let key = Value::from(key);
|
||||
if !snapshot.contains_key(&key) {
|
||||
config.remove(&key);
|
||||
}
|
||||
}
|
||||
config.extend(snapshot);
|
||||
config
|
||||
}
|
||||
|
||||
/// DNS 页权威的嵌套开关;只在 `enable_dns_settings` 时快照。
|
||||
fn snapshot_dns_ipv6(config: &Mapping) -> Option<Value> {
|
||||
config.get("dns")?.get("ipv6").cloned()
|
||||
}
|
||||
|
||||
/// 恢复 `dns.ipv6`,但不创建缺失的 `dns` 块。
|
||||
fn enforce_dns_ipv6(mut config: Mapping, dns_ipv6: Option<Value>) -> Mapping {
|
||||
if let Some(dns_ipv6) = dns_ipv6
|
||||
&& let Some(Value::Mapping(dns)) = config.get_mut("dns")
|
||||
{
|
||||
dns.insert(Value::from("ipv6"), dns_ipv6);
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
fn is_loopback_bind_address(addr: &str) -> bool {
|
||||
let addr = addr.trim();
|
||||
let addr = addr
|
||||
.strip_prefix('[')
|
||||
.and_then(|addr| addr.strip_suffix(']'))
|
||||
.unwrap_or(addr);
|
||||
|
||||
addr.eq_ignore_ascii_case("localhost")
|
||||
|| addr.parse::<std::net::IpAddr>().is_ok_and(|addr| addr.is_loopback())
|
||||
|| is_ipv4_shorthand_loopback(addr)
|
||||
}
|
||||
|
||||
fn is_ipv4_shorthand_loopback(addr: &str) -> bool {
|
||||
let parts = addr.split('.').map(str::parse::<u32>).collect::<Result<Vec<_>, _>>();
|
||||
|
||||
let Ok(parts) = parts else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match parts.as_slice() {
|
||||
[first, rest] => *first == 127 && *rest <= 0x00ff_ffff,
|
||||
[first, second, rest] => *first == 127 && *second <= 0xff && *rest <= 0xffff,
|
||||
[first, second, third, fourth] => *first == 127 && *second <= 0xff && *third <= 0xff && *fourth <= 0xff,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_lan_bind_address(mut config: Mapping) -> Mapping {
|
||||
let allow_lan = config.get("allow-lan").and_then(Value::as_bool).unwrap_or(false);
|
||||
|
||||
if allow_lan
|
||||
&& config
|
||||
.get("bind-address")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(is_loopback_bind_address)
|
||||
{
|
||||
config.insert(Value::from("bind-address"), Value::from("*"));
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
async fn process_profile_items(
|
||||
mut config: Mapping,
|
||||
mut exists_keys: Vec<String>,
|
||||
mut result_map: HashMap<String, ResultLog>,
|
||||
rules_item: ChainItem,
|
||||
proxies_item: ChainItem,
|
||||
groups_item: ChainItem,
|
||||
merge_item: ChainItem,
|
||||
script_item: ChainItem,
|
||||
profile_name: &String,
|
||||
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
||||
if let ChainType::Rules(rules) = rules_item.data {
|
||||
config = use_seq(rules, config.to_owned(), "rules");
|
||||
}
|
||||
|
||||
if let ChainType::Proxies(proxies) = proxies_item.data {
|
||||
config = use_seq(proxies, config.to_owned(), "proxies");
|
||||
}
|
||||
|
||||
if let ChainType::Groups(groups) = groups_item.data {
|
||||
config = use_seq(groups, config.to_owned(), "proxy-groups");
|
||||
}
|
||||
|
||||
if let ChainType::Merge(merge) = merge_item.data {
|
||||
exists_keys.extend(use_keys(&merge));
|
||||
config = use_merge(&merge, config.to_owned());
|
||||
config = use_merge(&merge, config);
|
||||
}
|
||||
|
||||
if let ChainType::Script(script) = script_item.data {
|
||||
let mut logs = vec![];
|
||||
match use_script(script, config.clone(), profile_name.clone()).await {
|
||||
Ok((res_config, res_logs)) => {
|
||||
exists_keys.extend(use_keys(&res_config));
|
||||
extend_changed_keys(&mut exists_keys, &config, &res_config);
|
||||
config = res_config;
|
||||
logs.extend(res_logs);
|
||||
}
|
||||
@@ -547,6 +621,31 @@ fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
|
||||
config
|
||||
}
|
||||
|
||||
/// 当 DNS 处于 fake-ip 模式且启用 IPv6 时,补充缺失的 `fake-ip-range6`,
|
||||
/// 否则 AAAA 查询无法获得 fake-ip,导致 IPv6 解析失败(见 issue #7373)。
|
||||
/// 兼容旧版本生成的、缺少该字段的 dns_config.yaml。
|
||||
fn ensure_fake_ip_range6(dns: &mut Mapping) {
|
||||
use serde_yaml_ng::Value;
|
||||
|
||||
let ipv6_enabled = dns.get("ipv6").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let is_fake_ip = dns
|
||||
.get("enhanced-mode")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|m| m == "fake-ip")
|
||||
.unwrap_or(true);
|
||||
|
||||
// 缺失或为空字符串(可能来自手动编辑的 YAML)时都需要补充
|
||||
let range6_missing = dns
|
||||
.get("fake-ip-range6")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().is_empty())
|
||||
.unwrap_or(true);
|
||||
|
||||
if ipv6_enabled && is_fake_ip && range6_missing {
|
||||
dns.insert(Value::from("fake-ip-range6"), Value::from("fdfe:dcba:9876::1/64"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> Mapping {
|
||||
if enable_dns_settings && let Ok(app_dir) = dirs::app_home_dir() {
|
||||
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
|
||||
@@ -564,10 +663,14 @@ async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> M
|
||||
|
||||
if let Some(dns_value) = dns_config.get("dns") {
|
||||
if let Some(dns_mapping) = dns_value.as_mapping() {
|
||||
config.insert("dns".into(), dns_mapping.clone().into());
|
||||
let mut dns_mapping = dns_mapping.clone();
|
||||
ensure_fake_ip_range6(&mut dns_mapping);
|
||||
config.insert("dns".into(), dns_mapping.into());
|
||||
logging!(info, Type::Core, "apply dns_config.yaml (dns section)");
|
||||
}
|
||||
} else {
|
||||
let mut dns_config = dns_config;
|
||||
ensure_fake_ip_range6(&mut dns_config);
|
||||
config.insert("dns".into(), dns_config.into());
|
||||
logging!(info, Type::Core, "apply dns_config.yaml");
|
||||
}
|
||||
@@ -608,23 +711,11 @@ pub async fn enhance() -> Result<(Mapping, HashSet<String>, HashMap<String, Resu
|
||||
let global_script = profile.global_script;
|
||||
let profile_name = profile.profile_name;
|
||||
|
||||
// process globals
|
||||
let (config, exists_keys, result_map) =
|
||||
process_global_items(config, global_merge, global_script, &profile_name).await;
|
||||
let result_map = HashMap::new();
|
||||
|
||||
// process profile-specific items
|
||||
let (config, exists_keys, result_map) = process_profile_items(
|
||||
config,
|
||||
exists_keys,
|
||||
result_map,
|
||||
rules_item,
|
||||
proxies_item,
|
||||
groups_item,
|
||||
merge_item,
|
||||
script_item,
|
||||
&profile_name,
|
||||
)
|
||||
.await;
|
||||
// 顺序项先于手动覆盖。
|
||||
let config = process_seq_items(config, rules_item, proxies_item, groups_item);
|
||||
let exists_keys = use_keys(&config).collect::<Vec<_>>();
|
||||
|
||||
// merge default clash config
|
||||
let config = merge_default_config(
|
||||
@@ -639,16 +730,42 @@ pub async fn enhance() -> Result<(Mapping, HashSet<String>, HashMap<String, Resu
|
||||
)
|
||||
.await;
|
||||
|
||||
// builtin scripts
|
||||
let mut config = apply_builtin_scripts(config, clash_core, enable_builtin).await;
|
||||
// app 生成项先于手动覆盖。
|
||||
let config = apply_builtin_scripts(config, clash_core, enable_builtin).await;
|
||||
let config = use_tun(config, enable_tun);
|
||||
let config = apply_dns_settings(config, enable_dns_settings).await;
|
||||
|
||||
config = cleanup_proxy_groups(config);
|
||||
// 手动覆盖前锁定 app 权威字段。
|
||||
let control_plane = snapshot_control_plane(&config);
|
||||
// DNS 页开启时,仅 `dns.ipv6` 跟随 UI;其余 DNS 字段仍可覆盖。
|
||||
let dns_ipv6 = if enable_dns_settings {
|
||||
snapshot_dns_ipv6(&config)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
config = use_tun(config, enable_tun);
|
||||
config = use_sort(config);
|
||||
// 全局手动覆盖。
|
||||
let (config, exists_keys, result_map) = process_global_items(
|
||||
config,
|
||||
exists_keys,
|
||||
result_map,
|
||||
global_merge,
|
||||
global_script,
|
||||
&profile_name,
|
||||
)
|
||||
.await;
|
||||
|
||||
// dns settings
|
||||
config = apply_dns_settings(config, enable_dns_settings).await;
|
||||
// 当前 profile 手动覆盖。
|
||||
let (config, exists_keys, result_map) =
|
||||
process_profile_items(config, exists_keys, result_map, merge_item, script_item, &profile_name).await;
|
||||
|
||||
// 手动覆盖后恢复 app 权威字段。
|
||||
let config = enforce_control_plane(config, control_plane);
|
||||
let config = enforce_dns_ipv6(config, dns_ipv6);
|
||||
let config = ensure_lan_bind_address(config);
|
||||
|
||||
let config = cleanup_proxy_groups(config);
|
||||
let config = use_sort(config);
|
||||
|
||||
let mut exists_keys_set = HashSet::new();
|
||||
exists_keys_set.extend(exists_keys);
|
||||
@@ -659,7 +776,247 @@ pub async fn enhance() -> Result<(Mapping, HashSet<String>, HashMap<String, Resu
|
||||
#[allow(clippy::expect_used)]
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::cleanup_proxy_groups;
|
||||
use super::{
|
||||
ChainItem, ChainType, cleanup_proxy_groups, ensure_lan_bind_address, process_global_items,
|
||||
process_profile_items, use_keys,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn mapping(yaml: &str) -> serde_yaml_ng::Mapping {
|
||||
serde_yaml_ng::from_str(yaml).expect("test config should be valid")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_overrides_follow_expected_priority() {
|
||||
let mut config = mapping(
|
||||
r"{global-merge-wins: other, global-script-wins: other, profile-merge-wins: other,
|
||||
profile-script-wins: other, nested: {winner: other}, dns: {enable: true}, tun: {enable: true}}",
|
||||
);
|
||||
let exists_keys = use_keys(&config).collect();
|
||||
config.insert("application-only".into(), true.into());
|
||||
|
||||
let global_merge = ChainItem {
|
||||
uid: "Merge".into(),
|
||||
data: ChainType::Merge(mapping(
|
||||
r"{global-merge-wins: global-merge, global-script-wins: global-merge,
|
||||
profile-merge-wins: global-merge, profile-script-wins: global-merge,
|
||||
nested: {winner: global-merge}, dns: {enable: false}, tun: {enable: false}}",
|
||||
)),
|
||||
};
|
||||
let global_script = ChainItem::to_script(
|
||||
"Script",
|
||||
r#"function main(config) {
|
||||
config["global-script-wins"] = "global-script";
|
||||
config["profile-merge-wins"] = "global-script";
|
||||
config["profile-script-wins"] = "global-script";
|
||||
config.nested.winner = "global-script";
|
||||
return config;
|
||||
}"#,
|
||||
);
|
||||
let profile_merge = ChainItem {
|
||||
uid: "profile-merge".into(),
|
||||
data: ChainType::Merge(mapping(
|
||||
r"{profile-merge-wins: profile-merge, profile-script-wins: profile-merge,
|
||||
nested: {winner: profile-merge}}",
|
||||
)),
|
||||
};
|
||||
let profile_script = ChainItem::to_script(
|
||||
"profile-script",
|
||||
r#"function main(config) {
|
||||
config["profile-script-wins"] = "profile-script";
|
||||
config.nested.winner = "profile-script";
|
||||
return config;
|
||||
}"#,
|
||||
);
|
||||
|
||||
let profile_name = "test-profile".into();
|
||||
let (config, exists_keys, result_map) = process_global_items(
|
||||
config,
|
||||
exists_keys,
|
||||
HashMap::new(),
|
||||
global_merge,
|
||||
global_script,
|
||||
&profile_name,
|
||||
)
|
||||
.await;
|
||||
let (config, exists_keys, _) = process_profile_items(
|
||||
config,
|
||||
exists_keys,
|
||||
result_map,
|
||||
profile_merge,
|
||||
profile_script,
|
||||
&profile_name,
|
||||
)
|
||||
.await;
|
||||
|
||||
let string_value = |key| config.get(key).and_then(serde_yaml_ng::Value::as_str);
|
||||
assert_eq!(string_value("global-merge-wins"), Some("global-merge"));
|
||||
assert_eq!(string_value("global-script-wins"), Some("global-script"));
|
||||
assert_eq!(string_value("profile-merge-wins"), Some("profile-merge"));
|
||||
assert_eq!(string_value("profile-script-wins"), Some("profile-script"));
|
||||
assert_eq!(
|
||||
config
|
||||
.get("nested")
|
||||
.and_then(|value| value.get("winner"))
|
||||
.and_then(serde_yaml_ng::Value::as_str),
|
||||
Some("profile-script")
|
||||
);
|
||||
assert!(!exists_keys.contains(&"application-only".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_plane_survives_manual_overrides() {
|
||||
let app_config = mapping(
|
||||
r#"{external-controller: "",
|
||||
external-controller-cors: {allow-origins: ["app-only"]},
|
||||
mixed-port: 7890, socks-port: 7891, secret: "app-secret", mode: rule, allow-lan: false,
|
||||
log-level: info, ipv6: false, unified-delay: true,
|
||||
dns: {proxy-server-nameserver: ["1.1.1.1"]}}"#,
|
||||
);
|
||||
let snapshot = super::snapshot_control_plane(&app_config);
|
||||
|
||||
let hijacked = mapping(
|
||||
r#"{external-controller: "0.0.0.0:9090",
|
||||
external-controller-cors: {allow-origins: ["*"]},
|
||||
mixed-port: 1080, socks-port: 1080, secret: "hijacked", mode: global, allow-lan: true,
|
||||
log-level: debug, ipv6: true, unified-delay: false,
|
||||
dns: {proxy-server-nameserver: ["8.8.8.8"]}}"#,
|
||||
);
|
||||
|
||||
let result = super::enforce_control_plane(hijacked, snapshot);
|
||||
|
||||
let as_str = |key| result.get(key).and_then(serde_yaml_ng::Value::as_str);
|
||||
assert_eq!(as_str("external-controller"), Some(""));
|
||||
assert_eq!(
|
||||
result.get("mixed-port").and_then(serde_yaml_ng::Value::as_u64),
|
||||
Some(7890)
|
||||
);
|
||||
assert_eq!(
|
||||
result.get("socks-port").and_then(serde_yaml_ng::Value::as_u64),
|
||||
Some(7891)
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
.get("external-controller-cors")
|
||||
.and_then(|value| value.get("allow-origins"))
|
||||
.and_then(serde_yaml_ng::Value::as_sequence)
|
||||
.and_then(|seq| seq.first())
|
||||
.and_then(serde_yaml_ng::Value::as_str),
|
||||
Some("app-only")
|
||||
);
|
||||
assert_eq!(as_str("secret"), Some("app-secret"));
|
||||
assert_eq!(as_str("mode"), Some("rule"));
|
||||
assert_eq!(
|
||||
result.get("allow-lan").and_then(serde_yaml_ng::Value::as_bool),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(as_str("log-level"), Some("info"));
|
||||
assert_eq!(result.get("ipv6").and_then(serde_yaml_ng::Value::as_bool), Some(false));
|
||||
assert_eq!(
|
||||
result.get("unified-delay").and_then(serde_yaml_ng::Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
|
||||
// DNS 数据面不属于顶层控制面。
|
||||
assert_eq!(
|
||||
result
|
||||
.get("dns")
|
||||
.and_then(|value| value.get("proxy-server-nameserver"))
|
||||
.and_then(serde_yaml_ng::Value::as_sequence)
|
||||
.and_then(|seq| seq.first())
|
||||
.and_then(serde_yaml_ng::Value::as_str),
|
||||
Some("8.8.8.8")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_bind_address_loopback_is_widened() {
|
||||
for bind_address in [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"127.0.0.2",
|
||||
"127.1",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"0:0:0:0:0:0:0:1",
|
||||
] {
|
||||
let result = ensure_lan_bind_address(mapping(&format!(
|
||||
r#"{{allow-lan: true, bind-address: "{bind_address}"}}"#
|
||||
)));
|
||||
|
||||
assert_eq!(
|
||||
result.get("bind-address").and_then(serde_yaml_ng::Value::as_str),
|
||||
Some("*"),
|
||||
"bind-address {bind_address} should be widened"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_bind_address_preserves_custom_or_disabled() {
|
||||
let custom = ensure_lan_bind_address(mapping(r#"{allow-lan: true, bind-address: "192.168.1.2"}"#));
|
||||
assert_eq!(
|
||||
custom.get("bind-address").and_then(serde_yaml_ng::Value::as_str),
|
||||
Some("192.168.1.2")
|
||||
);
|
||||
|
||||
let disabled = ensure_lan_bind_address(mapping(r#"{allow-lan: false, bind-address: "127.0.0.1"}"#));
|
||||
assert_eq!(
|
||||
disabled.get("bind-address").and_then(serde_yaml_ng::Value::as_str),
|
||||
Some("127.0.0.1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_plane_removes_reenabled_disabled_port() {
|
||||
let app_config = mapping(r"{mixed-port: 7890, mode: rule}");
|
||||
let snapshot = super::snapshot_control_plane(&app_config);
|
||||
|
||||
let hijacked = mapping(r"{mixed-port: 7890, mode: rule, socks-port: 1080}");
|
||||
let result = super::enforce_control_plane(hijacked, snapshot);
|
||||
|
||||
assert!(!result.contains_key("socks-port"));
|
||||
assert_eq!(
|
||||
result.get("mixed-port").and_then(serde_yaml_ng::Value::as_u64),
|
||||
Some(7890)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dns_ipv6_follows_ui_but_other_dns_stays_overridable() {
|
||||
let app_config = mapping(r#"{dns: {ipv6: false, proxy-server-nameserver: ["1.1.1.1"]}}"#);
|
||||
let dns_ipv6 = super::snapshot_dns_ipv6(&app_config);
|
||||
|
||||
let hijacked = mapping(r#"{dns: {ipv6: true, proxy-server-nameserver: ["8.8.8.8"]}}"#);
|
||||
let result = super::enforce_dns_ipv6(hijacked, dns_ipv6);
|
||||
|
||||
assert_eq!(
|
||||
result
|
||||
.get("dns")
|
||||
.and_then(|value| value.get("ipv6"))
|
||||
.and_then(serde_yaml_ng::Value::as_bool),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
.get("dns")
|
||||
.and_then(|value| value.get("proxy-server-nameserver"))
|
||||
.and_then(serde_yaml_ng::Value::as_sequence)
|
||||
.and_then(|seq| seq.first())
|
||||
.and_then(serde_yaml_ng::Value::as_str),
|
||||
Some("8.8.8.8")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_control_plane_skips_absent_keys() {
|
||||
let app_config = mapping(r"{mode: rule, mixed-port: 7890}");
|
||||
let snapshot = super::snapshot_control_plane(&app_config);
|
||||
assert!(snapshot.contains_key("mode"));
|
||||
assert!(snapshot.contains_key("mixed-port"));
|
||||
assert!(!snapshot.contains_key("secret"));
|
||||
assert!(!snapshot.contains_key("allow-lan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_missing_proxies_from_groups() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::process::AsyncHandler;
|
||||
|
||||
use super::use_lowercase;
|
||||
use super::field::{use_lowercase, use_lowercase_owned};
|
||||
use anyhow::{Error, Result};
|
||||
use boa_engine::{Context, JsString, JsValue, Source, native_function::NativeFunction};
|
||||
use clash_verge_logging::{Type, logging_error};
|
||||
@@ -126,7 +126,7 @@ fn use_script_sync(script: String, config: &Mapping, name: &String) -> Result<(M
|
||||
let res: Result<Mapping, Error> = parse_json_safely(&result);
|
||||
|
||||
match res {
|
||||
Ok(config) => Ok((use_lowercase(&config), outputs.lock().to_vec())),
|
||||
Ok(config) => Ok((use_lowercase_owned(config), outputs.lock().to_vec())),
|
||||
Err(err) => {
|
||||
outputs
|
||||
.lock()
|
||||
|
||||
@@ -49,13 +49,13 @@ pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let mut new_seq = Sequence::new();
|
||||
new_seq.extend(prepend);
|
||||
let mut updated_items = Sequence::new();
|
||||
updated_items.extend(prepend);
|
||||
|
||||
if let Some(Value::Sequence(origin)) = config.get(field) {
|
||||
if let Some(Value::Sequence(existing_items)) = config.remove(field) {
|
||||
// Filter out deleted items
|
||||
let filtered: Sequence = origin
|
||||
.iter()
|
||||
let kept_items: Sequence = existing_items
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
if let Value::String(s) = item {
|
||||
!delete.contains(s)
|
||||
@@ -69,25 +69,34 @@ pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
|
||||
true
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
new_seq.extend(filtered);
|
||||
updated_items.extend(kept_items);
|
||||
}
|
||||
|
||||
new_seq.extend(append);
|
||||
config.insert(Value::String(field.into()), Value::Sequence(new_seq));
|
||||
updated_items.extend(append);
|
||||
config.insert(Value::String(field.into()), Value::Sequence(updated_items));
|
||||
|
||||
// If this is proxies field, we also need to filter proxy-groups
|
||||
if field == "proxies"
|
||||
&& let Some(Value::Sequence(groups)) = config.get_mut("proxy-groups")
|
||||
{
|
||||
let mut new_groups = Sequence::new();
|
||||
let mut appended_to_selector = false;
|
||||
for group in groups {
|
||||
if let Value::Mapping(group_map) = group {
|
||||
let mut proxies_seq = group_map.get("proxies").and_then(Value::as_sequence).map(|proxies| {
|
||||
if field != "proxies" {
|
||||
return config;
|
||||
}
|
||||
|
||||
let Some(proxy_groups_value) = config.remove("proxy-groups") else {
|
||||
return config;
|
||||
};
|
||||
|
||||
let Value::Sequence(proxy_groups) = proxy_groups_value else {
|
||||
config.insert(Value::String("proxy-groups".into()), proxy_groups_value);
|
||||
return config;
|
||||
};
|
||||
|
||||
let mut updated_groups = Sequence::new();
|
||||
let mut appended_to_selector = false;
|
||||
for group in proxy_groups {
|
||||
if let Value::Mapping(mut group_map) = group {
|
||||
let mut group_proxies = match group_map.remove("proxies") {
|
||||
Some(Value::Sequence(proxies)) => Some(
|
||||
proxies
|
||||
.iter()
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
if let Value::String(name) = p {
|
||||
!delete.contains(name)
|
||||
@@ -95,42 +104,46 @@ pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
|
||||
true
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Sequence>()
|
||||
});
|
||||
|
||||
if !appended_to_selector && !added_proxy_names.is_empty() && is_selector_group(group_map) {
|
||||
let base_seq = proxies_seq.unwrap_or_else(Sequence::new);
|
||||
let mut seq = Sequence::new();
|
||||
let mut existing = HashSet::new();
|
||||
for name in &added_proxy_names {
|
||||
if existing.insert(name.clone()) {
|
||||
seq.push(Value::String(name.clone()));
|
||||
}
|
||||
}
|
||||
for value in base_seq {
|
||||
if let Value::String(name) = &value
|
||||
&& !existing.insert(name.to_owned())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
seq.push(value);
|
||||
}
|
||||
proxies_seq = Some(seq);
|
||||
appended_to_selector = true;
|
||||
.collect::<Sequence>(),
|
||||
),
|
||||
Some(value) => {
|
||||
group_map.insert(Value::String("proxies".into()), value);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
if let Some(seq) = proxies_seq {
|
||||
group_map.insert(Value::String("proxies".into()), Value::Sequence(seq));
|
||||
if !appended_to_selector && !added_proxy_names.is_empty() && is_selector_group(&group_map) {
|
||||
let base_group_proxies = group_proxies.unwrap_or_else(Sequence::new);
|
||||
let mut merged_proxies = Sequence::new();
|
||||
let mut seen_proxy_names = HashSet::new();
|
||||
for name in &added_proxy_names {
|
||||
if seen_proxy_names.insert(name.clone()) {
|
||||
merged_proxies.push(Value::String(name.clone()));
|
||||
}
|
||||
}
|
||||
new_groups.push(Value::Mapping(group_map.to_owned()));
|
||||
} else {
|
||||
new_groups.push(group.to_owned());
|
||||
for value in base_group_proxies {
|
||||
if let Value::String(name) = &value
|
||||
&& !seen_proxy_names.insert(name.to_owned())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
merged_proxies.push(value);
|
||||
}
|
||||
group_proxies = Some(merged_proxies);
|
||||
appended_to_selector = true;
|
||||
}
|
||||
|
||||
if let Some(group_proxies) = group_proxies {
|
||||
group_map.insert(Value::String("proxies".into()), Value::Sequence(group_proxies));
|
||||
}
|
||||
updated_groups.push(Value::Mapping(group_map));
|
||||
} else {
|
||||
updated_groups.push(group);
|
||||
}
|
||||
config.insert(Value::String("proxy-groups".into()), Value::Sequence(new_groups));
|
||||
}
|
||||
config.insert(Value::String("proxy-groups".into()), Value::Sequence(updated_groups));
|
||||
|
||||
config
|
||||
}
|
||||
@@ -288,4 +301,27 @@ proxy-groups:
|
||||
let names: Vec<&str> = group2_proxies.iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(names, vec!["proxy1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
#[allow(clippy::expect_used)]
|
||||
fn test_preserve_non_sequence_proxy_groups() {
|
||||
let config_str = r#"
|
||||
proxies:
|
||||
- name: "proxy1"
|
||||
type: "ss"
|
||||
proxy-groups: "invalid"
|
||||
"#;
|
||||
let mut config: Mapping = serde_yaml_ng::from_str(config_str).expect("Failed to parse test config YAML");
|
||||
|
||||
let seq = SeqMap {
|
||||
prepend: Sequence::new(),
|
||||
append: Sequence::new(),
|
||||
delete: vec!["proxy1".to_string()],
|
||||
};
|
||||
|
||||
config = use_seq(seq, config, "proxies");
|
||||
|
||||
assert_eq!(config.get("proxy-groups").and_then(Value::as_str), Some("invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,11 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
|
||||
revise!(dns_val, "fake-ip-range", "198.18.0.1/16");
|
||||
}
|
||||
|
||||
// 当启用 IPv6 时,补充 IPv6 的 fake-ip 范围
|
||||
if ipv6_val && !dns_val.contains_key(Value::from("fake-ip-range6")) {
|
||||
revise!(dns_val, "fake-ip-range6", "fdfe:dcba:9876::1/64");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
AsyncHandler::spawn(move || async move {
|
||||
|
||||
+26
-21
@@ -80,7 +80,10 @@ fn after_change_clash_mode() {
|
||||
}
|
||||
|
||||
/// Change Clash mode (rule/global/direct/script)
|
||||
pub async fn change_clash_mode(mode: String) {
|
||||
///
|
||||
/// mihomo `/configs` PATCH 失败时返回 `Err`,以便命令层把失败上抛给前端。
|
||||
/// (此前该函数吞掉错误并始终视为成功,导致 UI 误判"切换成功"、看似"切不动"。)
|
||||
pub async fn change_clash_mode(mode: String) -> Result<(), String> {
|
||||
let mut mapping = Mapping::new();
|
||||
mapping.insert(Value::from("mode"), Value::from(mode.as_str()));
|
||||
// Convert YAML mapping to JSON Value
|
||||
@@ -88,27 +91,29 @@ pub async fn change_clash_mode(mode: String) {
|
||||
"mode": mode
|
||||
});
|
||||
logging!(debug, Type::Core, "change clash mode to {mode}");
|
||||
match handle::Handle::mihomo().await.patch_base_config(&json_value).await {
|
||||
Ok(_) => {
|
||||
// 更新订阅
|
||||
let clash = Config::clash().await;
|
||||
clash.edit_draft(|d| d.patch_config(&mapping));
|
||||
clash.apply();
|
||||
|
||||
// 分离数据获取和异步调用
|
||||
let clash_data = clash.data_arc();
|
||||
if clash_data.save_config().await.is_ok() {
|
||||
handle::Handle::refresh_clash();
|
||||
tray::Tray::global().update_menu_and_icon().await;
|
||||
}
|
||||
|
||||
let is_auto_close_connection = Config::verge().await.data_arc().auto_close_connection.unwrap_or(false);
|
||||
if is_auto_close_connection {
|
||||
after_change_clash_mode();
|
||||
}
|
||||
}
|
||||
Err(err) => logging!(error, Type::Core, "{err}"),
|
||||
if let Err(err) = handle::Handle::mihomo().await.patch_base_config(&json_value).await {
|
||||
logging!(error, Type::Core, "{err}");
|
||||
return Err(err.to_string().into());
|
||||
}
|
||||
|
||||
// 更新订阅
|
||||
let clash = Config::clash().await;
|
||||
clash.edit_draft(|d| d.patch_config(&mapping));
|
||||
clash.apply();
|
||||
|
||||
// 分离数据获取和异步调用
|
||||
let clash_data = clash.data_arc();
|
||||
if clash_data.save_config().await.is_ok() {
|
||||
handle::Handle::refresh_clash();
|
||||
tray::Tray::global().update_menu_and_icon().await;
|
||||
}
|
||||
|
||||
let is_auto_close_connection = Config::verge().await.data_arc().auto_close_connection.unwrap_or(false);
|
||||
if is_auto_close_connection {
|
||||
after_change_clash_mode();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test delay to a URL through proxy.
|
||||
|
||||
@@ -18,6 +18,8 @@ pub async fn patch_clash(patch: &Mapping) -> Result<()> {
|
||||
if patch.get("secret").is_some() || patch.get("external-controller").is_some() {
|
||||
Config::generate().await?;
|
||||
CoreManager::global().restart_core().await?;
|
||||
} else if patch.get("allow-lan").is_some() {
|
||||
CoreManager::global().update_config_checked().await?;
|
||||
} else {
|
||||
if patch.get("mode").is_some() {
|
||||
tray::Tray::global().update_menu_and_icon().await;
|
||||
@@ -210,9 +212,6 @@ async fn process_terminated_flags(update_flags: UpdateFlags, patch: &IVerge) ->
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::VERGE_CONFIG) {
|
||||
Config::verge()
|
||||
.await
|
||||
.edit_draft(|d| d.enable_global_hotkey = patch.enable_global_hotkey);
|
||||
handle::Handle::refresh_verge();
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::LAUNCH) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user