Compare commits

...
17 Commits
Author SHA1 Message Date
c6b51171a1 fix(backup): keep WebDAV password masked (#8291)
* fix(backup): keep WebDAV password masked

* fix(eslint): Clean unused imports

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: susu-Sugar <214122492+susu-Sugar@users.noreply.github.com>
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-14 16:52:11 +00:00
Tunglies d4287970bc perf(tray): cache speed-text attributes and derive width arithmetically
Every traffic refresh rebuilt the attributed-string dictionary (font,
color, paragraph style, baseline number, dict) and ran a CoreText layout
via NSAttributedString::size() just to size the status item, although
both depend only on the button height and the glyph advance.

The dictionary and the monospaced per-glyph advance (measured once
through the same layout engine with a "0" probe) are now cached keyed by
button height, and the width is computed as longest-line length times
the advance. The speed charset is pure ASCII on a monospaced font, so
the arithmetic width matched the layout width exactly (max deviation
0.000pt over 2000 realistic samples).

Standalone A/B over 43,200 updates (12h at 1 Hz), same hot path:

| variant | mean | p95 | phys_footprint after |
| --- | ---: | ---: | ---: |
| rebuild + layout (old) | 6.89us | 7.8us | 19.8MB |
| cached attrs | 4.71us | 5.0us | 7.8MB |
| cached + arithmetic width | 0.27us | 0.3us | 6.0MB |

The retained-memory delta is malloc-zone fragmentation from the churn,
which this removes at the source.
2026-10-14 12:01:44 +08:00
Tunglies b20fdc9b96 perf(runtime): cap tokio workers at 4
The cap of 8 was a conservative pick from 8ccb0b9a; that commit's own
setup benchmark had the 4-worker / 8-blocking row as the best config:
639.3ms avg / 645.5ms max setup time and 22.1 avg threads, ahead of the
adopted 8-worker row (650.1ms / 662.4ms, 26.0 threads).

Startup parallelism is unaffected in practice: the setup join! polls
cooperatively on one task, and CPU work (validation, script eval, YAML
parse) runs on the spawn_blocking pool, which keeps its 8-thread cap
through the existing 2x invariant. A 12h live session showed the 8 idle
workers accumulating under 0.5s CPU total, so four parked stacks
(~0.4MB dirty) are pure overhead.
2026-10-13 21:35:03 +08:00
Tunglies 5b10331022 feat!: remove signal crate for macOS and windows (#7827)
* feat!: remove signal crate

* fix: clippy

* feat(signal): limit the shutdown signal crate to Linux

Upstream now covers termination and session-end events on Windows and
macOS, but Linux has no equivalent, so pre-shutdown cleanup there still
depends on listening for SIGTERM/SIGINT/SIGHUP (#7827). Restore
clash-verge-signal behind a target_os = "linux" gate instead of dropping
it: the dependency lives in a linux-only target section, the crate root
carries an English Linux-only rationale and compiles to nothing
elsewhere, and the Windows backend is gone.

register() now takes the quit flow's unit future: the latch serializes
in-flight cleanups and releases after each attempt, leaving the
exit-or-cancel decision to the quit flow itself.

Verified with cargo check and clippy for aarch64-unknown-linux-gnu, and
cargo check on the host where the crate compiles empty.
2026-10-13 12:33:22 +00:00
Tunglies 3ec858eb9f fix(packaging): stop shipping frontend-contract in Linux bundles
tauri-cli's get_binaries() adds every file in src-tauri/src/bin/ as a
bundle binary, and the deb/rpm packagers copy each one to /usr/bin, so
the dev-only bindings generator ended up as /usr/bin/frontend-contract
(1.0 MB) in release packages and broke downstream repackaging (Copr:
"Installed but unpackaged file(s) found"). There is no bundler option
to exclude a binary, so move the generator to an example target, which
cargo keeps dev-only and get_binaries() never scans, and invoke it via
'cargo run --example'.

Ref https://github.com/clash-verge-rev/clash-verge-rev/pull/8131#issuecomment-6137907930
2026-10-13 16:20:13 +08:00
Huanhuan Liand❤是纱雾酱哟~ 3693efcf6c fix(tests): reset test item state when delay test fails (#8231)
* fix(tests): reset test item state when delay test fails

* docs(changelog): note test-item stuck-state fix

Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-12 10:25:58 +00:00
Tunglies 6e08a77a84 refactor(backend): specta ipc bindings (#8195)
* refactor(ipc): generate bindings with Specta

* fix(profile): project IPC inputs to consumed fields

* fix(events): remove unused refresh payloads

* fix(webdav): return only consumed backup metadata

* fix(network): trim unused interface metadata

* fix(sysinfo): return only displayed system fields

* chore(knip): check generated runtime exports

* fix(proxy): confirm runtime chain updates before UI changes

* fix(clipboard): propagate proxy environment copy failures

* refactor(dns): remove ignored runtime patch

* refactor(core): trim unused upgrade response fields

* refactor(ipc): avoid redundant response copies

* refactor(proxy): clarify automatic proxy query naming

* chore(tests): remove added IPC regression tests

* refactor(ipc): enforce typed command boundaries

* chore(contract): check generated bindings before push

* refactor(ipc): remove unused proxy liveness payloads
2026-10-12 06:16:05 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 27efaf34f4 chore(deps): update dependency pnpm to v12.11.2 (#8252)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-11 16:06:46 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 585c67f8c5 chore(deps): update rust crate zip to v9 (#8248)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-11 13:23:40 +00:00
oomeowand❤是纱雾酱哟~ d6a2717fc4 fix(profiles): skip reloading current profile on URL import (#8092)
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-11 12:01:50 +00:00
MrHardandClaude Opus 5.5 84312a5cd1 fix(sysproxy): bypass *.local by default on Windows (#8054)
Hostnames such as mi13.local are resolved by mDNS on the local network,
which the proxy core cannot do, so they failed with 'couldn't find ip'
while the system proxy was on. The macOS default already bypasses
*.local.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:32:27 +00:00
Magicapple 63ee674dcb fix(ui): disable auto-capitalization for rule content input (#8052)
On macOS, WKWebView applies system auto-capitalization to text inputs, so
typing a lowercase rule payload (e.g. a domain) in the rule editor dialog
turned its first letter into uppercase and required a manual correction.

Apply the same input-hint attributes already used by other text fields in
the app (autoComplete/autoCorrect/autoCapitalize/spellCheck) to the rule
content field.

Fixes #8038
2026-10-10 15:40:21 +00:00
wonfen 52dca35acb ci(signpath): add manual Windows test signing workflow (#8239) 2026-10-10 09:16:30 +00:00
wonfen 44477d97d4 fix(deps): remove obsolete tray-icon patch 2026-10-09 13:53:14 +08:00
wonfen 0fefa7a7ff chore: bump version 2026-10-09 13:43:50 +08:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> b6d94d45d5 chore(deps): update dependency pnpm to v12.10.1 (#8217)
* chore(deps): update dependency pnpm to v12.10.1

* fix(knip): remove unnecessary function `export`

Remove export for function `readLegacyTestUrls`, as it didn't be imported somewhere else

Assisted-by: vscode-copilot-sdk:copilot:claude-haiku-5.5 codegraph
Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-09 04:11:48 +00:00
aq 9975322b9b fix(ci): handle plain Copilot 429 in PR review (#8116)
* fix(ci): handle plain Copilot 429 in PR review

Recognize unclassified CLI rate-limit output in the existing non-blocking quota guard. Regenerate the pinned gh-aw workflow so review checks do not fail when Copilot cannot accept a request.

* fix(ci): avoid duplicate quota predicates in Copilot review
2026-10-09 03:05:12 +00:00
112 changed files with 3052 additions and 1702 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"features": { "features": {
"ghcr.io/devcontainers/features/node:2": { "ghcr.io/devcontainers/features/node:2": {
"version": "24.21.0", "version": "24.21.0",
"pnpmVersion": "12.10.0" "pnpmVersion": "12.11.2"
}, },
"ghcr.io/devcontainers/features/rust:1": { "ghcr.io/devcontainers/features/rust:1": {
"version": "1.99.0", "version": "1.99.0",
+4 -12
View File
@@ -6,6 +6,7 @@ on:
permissions: permissions:
contents: read contents: read
pull-requests: read
env: env:
HUSKY: 0 HUSKY: 0
@@ -99,14 +100,7 @@ jobs:
id: check_inputs id: check_inputs
uses: dorny/paths-filter@v4 uses: dorny/paths-filter@v4
with: with:
filters: | filters: scripts/contract-inputs.json
contract:
- 'src/services/contract.ts'
- 'scripts/gen-contract.mjs'
- 'src-tauri/src/core/notify/**'
- 'src-tauri/src/bin/**'
- 'src-tauri/src/lib.rs'
- '.github/workflows/frontend-check.yml'
- name: Skip if contract inputs unchanged - name: Skip if contract inputs unchanged
if: steps.check_inputs.outputs.contract != 'true' if: steps.check_inputs.outputs.contract != 'true'
@@ -159,8 +153,6 @@ jobs:
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu" touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
done done
- name: Regenerate contract and require empty diff - name: Check generated contract without changing the checkout
if: steps.check_inputs.outputs.contract == 'true' if: steps.check_inputs.outputs.contract == 'true'
run: | run: pnpm gen:contract --check
pnpm gen:contract
git diff --exit-code -- src/services/contract.ts
+2 -2
View File
@@ -1,4 +1,4 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"ff92739239d50701d56d10eb77fff5083a556fe471496674aa066c4f73b6b6d2","body_hash":"e7b165b458a07108c2e6bfe6f4ed61f5659ce81512691dda091165d5966ee732","compiler_version":"v0.89.21","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.87"}} # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8296ef86aad04ad225d29c7fe51dd18dfa50f258c46e8bd24e443193cc6f80fc","body_hash":"e7b165b458a07108c2e6bfe6f4ed61f5659ce81512691dda091165d5966ee732","compiler_version":"v0.89.21","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.87"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_DEFAULT_OTLP_ENDPOINT","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"924af5fdc64061cfbf66fb584c8b07e2ac230c60","version":"v0.89.21"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.23","digest":"sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23","digest":"sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.23","digest":"sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.25","digest":"sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e","pinned_image":"ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"},{"image":"ghcr.io/github/github-mcp-server:v1.12.2","digest":"sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6","pinned_image":"ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"}],"has_pull_request_target":true,"mcp_servers":[{"name":"github","tools":["get_pull_request","get_pull_request_comments","get_pull_request_diff","get_pull_request_files","get_pull_request_review_comments","get_pull_request_reviews","get_pull_request_status","issue_read","list_issue_types","list_issues","list_pull_requests","pull_request_read","search_issues","search_pull_requests"]},{"name":"safeoutputs","tools":["add_comment","add_labels","missing_data","missing_tool","noop","remove_labels"]}]} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_DEFAULT_OTLP_ENDPOINT","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"924af5fdc64061cfbf66fb584c8b07e2ac230c60","version":"v0.89.21"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.23","digest":"sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23","digest":"sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.23","digest":"sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.25","digest":"sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e","pinned_image":"ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"},{"image":"ghcr.io/github/github-mcp-server:v1.12.2","digest":"sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6","pinned_image":"ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"}],"has_pull_request_target":true,"mcp_servers":[{"name":"github","tools":["get_pull_request","get_pull_request_comments","get_pull_request_diff","get_pull_request_files","get_pull_request_review_comments","get_pull_request_reviews","get_pull_request_status","issue_read","list_issue_types","list_issues","list_pull_requests","pull_request_read","search_issues","search_pull_requests"]},{"name":"safeoutputs","tools":["add_comment","add_labels","missing_data","missing_tool","noop","remove_labels"]}]}
# This file was automatically generated by gh-aw (v0.89.21). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # This file was automatically generated by gh-aw (v0.89.21). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
# #
@@ -541,7 +541,7 @@ jobs:
GH_AW_SKILL_DIR: ".github/skills" GH_AW_SKILL_DIR: ".github/skills"
run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh"
- name: Make Copilot quota exhaustion non-blocking - name: Make Copilot quota exhaustion non-blocking
run: "node <<'NODE'\nconst fs = require('fs');\nconst harnessPath = `${process.env.RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs`;\nconst harness = fs.readFileSync(harnessPath, 'utf8');\nconst quotaBranch = [\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' return { action: \"stop\" };',\n ' }',\n].join('\\n');\nconst nonBlockingQuotaBranch = [\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' if (/\\\\b429\\\\b|Too Many Requests/i.test(result.output)) {',\n ' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);',\n ' return { action: \"stop\", exitCode: 0 };',\n ' }',\n ' return { action: \"stop\" };',\n ' }',\n].join('\\n');\n\nif (harness.split(quotaBranch).length !== 2) {\n throw new Error('Unexpected Copilot quota branch; re-audit the gh-aw upgrade');\n}\nfs.writeFileSync(harnessPath, harness.replace(quotaBranch, nonBlockingQuotaBranch));\nNODE" run: "node <<'NODE'\nconst fs = require('fs');\nconst harnessPath = `${process.env.RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs`;\nconst harness = fs.readFileSync(harnessPath, 'utf8');\nconst quotaBranch = [\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' return { action: \"stop\" };',\n ' }',\n].join('\\n');\nconst nonBlockingQuotaBranch = [\n ' let quotaErrorMsg = null;',\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' quotaErrorMsg = { action: \"stop\" };',\n ' }',\n ' if (/\\\\b429\\\\b|Too Many Requests/i.test(result.output)) {',\n ' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);',\n ' quotaErrorMsg = { action: \"stop\", exitCode: 0 };',\n ' }',\n ' if (quotaErrorMsg) {',\n ' return quotaErrorMsg;',\n ' }',\n].join('\\n');\n\nif (harness.split(quotaBranch).length !== 2) {\n throw new Error('Unexpected Copilot quota branch; re-audit the gh-aw upgrade');\n}\nfs.writeFileSync(harnessPath, harness.replace(quotaBranch, nonBlockingQuotaBranch));\nNODE"
- name: Download container images - name: Download container images
run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2 ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64 ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0 ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086 ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6 run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2 ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64 ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0 ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086 ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6
+9 -5
View File
@@ -41,13 +41,17 @@ pre-agent-steps:
' }', ' }',
].join('\n'); ].join('\n');
const nonBlockingQuotaBranch = [ const nonBlockingQuotaBranch = [
' let quotaErrorMsg = null;',
' if (isQuotaExceeded) {', ' if (isQuotaExceeded) {',
' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);', ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',
' if (/\\b429\\b|Too Many Requests/i.test(result.output)) {', ' quotaErrorMsg = { action: "stop" };',
' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);', ' }',
' return { action: "stop", exitCode: 0 };', ' if (/\\b429\\b|Too Many Requests/i.test(result.output)) {',
' }', ' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);',
' return { action: "stop" };', ' quotaErrorMsg = { action: "stop", exitCode: 0 };',
' }',
' if (quotaErrorMsg) {',
' return quotaErrorMsg;',
' }', ' }',
].join('\n'); ].join('\n');
+90
View File
@@ -0,0 +1,90 @@
name: SignPath Test
on:
workflow_dispatch:
permissions:
contents: read
actions: read
env:
CARGO_INCREMENTAL: 0
HUSKY: 0
jobs:
sign-windows:
runs-on: windows-latest
timeout-minutes: 90
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
targets: x86_64-pc-windows-msvc
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Prepare build resources
run: pnpm run prebuild x86_64-pc-windows-msvc
- name: Build test installer
shell: pwsh
env:
NODE_OPTIONS: '--max-old-space-size=4096'
run: |
$configPath = Join-Path $env:RUNNER_TEMP 'signpath-test.json'
'{"bundle":{"createUpdaterArtifacts":false}}' |
Set-Content -LiteralPath $configPath -Encoding utf8
pnpm exec tauri build --target x86_64-pc-windows-msvc --bundles nsis --config $configPath
- name: Upload unsigned installer
id: unsigned
uses: actions/upload-artifact@v7
with:
name: windows-x64-unsigned
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
if-no-files-found: error
- name: Sign with SignPath test certificate
uses: signpath/github-action-submit-signing-request@v3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: '5b298f4d-010d-4bae-b665-70551a58839c'
project-slug: 'clash-verge-rev'
signing-policy-slug: 'test-signing'
artifact-configuration-slug: 'initial'
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed
- name: Inspect installer signature
shell: pwsh
run: |
Get-ChildItem -Path signed/*-setup.exe | ForEach-Object {
$signature = Get-AuthenticodeSignature -LiteralPath $_.FullName
if (-not $signature.SignerCertificate) {
throw "No Authenticode signer certificate found for $($_.Name)"
}
$signature | Format-List Path, Status, StatusMessage, SignerCertificate
}
- name: Upload signed test installer
uses: actions/upload-artifact@v7
with:
name: windows-x64-test-signed
path: signed/*-setup.exe
if-no-files-found: error
+1
View File
@@ -5,4 +5,5 @@ if ! command -v "cargo-make" >/dev/null 2>&1; then
cargo install --force cargo-make cargo install --force cargo-make
fi fi
node scripts/check-contract-changes.mjs --push "$1" || exit $?
cargo make pre-push cargo make pre-push
+1 -1
View File
@@ -1,3 +1,3 @@
nodejs 24.21.0 nodejs 24.21.0
pnpm 12.10.0 pnpm 12.11.2
rust 1.99.0 rust 1.99.0
Generated
+102 -11
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "Inflector"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.1" version = "2.0.1"
@@ -1083,7 +1089,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]] [[package]]
name = "clash-verge" name = "clash-verge"
version = "2.5.8" version = "2.5.9"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
@@ -1135,6 +1141,8 @@ dependencies = [
"sha2 0.11.0", "sha2 0.11.0",
"smartstring", "smartstring",
"socket2", "socket2",
"specta",
"specta-typescript",
"sysproxy", "sysproxy",
"tauri", "tauri",
"tauri-build", "tauri-build",
@@ -1155,6 +1163,7 @@ dependencies = [
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-plugin-wdio-webdriver", "tauri-plugin-wdio-webdriver",
"tauri-plugin-window-state", "tauri-plugin-window-state",
"tauri-specta",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-stream", "tokio-stream",
@@ -1166,7 +1175,7 @@ dependencies = [
"windows-service", "windows-service",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"winreg 0.56.0", "winreg 0.56.0",
"zip 8.6.0", "zip 9.0.1",
] ]
[[package]] [[package]]
@@ -1213,6 +1222,7 @@ dependencies = [
"rust_iso3166", "rust_iso3166",
"serde", "serde",
"serde_json", "serde_json",
"specta",
"tokio", "tokio",
] ]
@@ -4038,9 +4048,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "lzma-rust2" name = "lzma-rust2"
version = "0.16.5" version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" checksum = "fde178a3caf126c440fa15628147772d8ee590a39477d711353fbe2e58a73a5b"
dependencies = [ dependencies = [
"sha2 0.11.0", "sha2 0.11.0",
] ]
@@ -6804,6 +6814,58 @@ dependencies = [
"system-deps 6.2.2", "system-deps 6.2.2",
] ]
[[package]]
name = "specta"
version = "2.0.0-rc.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38f9a30cbcbb7011f1da7d73483983bf838af123883e45f2b36ed76328df9c50"
dependencies = [
"paste",
"rustc_version 0.4.1",
"serde_json",
"specta-macros",
]
[[package]]
name = "specta-macros"
version = "2.0.0-rc.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ce14957ecc2897f1f848b8255b6531d13ddf49cbcf506b7c2c9fb1d005593bb"
dependencies = [
"Inflector",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "specta-serde"
version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8a72b755ddb8949fd8f17c5db43f0e8a806ea587d9bc602ee3f73240c00029"
dependencies = [
"specta",
"specta-macros",
]
[[package]]
name = "specta-typescript"
version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "639404ee95557f2f8b7e4cb773ffefd45304c7ab8ba21ac83b69051595e083c0"
dependencies = [
"specta",
]
[[package]]
name = "specta-util"
version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29b1fc02b446f7244a92924fe68c0555921209f1d342990cd1539e9138e69502"
dependencies = [
"specta",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@@ -7159,6 +7221,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_repr", "serde_repr",
"serialize-to-javascript", "serialize-to-javascript",
"specta",
"swift-rs", "swift-rs",
"tauri-build", "tauri-build",
"tauri-macros", "tauri-macros",
@@ -7276,6 +7339,7 @@ dependencies = [
"libc", "libc",
"parking_lot", "parking_lot",
"serde", "serde",
"specta",
"sysinfo", "sysinfo",
"tauri", "tauri",
"tauri-plugin-clipboard-manager", "tauri-plugin-clipboard-manager",
@@ -7655,6 +7719,37 @@ dependencies = [
"wry", "wry",
] ]
[[package]]
name = "tauri-specta"
version = "2.0.0-rc.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee080f36d2ac17ce2f3a82fb53f02d664e8345457de51b56dad3c394dacc41a2"
dependencies = [
"heck 0.5.0",
"serde",
"serde_json",
"specta",
"specta-serde",
"specta-typescript",
"specta-util",
"tauri",
"tauri-specta-macros",
"thiserror 2.0.20",
]
[[package]]
name = "tauri-specta-macros"
version = "2.0.0-rc.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a59dfdce06c98d8d211619bea5fdb39486d8a8c558e12b2d2ce255972320012"
dependencies = [
"darling",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "tauri-utils" name = "tauri-utils"
version = "2.10.1" version = "2.10.1"
@@ -10055,9 +10150,9 @@ dependencies = [
[[package]] [[package]]
name = "zip" name = "zip"
version = "8.6.0" version = "9.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692"
dependencies = [ dependencies = [
"aes", "aes",
"bzip2", "bzip2",
@@ -10070,6 +10165,7 @@ dependencies = [
"indexmap 2.14.0", "indexmap 2.14.0",
"lzma-rust2", "lzma-rust2",
"memchr", "memchr",
"nix",
"pbkdf2", "pbkdf2",
"ppmd-rust", "ppmd-rust",
"sha1 0.11.0", "sha1 0.11.0",
@@ -10187,8 +10283,3 @@ dependencies = [
"syn 3.0.5", "syn 3.0.5",
"winnow 1.0.4", "winnow 1.0.4",
] ]
[[patch.unused]]
name = "tray-icon"
version = "0.24.1"
source = "git+https://github.com/Tunglies/tray-icon.git?branch=fix%2Fmacos-27-click-reentrancy#5750c67d02d32f2af100e120ad665959d4268cef"
-5
View File
@@ -50,11 +50,6 @@ split-debuginfo = "unpacked"
# Backport Windows menu command ID recycling on the muda 0.20 compatibility branch. # Backport Windows menu command ID recycling on the muda 0.20 compatibility branch.
muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" } muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" }
# macOS Golden Gate fix for tray-icon click reentrancy issue
# Remove the following patch once the fix is merged upstream(tauri)
# https://github.com/tauri-apps/tray-icon/pull/365
tray-icon = { git = "https://github.com/Tunglies/tray-icon.git", branch = "fix/macos-27-click-reentrancy" }
[workspace.dependencies] [workspace.dependencies]
clash-verge-draft = { path = "crates/clash-verge-draft" } clash-verge-draft = { path = "crates/clash-verge-draft" }
clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" } clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" }
+6 -28
View File
@@ -1,51 +1,29 @@
## v2.5.8 ## v2.5.9
<details> <details>
<summary><strong> 🐞 修复问题 </strong></summary> <summary><strong> 🐞 修复问题 </strong></summary>
- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题 - 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置的问题
- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题 - 修复「首页」网站测速失败后卡片一直显示测试中的问题
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题
- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题
- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题
**🖥️ Windows** **🐧 Linux**
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题 - Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复
- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题
**🍎 macOS**
- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题
**🖥️/🍎 Windows/macOS**
- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题
</details> </details>
<details> <details>
<summary><strong> ✨ 新增功能 </strong></summary> <summary><strong> ✨ 新增功能 </strong></summary>
- 新增系统托盘代理组中的「测试延迟」菜单项
</details> </details>
<details> <details>
<summary><strong> 🚀 优化改进 </strong></summary> <summary><strong> 🚀 优化改进 </strong></summary>
- 优化端口设置窗口的实现,精简重复代码
- 优化侧边栏流量图表的 CPU 占用
**🖥️ Windows** **🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法 - Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
</details> </details>
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global global: Global
profiles: Perfis profiles: Perfis
proxies: Proxies proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: Proxy do sistema systemProxy: Proxy do sistema
tunMode: Modo TUN tunMode: Modo TUN
closeAllConnections: Fechar todas as conexões closeAllConnections: Fechar todas as conexões
@@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
clash-verge-logging = { workspace = true } clash-verge-logging = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
log = { workspace = true } log = { workspace = true }
+1 -1
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use super::utils::{country_code_to_emoji, get_local_date_string}; use super::utils::{country_code_to_emoji, get_local_date_string};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
pub struct UnlockItem { pub struct UnlockItem {
pub name: String, pub name: String,
pub status: String, pub status: String,
+23 -31
View File
@@ -1,3 +1,15 @@
//! Linux-only termination signal bridge.
//!
//! Upstream Tauri delivers session-end and termination events to the app on
//! Windows and macOS, but has no equivalent on Linux: a desktop session may
//! end without any window or run event reaching the process. Linux builds
//! keep this crate to listen for SIGTERM, SIGINT and SIGHUP and forward them
//! to the app quit flow, so system proxy and TUN cleanup still runs before
//! the process dies. Non-Linux builds compile this crate to nothing and must
//! not depend on it.
#![cfg(target_os = "linux")]
use std::sync::{ use std::sync::{
OnceLock, OnceLock,
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
@@ -5,20 +17,13 @@ use std::sync::{
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
#[cfg(unix)]
mod unix; mod unix;
#[cfg(windows)]
mod windows;
pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new(); pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new(); pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new();
#[derive(Clone, Copy, Debug, Eq, PartialEq)] /// Serializes signal-triggered cleanups: a signal arriving while a quit
pub enum ShutdownOutcome { /// attempt is still in flight is dropped instead of overlapping it.
Committed,
Canceled,
}
pub(crate) struct ShutdownLatch { pub(crate) struct ShutdownLatch {
is_cleaning_up: AtomicBool, is_cleaning_up: AtomicBool,
} }
@@ -36,17 +41,18 @@ impl ShutdownLatch {
.is_ok() .is_ok()
} }
fn finish(&self, outcome: ShutdownOutcome) { fn finish(&self) {
if matches!(outcome, ShutdownOutcome::Canceled) { self.is_cleaning_up.store(false, Ordering::Release);
self.is_cleaning_up.store(false, Ordering::Release);
}
} }
} }
/// Spawns the Linux signal loop on a dedicated runtime so it survives the
/// teardown of any app-owned runtime. The handler runs once per accepted
/// signal; the quit flow it invokes owns the exit-or-cancel decision.
pub fn register<F, Fut>(f: F) pub fn register<F, Fut>(f: F)
where where
F: Fn() -> Fut + Send + Sync + 'static, F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static, Fut: Future<Output = ()> + Send + 'static,
{ {
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() { RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
Ok(rt) => Some(rt), Ok(rt) => Some(rt),
@@ -61,36 +67,22 @@ where
} }
}); });
#[cfg(unix)]
unix::register(f); unix::register(f);
#[cfg(windows)]
windows::register(f);
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ShutdownLatch, ShutdownOutcome}; use super::ShutdownLatch;
#[test] #[test]
fn canceled_shutdown_releases_latch_and_permits_next_attempt() { fn finished_attempt_releases_latch_for_the_next_signal() {
let latch = ShutdownLatch::new(); let latch = ShutdownLatch::new();
assert!(latch.try_begin()); assert!(latch.try_begin());
assert!(!latch.try_begin()); assert!(!latch.try_begin());
latch.finish(ShutdownOutcome::Canceled); latch.finish();
assert!(latch.try_begin()); assert!(latch.try_begin());
} }
#[test]
fn committed_shutdown_retains_latch_and_suppresses_repeats() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
latch.finish(ShutdownOutcome::Committed);
assert!(!latch.try_begin());
}
} }
+4 -3
View File
@@ -1,12 +1,12 @@
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use tokio::signal::unix::{SignalKind, signal}; use tokio::signal::unix::{SignalKind, signal};
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome}; use crate::{RUNTIME, SHUTDOWN_LATCH};
pub fn register<F, Fut>(f: F) pub fn register<F, Fut>(f: F)
where where
F: Fn() -> Fut + Send + Sync + 'static, F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static, Fut: Future<Output = ()> + Send + 'static,
{ {
if let Some(Some(rt)) = RUNTIME.get() { if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move { rt.spawn(async move {
@@ -60,7 +60,8 @@ where
} }
logging!(info, Type::SystemSignal, "Caught signal {}", signal_name); logging!(info, Type::SystemSignal, "Caught signal {}", signal_name);
SHUTDOWN_LATCH.finish(f().await); f().await;
SHUTDOWN_LATCH.finish();
} }
}); });
} else { } else {
-83
View File
@@ -1,83 +0,0 @@
use clash_verge_logging::{Type, logging};
use tokio::signal::windows;
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
let mut ctrl_c = match windows::ctrl_c() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+C: {}", e);
return;
}
};
let mut ctrl_close = match windows::ctrl_close() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Close: {}", e);
return;
}
};
let mut ctrl_shutdown = match windows::ctrl_shutdown() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Shutdown: {}", e);
return;
}
};
let mut ctrl_logoff = match windows::ctrl_logoff() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Logoff: {}", e);
return;
}
};
loop {
let signal_name;
tokio::select! {
_ = ctrl_c.recv() => {
signal_name = "Ctrl+C";
}
_ = ctrl_close.recv() => {
signal_name = "Ctrl+Close";
}
_ = ctrl_shutdown.recv() => {
signal_name = "Ctrl+Shutdown";
}
_ = ctrl_logoff.recv() => {
signal_name = "Ctrl+Logoff";
}
}
if !SHUTDOWN_LATCH.try_begin() {
logging!(
info,
Type::SystemSignal,
"Already shutting down, ignoring repeated signal: {}",
signal_name
);
continue;
}
logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name);
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
logging!(
error,
Type::SystemSignal,
"register shutdown signal failed, RUNTIME is not available"
);
}
}
@@ -5,6 +5,7 @@ edition = "2024"
rust-version = "1.91" rust-version = "1.91"
[dependencies] [dependencies]
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
tauri = { workspace = true } tauri = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true } tauri-plugin-clipboard-manager = { workspace = true }
parking_lot = { workspace = true } parking_lot = { workspace = true }
@@ -1,53 +1,46 @@
use parking_lot::RwLock; use parking_lot::RwLock;
use serde::Serialize; use serde::Serialize;
use tauri::{AppHandle, Runtime, State, command}; use tauri::{AppHandle, Runtime, State, command};
use tauri_plugin_clipboard_manager::{ClipboardExt as _, Error}; use tauri_plugin_clipboard_manager::ClipboardExt as _;
use crate::Platform; use crate::Platform;
#[derive(Serialize)] #[derive(Serialize, specta::Type)]
pub struct SystemInfo { pub struct SystemInfo {
pub system_name: String, pub system_name: String,
pub system_version: 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 { impl From<&Platform> for SystemInfo {
fn from(platform: Platform) -> Self { fn from(platform: &Platform) -> Self {
Self { Self {
system_name: platform.sysinfo.system_name, system_name: platform.sysinfo.system_name.clone(),
system_version: platform.sysinfo.system_version, system_version: platform.sysinfo.system_version.clone(),
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] #[command]
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<SystemInfo, Error> { #[specta::specta]
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<SystemInfo, String> {
let platform = state.inner().read(); let platform = state.inner().read();
Ok(SystemInfo::from(platform.clone())) Ok(SystemInfo::from(&*platform))
} }
/// 获取应用的运行时间(毫秒) /// 获取应用的运行时间(毫秒)
#[command] #[command]
pub fn get_app_uptime(state: State<'_, RwLock<Platform>>) -> Result<u128, Error> { #[specta::specta]
pub fn get_app_uptime(state: State<'_, RwLock<Platform>>) -> Result<u128, String> {
Ok(state.inner().read().appinfo.app_startup_time.elapsed().as_millis()) Ok(state.inner().read().appinfo.app_startup_time.elapsed().as_millis())
} }
#[command] #[command]
#[specta::specta]
pub fn export_diagnostic_info<R: Runtime>( pub fn export_diagnostic_info<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
state: State<'_, RwLock<Platform>>, state: State<'_, RwLock<Platform>>,
) -> Result<(), Error> { ) -> Result<(), String> {
let info = state.inner().read().to_string(); let info = state.inner().read().to_string();
let clipboard = app_handle.clipboard(); let clipboard = app_handle.clipboard();
clipboard.write_text(info) clipboard.write_text(info).map_err(|error| error.to_string())
} }
+52
View File
@@ -1,3 +1,55 @@
## v2.5.8
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题
- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题
- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题
- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题
**🖥️ Windows**
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题
- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复
- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题
**🍎 macOS**
- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题
**🖥️/🍎 Windows/macOS**
- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增系统托盘代理组中的「测试延迟」菜单项
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化端口设置窗口的实现,精简重复代码
- 优化侧边栏流量图表的 CPU 占用
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
</details>
## v2.5.7 ## v2.5.7
<details> <details>
+115
View File
@@ -12,6 +12,92 @@ import pluginUnusedImports from 'eslint-plugin-unused-imports'
import globals from 'globals' import globals from 'globals'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
const ipcBoundaryRules = ({
allowCommands = false,
allowInvoke = false,
allowEvents = false,
} = {}) => {
const paths = [
...(!allowInvoke
? [
{
name: '@tauri-apps/api/core',
importNames: ['invoke'],
allowTypeImports: true,
message: 'Invoke backend commands through services/cmds.',
},
]
: []),
...(!allowEvents
? [
{
name: '@tauri-apps/api/event',
importNames: ['listen', 'emit', 'emitTo', 'once'],
allowTypeImports: true,
message:
'The event bus owns Tauri event subscriptions and emissions.',
},
]
: []),
]
const patterns = allowCommands
? []
: [
{
regex: '(^|/)bindings(\\.ts)?$',
importNames: ['commands'],
allowTypeImports: true,
message:
'Only services/cmds may import generated command implementations.',
},
]
const modules = [
...(!allowInvoke ? ['@tauri-apps/api/core'] : []),
...(!allowEvents ? ['@tauri-apps/api/event'] : []),
]
const syntax = modules.flatMap((name) => [
{
selector: `ImportExpression[source.value='${name}']`,
message:
'Use the owned IPC service boundary instead of dynamically importing Tauri APIs.',
},
{
selector: `CallExpression[callee.name='require'][arguments.0.value='${name}']`,
message:
'Use the owned IPC service boundary instead of requiring Tauri APIs.',
},
{
selector: `TSImportEqualsDeclaration[moduleReference.expression.value='${name}']`,
message: 'Use the owned IPC service boundary for Tauri APIs.',
},
])
if (!allowCommands) {
syntax.push(
{
selector: 'ImportExpression[source.value=/bindings(\\.ts)?$/]',
message:
'Only services/cmds may load generated command implementations.',
},
{
selector:
"CallExpression[callee.name='require'][arguments.0.value=/bindings(\\.ts)?$/]",
message:
'Only services/cmds may load generated command implementations.',
},
{
selector:
'TSImportEqualsDeclaration[moduleReference.expression.value=/bindings(\\.ts)?$/]',
message:
'Only services/cmds may load generated command implementations.',
},
)
}
return {
'no-restricted-imports': ['error', { paths, patterns }],
'no-restricted-syntax': ['error', ...syntax],
}
}
export default defineConfig([ export default defineConfig([
pluginESx.configs['flat/restrict-to-es2022'], pluginESx.configs['flat/restrict-to-es2022'],
{ {
@@ -162,4 +248,33 @@ export default defineConfig([
}, },
}, },
}, },
{
files: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}'],
rules: ipcBoundaryRules(),
},
{
files: ['src/services/cmds.ts'],
rules: ipcBoundaryRules({ allowCommands: true }),
},
{
files: ['src/services/bindings.ts'],
rules: ipcBoundaryRules({ allowCommands: true, allowInvoke: true }),
},
{
files: ['src/services/bus/**/*.{ts,tsx}'],
rules: ipcBoundaryRules({ allowEvents: true }),
},
{
files: [
'src/components/proxy/proxy-chain.tsx',
'src/components/proxy/proxy-groups-chain.tsx',
'src/pages/proxies.tsx',
],
rules: {
'@typescript-eslint/no-floating-promises': [
'error',
{ ignoreVoid: false },
],
},
},
]) ])
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://unpkg.com/knip@6/schema.json", "$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/polyfills/*.js!"], "entry": ["src/polyfills/*.js!", "scripts/check-contract-changes.mjs!"],
"project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"], "project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"],
"include": [ "include": [
"files", "files",
@@ -18,7 +18,7 @@
"ignoreDependencies": ["lint-staged"], "ignoreDependencies": ["lint-staged"],
"ignoreIssues": { "ignoreIssues": {
"src/types/generated/**": ["exports", "types"], "src/types/generated/**": ["exports", "types"],
"src/services/contract.ts": ["exports", "types"] "src/services/bindings.ts": ["types"]
}, },
"cycles": { "cycles": {
"dynamicImports": true "dynamicImports": true
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "clash-verge", "name": "clash-verge",
"version": "2.5.8", "version": "2.5.9",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {
"prepare": "husky || true", "prepare": "husky || true",
@@ -146,5 +146,5 @@
] ]
}, },
"type": "module", "type": "module",
"packageManager": "pnpm@12.10.0" "packageManager": "pnpm@12.11.2"
} }
+61 -61
View File
@@ -7,153 +7,153 @@ importers:
configDependencies: {} configDependencies: {}
packageManagerDependencies: packageManagerDependencies:
pnpm: pnpm:
specifier: 12.10.0 specifier: 12.11.2
version: 12.10.0 version: 12.11.2
packages: packages:
'@pnpm/exe.android-arm64@12.10.0': '@pnpm/exe.android-arm64@12.11.2':
resolution: {integrity: sha512-QMMRzOI/7CU4xLbKY6O2BZn3VI4+a8LLyim18H7it8llBWuBPMqDqUURE8045k+7lI8/lI2/tElhyKptEviKDQ==} resolution: {integrity: sha512-owLw13OfPkEoY+hLBNeeG+dCFhywr7q2d/EHPz4mNL2RhKnv8gNwMARkjkiDQu234aItVwFNDeGnmQXm+ikkAg==}
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@pnpm/exe.android-x64@12.10.0': '@pnpm/exe.android-x64@12.11.2':
resolution: {integrity: sha512-41zwlROW+t523Nfqo4rWp8IPiSK5vnAWrnCey26Je45rX1SMNc4ABY7OFmPw41/t+Y0CE+4tjTj+0s9YGqPM5w==} resolution: {integrity: sha512-GYPkVebl4iokOA/4MPSpGoUVfFlsth7uqOCe1XiaiM23LAdvUOMJBObqHJijr89ZqjwHm9pO6vO45GRwJ2U2qA==}
cpu: [x64] cpu: [x64]
os: [android] os: [android]
'@pnpm/exe.darwin-arm64@12.10.0': '@pnpm/exe.darwin-arm64@12.11.2':
resolution: {integrity: sha512-L+r7AD8Fjf8Vcc4ZRJl+Pjzc27CIeQ/FiRwPmOluFO2UcXubmGHdW+OuMUxrU0vkoXiGR61Ft0RtaPwTRY4W/g==} resolution: {integrity: sha512-n9M4On66y8FDxUAKgMO1FXt0qihyG4e+56p3/F0uo8Bx9KplW8BwKVnXigzCJ8aNM6sVVPkOE6M9XXbeVIxnZA==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@pnpm/exe.darwin-x64@12.10.0': '@pnpm/exe.darwin-x64@12.11.2':
resolution: {integrity: sha512-/TCF1K6E+4PnbkLgqGS1Iqlf5bHp6FDzuXgu6++PMMAFkF7Yk5EF3BcfG1gYYjpkvlJBccvVMsRBnWFbSyqu1w==} resolution: {integrity: sha512-oSW7n3sBT0c0cyk4kj10T2Y6Mpm+OFEiCAWozgX039tdoI6yqJF1uaFD3sw7ugLdu6VV3+uY0pa1TUZIPdxDWA==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@pnpm/exe.freebsd-x64@12.10.0': '@pnpm/exe.freebsd-x64@12.11.2':
resolution: {integrity: sha512-k7/WFA91E2qhnR/2EOEv402U8bjP758y2pybbdiB5Hrd4KhgviVOJp5cFrXpKgd8HZJs/RZN7cdmMvU2Q2BVgg==} resolution: {integrity: sha512-qbzc3KoDpa/Lkf8vs3jnDe6dUUbso+6eLIyssYFMiliLpYNxn8dirk11YoNnOQcdHVB50v5RFls1Yid6ttT5pg==}
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@pnpm/exe.linux-arm64-musl@12.10.0': '@pnpm/exe.linux-arm64-musl@12.11.2':
resolution: {integrity: sha512-ChD8bN0lRCRaL+CMOaMUgZQ4WHzEkDXmDHrZgXgdoo6yymQKKzr66Ic7Sl/D7Ky1cVUcFEzHjlq4fGDJwt6Lqg==} resolution: {integrity: sha512-uMEYDwKRktGwANubQ+kYCwmhNwg3Ik6qK89ahsbQ5cP30R/5FVJb/T99vr9XRUBC/A7mZI6eeT92oyVas9ACyg==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@pnpm/exe.linux-arm64@12.10.0': '@pnpm/exe.linux-arm64@12.11.2':
resolution: {integrity: sha512-egl5RUSQFh7kETBdmMhaTuaQKP1pjH6Cxq370zgpFFKJXF+GGkIzK+jLNazrjX9pKIWF3iqn7kUeyP6yKxzBoA==} resolution: {integrity: sha512-6e8ZAQUwFXAWHHHv5FesWqf/kEK04wuitBv5f/gxzkC0DiBfaixr+f+6JQede8SF4Ji5LcMwVFJbzAtffpWxEg==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@pnpm/exe.linux-ppc64@12.10.0': '@pnpm/exe.linux-ppc64@12.11.2':
resolution: {integrity: sha512-YJ6bEZ+stPpv71CGfGQQeyIpMx/zSMEcAXQzG+rjBtzVfyNsxYciRevhJ+PE9b2qKqN78Mh/1tOMmx9ly4VXGw==} resolution: {integrity: sha512-Ui8A4nepkVupx2VpuFfysEluMvWK6gTB5rgVts7Ss/+8dj0RD28x3F/seNeH/zSypgC6yKdV6qsKVQh/uffTuQ==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@pnpm/exe.linux-riscv64@12.10.0': '@pnpm/exe.linux-riscv64@12.11.2':
resolution: {integrity: sha512-njfd4euctC1zH1nVBbm6+r1c+0y/TtgqN523rk/KMSxqItpV5QAVa9Yz48Rt5DOIKmuLq7xXOhsQodBlbTk3pw==} resolution: {integrity: sha512-i1yLdQ5bu+wjQ+srtFoJAaY2gN/+oXHFdjul4llCeoDxHE0rTIG3R41NhHfCjyFPW4+CpbBT58933CL9sMOCnA==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@pnpm/exe.linux-s390x@12.10.0': '@pnpm/exe.linux-s390x@12.11.2':
resolution: {integrity: sha512-4JI2F8pYGMcsG6ny61OiBSvZeDozc64TbPozbTU2X5uCsaFCsCvcpr/WwyypwCv+/mNjcIhaKhlxpwJYsnteIg==} resolution: {integrity: sha512-YS1/lhPjjTsN4QL50VaFZKRa3Uc/bDnhE8eKbJMFvuX06UuFS5YaiDg04APoZdfVSGqTfX27QAg7rfLMSWaqxA==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.10.0': '@pnpm/exe.linux-x64-musl@12.11.2':
resolution: {integrity: sha512-xayd2iG8ugdvjlGeOvsfm+iSe0bFu9RTX3/8HWOLAQmHdbLKi4hPbxP0S4c9tj8pNej/IxVBQLJksRXFlhNjPw==} resolution: {integrity: sha512-7eIxQR05fvJxCujUSKp5KTtehJ7QqrgqP/HD1+BcB/SaV2Be4XI1PEHiOgA1zxsiMIeMvTF6E4G4dYHrFjeXRg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@pnpm/exe.linux-x64@12.10.0': '@pnpm/exe.linux-x64@12.11.2':
resolution: {integrity: sha512-PFkzWEghFLKuocxMHtn57mlJ59f5aBbqjX6FCUtiunEHqxTxOibSduQPs3Llm6VWbAXBjD3VrX29Z+bjU1Dpyw==} resolution: {integrity: sha512-FK7QKe24AYgppJwO3mpooYptduuvIGqKJPi5ac7MdnWkjUy46JBfYhCW0grnkWKC8YOf0x+vGviuQqJEj3SpQQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@pnpm/exe.win32-arm64@12.10.0': '@pnpm/exe.win32-arm64@12.11.2':
resolution: {integrity: sha512-0HtTS+TLONWd/dNSczyOkK0UeZ36cbdhC/2ljZ61DPhvc4jTb7ur0yU47On0in7Im3yXwzTY4vtF8ykrDwc+NQ==} resolution: {integrity: sha512-PCTjVDdJb6VvraZxrEeFv6EqEA0hhIsAySx28e95J2Tqpi88hLpgOXgNqV0vpxq9A+b8GLQfyk/D+1UlJPv/YQ==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@pnpm/exe.win32-x64@12.10.0': '@pnpm/exe.win32-x64@12.11.2':
resolution: {integrity: sha512-IcW3rehTGzJ7YnmHEWnbnJakjbBmnH0r29qiajK9+6b9psMF9IjocFr0dyitRVSfXxyW/FI5PIK3mOswGoyFTg==} resolution: {integrity: sha512-8sgX86OvRjJ3yZs/TvGR2L31wH9ScPZ+f+geKI3ikuB0RRiK3i4LlTRO8OXN6pdRqj8epMde4312MCjj7JsJdA==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
pnpm@12.10.0: pnpm@12.11.2:
resolution: {integrity: sha512-Eip7bd+dQCaLwNsal4CJm5Xk+out/Ke9RQYVrYz3wmWfX1acIO18U4hrx7CNDVClT0rVBwaEf1aC7GxQSEFlOQ==} resolution: {integrity: sha512-JaUWUqGWeFr18ovabf6NxYLr76SsHRgCIc4TkCMcX7dmN4IupLbkYzznSs3LCnf3Ja8RutOBd/3t2u6BipVcuQ==}
engines: {node: '>=18.*'} engines: {node: '>=18.*'}
hasBin: true hasBin: true
snapshots: snapshots:
'@pnpm/exe.android-arm64@12.10.0': '@pnpm/exe.android-arm64@12.11.2':
optional: true optional: true
'@pnpm/exe.android-x64@12.10.0': '@pnpm/exe.android-x64@12.11.2':
optional: true optional: true
'@pnpm/exe.darwin-arm64@12.10.0': '@pnpm/exe.darwin-arm64@12.11.2':
optional: true optional: true
'@pnpm/exe.darwin-x64@12.10.0': '@pnpm/exe.darwin-x64@12.11.2':
optional: true optional: true
'@pnpm/exe.freebsd-x64@12.10.0': '@pnpm/exe.freebsd-x64@12.11.2':
optional: true optional: true
'@pnpm/exe.linux-arm64-musl@12.10.0': '@pnpm/exe.linux-arm64-musl@12.11.2':
optional: true optional: true
'@pnpm/exe.linux-arm64@12.10.0': '@pnpm/exe.linux-arm64@12.11.2':
optional: true optional: true
'@pnpm/exe.linux-ppc64@12.10.0': '@pnpm/exe.linux-ppc64@12.11.2':
optional: true optional: true
'@pnpm/exe.linux-riscv64@12.10.0': '@pnpm/exe.linux-riscv64@12.11.2':
optional: true optional: true
'@pnpm/exe.linux-s390x@12.10.0': '@pnpm/exe.linux-s390x@12.11.2':
optional: true optional: true
'@pnpm/exe.linux-x64-musl@12.10.0': '@pnpm/exe.linux-x64-musl@12.11.2':
optional: true optional: true
'@pnpm/exe.linux-x64@12.10.0': '@pnpm/exe.linux-x64@12.11.2':
optional: true optional: true
'@pnpm/exe.win32-arm64@12.10.0': '@pnpm/exe.win32-arm64@12.11.2':
optional: true optional: true
'@pnpm/exe.win32-x64@12.10.0': '@pnpm/exe.win32-x64@12.11.2':
optional: true optional: true
pnpm@12.10.0: pnpm@12.11.2:
optionalDependencies: optionalDependencies:
'@pnpm/exe.android-arm64': 12.10.0 '@pnpm/exe.android-arm64': 12.11.2
'@pnpm/exe.android-x64': 12.10.0 '@pnpm/exe.android-x64': 12.11.2
'@pnpm/exe.darwin-arm64': 12.10.0 '@pnpm/exe.darwin-arm64': 12.11.2
'@pnpm/exe.darwin-x64': 12.10.0 '@pnpm/exe.darwin-x64': 12.11.2
'@pnpm/exe.freebsd-x64': 12.10.0 '@pnpm/exe.freebsd-x64': 12.11.2
'@pnpm/exe.linux-arm64': 12.10.0 '@pnpm/exe.linux-arm64': 12.11.2
'@pnpm/exe.linux-arm64-musl': 12.10.0 '@pnpm/exe.linux-arm64-musl': 12.11.2
'@pnpm/exe.linux-ppc64': 12.10.0 '@pnpm/exe.linux-ppc64': 12.11.2
'@pnpm/exe.linux-riscv64': 12.10.0 '@pnpm/exe.linux-riscv64': 12.11.2
'@pnpm/exe.linux-s390x': 12.10.0 '@pnpm/exe.linux-s390x': 12.11.2
'@pnpm/exe.linux-x64': 12.10.0 '@pnpm/exe.linux-x64': 12.11.2
'@pnpm/exe.linux-x64-musl': 12.10.0 '@pnpm/exe.linux-x64-musl': 12.11.2
'@pnpm/exe.win32-arm64': 12.10.0 '@pnpm/exe.win32-arm64': 12.11.2
'@pnpm/exe.win32-x64': 12.10.0 '@pnpm/exe.win32-x64': 12.11.2
--- ---
lockfileVersion: '9.0' lockfileVersion: '9.0'
+25 -75
View File
@@ -1,84 +1,34 @@
import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
/** import { ESLint } from 'eslint'
* Architecture grep gates for the frontend contract flow. Select gates via
* argv:
*
* node scripts/check-architecture.mjs listen emit invoke
*
* - listen/emit: the Tauri event API may only be used under src/services/bus/**
* (the event bus). Detected as a bare `listen(`/`emit(` call (member calls
* like `this.emit(` are unrelated class methods) or as importing the name
* from '@tauri-apps/api/event'.
* - invoke: `invoke(` calls may exist only under src/services/**.
*/
const ROOT = path.resolve(import.meta.dirname, '..') const ROOT = path.resolve(import.meta.dirname, '..')
const SRC = path.join(ROOT, 'src') const gates = process.argv.slice(2)
if (
const tauriEventImport = (name) => !gates.length ||
new RegExp( gates.some((gate) => !['listen', 'emit', 'invoke'].includes(gate))
`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*'@tauri-apps/api/event'`, ) {
)
const GATES = {
listen: {
patterns: [/(?<![.\w])listen\(/, tauriEventImport('listen')],
allowed: (file) => file.startsWith('src/services/bus/'),
description: 'Tauri listen outside the event bus (src/services/bus)',
},
emit: {
patterns: [/(?<![.\w])emit\(/, tauriEventImport('emit')],
allowed: (file) => file.startsWith('src/services/bus/'),
description: 'Tauri emit outside the event bus (src/services/bus)',
},
invoke: {
patterns: [/\binvoke\(/],
allowed: (file) => file.startsWith('src/services/'),
description: 'invoke( outside the services layer (src/services)',
},
}
const walk = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true })
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) return walk(full)
return /\.(ts|tsx|js|jsx|mts|mjs)$/.test(entry.name) ? [full] : []
})
}
const selected = process.argv.slice(2)
if (selected.length === 0) {
console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)') console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)')
process.exit(2) process.exit(2)
} }
let violations = 0 // All IPC boundaries use the lint configuration, including when callers request a legacy gate.
for (const gate of selected) { const eslint = new ESLint({ cwd: ROOT, allowInlineConfig: false })
const rule = GATES[gate] const results = await eslint.lintFiles(['src'])
if (!rule) { const boundaryRules = new Set([
console.error(`unknown gate: ${gate}`) 'no-restricted-imports',
process.exit(2) 'no-restricted-syntax',
} '@typescript-eslint/no-floating-promises',
for (const file of walk(SRC)) { ])
const relative = path.relative(ROOT, file).replaceAll('\\', '/') const violations = results.flatMap((result) =>
if (rule.allowed(relative)) continue result.messages
const lines = fs.readFileSync(file, 'utf8').split('\n') .filter((message) => message.fatal || boundaryRules.has(message.ruleId))
lines.forEach((line, index) => { .map((message) => ({ file: result.filePath, ...message })),
if (rule.patterns.some((pattern) => pattern.test(line))) { )
violations += 1 for (const violation of violations) {
console.error( console.error(
`[arch] ${rule.description}: ${relative}:${index + 1}: ${line.trim()}`, `[arch] ${path.relative(ROOT, violation.file)}:${violation.line}: ${violation.message}`,
) )
}
})
}
} }
if (violations.length) process.exitCode = 1
if (violations > 0) { else console.log('[arch] no violations')
console.error(`[arch] ${violations} violation(s) found`)
process.exit(1)
}
console.log('[arch] no violations')
+69
View File
@@ -0,0 +1,69 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
const ROOT = path.resolve(import.meta.dirname, '..')
const git = (...args) =>
execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim()
const { contract } = JSON.parse(
fs.readFileSync(path.join(ROOT, 'scripts/contract-inputs.json'), 'utf8'),
)
const inputs = contract.map((pattern) => `:(glob)${pattern}`)
const args = process.argv.slice(2)
if (args.length !== 2 || args[0] !== '--push') {
console.error('usage: check-contract-changes.mjs --push <remote>')
process.exit(2)
}
const hasChanges = (...range) =>
git('diff', '--name-only', ...range, '--', ...inputs) !== ''
const assertCleanInputs = (...range) => {
if (
hasChanges(...range) ||
git('ls-files', '--others', '--exclude-standard', '--', ...inputs)
) {
throw new Error(
'Contract inputs differ from the pushed commit. Commit those changes before pushing.',
)
}
}
let relevant = false
const head = git('rev-parse', 'HEAD')
for (const line of fs
.readFileSync(0, 'utf8')
.trim()
.split('\n')
.filter(Boolean)) {
const fields = line.trim().split(/\s+/)
if (
fields.length !== 4 ||
![fields[1], fields[3]].every((sha) => /^[a-f0-9]{40,64}$/.test(sha))
) {
throw new Error('Invalid pre-push ref update')
}
const [, local, , remote] = fields
if (/^0+$/.test(local) || local === remote) continue
// A new remote branch has no upstream; use its fork point with the remote default branch.
const base = /^0+$/.test(remote)
? git('merge-base', local, `refs/remotes/${args[1]}/HEAD`)
: remote
if (!hasChanges(base, local)) continue
const commit = git('rev-parse', `${local}^{commit}`)
if (commit !== head)
throw new Error(
'Check out the pushed commit before validating its changed IPC contract.',
)
assertCleanInputs('HEAD')
relevant = true
}
if (relevant) {
execFileSync(
process.execPath,
[path.join(ROOT, 'scripts/gen-contract.mjs'), '--check'],
{
cwd: ROOT,
stdio: 'inherit',
},
)
} else {
console.log('[contract] inputs unchanged; skipping Rust generation')
}
+23
View File
@@ -0,0 +1,23 @@
{
"contract": [
"src-tauri/**/*.rs",
"crates/**/*.rs",
"Cargo.toml",
"Cargo.lock",
"src-tauri/Cargo.toml",
"crates/**/Cargo.toml",
"rust-toolchain.toml",
"src-tauri/tauri.conf*.json",
"src/services/bindings.ts",
"scripts/gen-contract.mjs",
"scripts/check-contract-changes.mjs",
"biome.json",
"biome.jsonc",
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"scripts/contract-inputs.json",
".github/workflows/frontend-check.yml",
".husky/pre-push"
]
}
+46 -82
View File
@@ -1,91 +1,55 @@
import { execFileSync } from 'node:child_process' import { execFileSync } from 'node:child_process'
import fs from 'node:fs' import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path' import path from 'node:path'
/**
* Regenerates src/services/contract.ts from the backend wire contract.
* Source of truth: `cargo run -p clash-verge --bin frontend-contract` inside
* src-tauri, which serializes the same enum variants and event payloads the
* app emits at runtime. CI reruns this and fails on a non-empty diff.
*/
const ROOT = path.resolve(import.meta.dirname, '..') const ROOT = path.resolve(import.meta.dirname, '..')
const OUT = path.join(ROOT, 'src/services/contract.ts') const BINDINGS = path.join(ROOT, 'src/services/bindings.ts')
const args = process.argv.slice(2)
const contract = JSON.parse( if (args.length > 1 || (args.length === 1 && args[0] !== '--check')) {
console.error('usage: gen-contract.mjs [--check]')
process.exit(2)
}
const check = args[0] === '--check'
const temporary = check
? fs.mkdtempSync(path.join(os.tmpdir(), 'verge-contract-'))
: null
const output = temporary ? path.join(temporary, 'bindings.ts') : BINDINGS
try {
execFileSync( execFileSync(
'cargo', 'cargo',
['run', '-p', 'clash-verge', '--bin', 'frontend-contract', '-q'], [
{ 'run',
cwd: path.join(ROOT, 'src-tauri'), '-p',
encoding: 'utf8', 'clash-verge',
maxBuffer: 16 * 1024 * 1024, '--example',
}, 'frontend-contract',
), '--locked',
) '-q',
...(check ? ['--', output] : []),
if (contract.version !== 1) { ],
throw new Error(`unsupported wire contract version: ${contract.version}`) { cwd: ROOT, stdio: 'inherit' },
} )
const formatted = execFileSync(
/** TS type per event, derived from the serialized sample payload's JSON shape. */ process.execPath,
const payloadType = (name, sample) => { [
if (sample === null) return 'null' path.join(ROOT, 'node_modules/@biomejs/biome/bin/biome'),
if (typeof sample === 'string') return 'string' 'format',
if (Array.isArray(sample)) { '--stdin-file-path',
if (name === 'verge://notice-message') return '[NoticeStatus, string]' BINDINGS,
throw new Error(`unexpected array payload for ${name}`) ],
{ cwd: ROOT, input: fs.readFileSync(output) },
)
if (check) {
if (!formatted.equals(fs.readFileSync(BINDINGS))) {
console.error(
'IPC bindings are out of date. Run pnpm gen:contract and commit the generated file.',
)
process.exitCode = 1
}
} else {
fs.writeFileSync(BINDINGS, formatted)
} }
if (typeof sample === 'object') { } finally {
const keys = Object.keys(sample) if (temporary) fs.rmSync(temporary, { recursive: true, force: true })
if (keys.length === 1 && keys[0] === 'uid') return '{ uid: string }'
if (name === 'verge://run-state-changed') return 'RunState'
throw new Error(`unexpected object payload for ${name}: ${keys.join(',')}`)
}
throw new Error(`unexpected payload for ${name}`)
} }
const needsRunState = contract.events.some(
(e) => e.name === 'verge://run-state-changed',
)
if (!needsRunState)
throw new Error('run-state-changed event missing from contract')
const lines = []
lines.push(
'// @generated by scripts/gen-contract.mjs from the backend wire contract. DO NOT EDIT.',
)
lines.push('// Regen: pnpm gen:contract')
lines.push('')
lines.push("import type { RunState } from './cmds'")
lines.push('')
lines.push(
'/** Wire status strings from NoticeStatus; keep in sync via `pnpm gen:contract`. */',
)
lines.push('export type NoticeStatus =')
for (const status of contract.noticeStatuses) {
lines.push(` | ${JSON.stringify(status)}`)
}
lines.push('')
lines.push('/** Event name → payload type for every backend-emitted event. */')
lines.push('export interface VergeEventPayloads {')
for (const event of contract.events) {
const key = JSON.stringify(event.name)
lines.push(` ${key}: ${payloadType(event.name, event.sample)}`)
}
lines.push('}')
lines.push('')
const content = `${lines.join('\n')}\n`
fs.writeFileSync(OUT, content)
// Match the repo's biome style so CI parity (`git diff --exit-code`) is stable.
execFileSync(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
['exec', 'biome', 'format', '--write', path.relative(ROOT, OUT)],
{ cwd: ROOT, stdio: 'ignore' },
)
console.log(
`[gen-contract] wrote ${path.relative(ROOT, OUT)}: ${contract.events.length} events, ${contract.noticeStatuses.length} statuses`,
)
+9 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "clash-verge" name = "clash-verge"
version = "2.5.8" version = "2.5.9"
description = "clash verge" description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"] authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only" license = "GPL-3.0-only"
@@ -35,7 +35,6 @@ tauri-build = { version = "2.7.1", features = [] }
clash-verge-draft = { workspace = true } clash-verge-draft = { workspace = true }
clash-verge-media-unlock = { workspace = true } clash-verge-media-unlock = { workspace = true }
clash-verge-logging = { workspace = true } clash-verge-logging = { workspace = true }
clash-verge-signal = { workspace = true }
clash-verge-i18n = { workspace = true } clash-verge-i18n = { workspace = true }
clash-verge-limiter = { workspace = true } clash-verge-limiter = { workspace = true }
tauri-plugin-clash-verge-sysinfo = { workspace = true } tauri-plugin-clash-verge-sysinfo = { workspace = true }
@@ -80,7 +79,7 @@ tauri-plugin-fs = "2.6.0"
tauri-plugin-process = "2.4.0" tauri-plugin-process = "2.4.0"
tauri-plugin-deep-link = "2.6.1" tauri-plugin-deep-link = "2.6.1"
tauri-plugin-window-state = "2.5.0" tauri-plugin-window-state = "2.5.0"
zip = "8.6.0" zip = "9.0.1"
flate2 = "1.1.10" flate2 = "1.1.10"
reqwest_dav = "0.3.3" reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" } aes-gcm = { version = "0.11.1" }
@@ -109,10 +108,17 @@ webpki-roots = "1.0.9"
dark-light = "3.0.0" dark-light = "3.0.0"
bytes = "1.12.1" bytes = "1.12.1"
tokio-util = { version = "0.7.19", features = ["time"] } tokio-util = { version = "0.7.19", features = ["time"] }
specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_json"] }
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
specta-typescript = "=0.0.12"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2.190" libc = "0.2.190"
[target.'cfg(target_os = "linux")'.dependencies]
# Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
clash-verge-signal = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true } tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
objc2 = "0.6.5" objc2 = "0.6.5"
+14
View File
@@ -0,0 +1,14 @@
// Dev-only target: Tauri ships everything under src/bin/ to /usr/bin in deb/rpm
// bundles (PR #8131), so this generator must stay an example, never a bin.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args_os().skip(1);
let output = args
.next()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/services/bindings.ts"));
if args.next().is_some() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "expected at most one output path").into());
}
app_lib::ipc::builder().export(specta_typescript::Typescript::default(), output)?;
Ok(())
}
-8
View File
@@ -1,8 +0,0 @@
//! Prints the frontend wire contract as JSON to stdout. Zero new dependencies:
//! the payload is derived from the same serializers the app uses at runtime.
//! Consumed by scripts/gen-contract.mjs; CI regenerates and diffs the committed
//! TypeScript artifact.
fn main() {
println!("{}", app_lib::frontend_wire_contract());
}
+24 -6
View File
@@ -9,12 +9,14 @@ use smartstring::alias::String;
use tauri::{AppHandle, Manager as _, ipc::Channel}; use tauri::{AppHandle, Manager as _, ipc::Channel};
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn open_app_dir() -> CmdResult<()> { pub async fn open_app_dir() -> CmdResult<()> {
let app_dir = dirs::app_home_dir().stringify_err()?; let app_dir = dirs::app_home_dir().stringify_err()?;
open::that(app_dir).stringify_err() open::that(app_dir).stringify_err()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn open_core_dir() -> CmdResult<()> { pub async fn open_core_dir() -> CmdResult<()> {
let core_dir = tauri::utils::platform::current_exe().stringify_err()?; let core_dir = tauri::utils::platform::current_exe().stringify_err()?;
let core_dir = core_dir.parent().ok_or("failed to get core dir")?; let core_dir = core_dir.parent().ok_or("failed to get core dir")?;
@@ -22,12 +24,14 @@ pub async fn open_core_dir() -> CmdResult<()> {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn open_logs_dir() -> CmdResult<()> { pub async fn open_logs_dir() -> CmdResult<()> {
let log_dir = dirs::app_logs_dir().stringify_err()?; let log_dir = dirs::app_logs_dir().stringify_err()?;
open::that(log_dir).stringify_err() open::that(log_dir).stringify_err()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn open_devtools(app_handle: AppHandle) { pub fn open_devtools(app_handle: AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") { if let Some(window) = app_handle.get_webview_window("main") {
if !window.is_devtools_open() { if !window.is_devtools_open() {
@@ -39,22 +43,26 @@ pub fn open_devtools(app_handle: AppHandle) {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn exit_app() { pub async fn exit_app() {
feat::quit().await; feat::quit().await;
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn restart_app() -> CmdResult<()> { pub async fn restart_app() -> CmdResult<()> {
feat::restart_app().await; feat::restart_app().await;
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn install_update( pub async fn install_update(
app_handle: AppHandle, app_handle: AppHandle,
version: String, version: std::string::String,
on_event: Channel<DownloadEvent>, on_event: Channel<DownloadEvent>,
) -> CmdResult<bool> { ) -> CmdResult<bool> {
let version: String = version.into();
SilentUpdater::global() SilentUpdater::global()
.install_update(&app_handle, &version, |event| { .install_update(&app_handle, &version, |event| {
let _ = on_event.send(event); let _ = on_event.send(event);
@@ -64,22 +72,32 @@ pub async fn install_update(
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn cancel_update_download() { pub fn cancel_update_download() {
SilentUpdater::global().cancel_download(); SilentUpdater::global().cancel_download();
} }
#[tauri::command] #[tauri::command]
pub fn get_app_dir() -> CmdResult<String> { #[specta::specta]
pub fn get_app_dir() -> CmdResult<std::string::String> {
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into(); let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
Ok(app_home_dir) Ok(app_home_dir)
} }
#[tauri::command] #[tauri::command]
pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String> { #[specta::specta]
feat::download_icon_cache(url, name).await pub async fn download_icon_cache(
url: std::string::String,
name: std::string::String,
) -> CmdResult<std::string::String> {
let url: String = url.into();
let name: String = name.into();
feat::download_icon_cache(url, name).await.map(Into::into)
} }
#[tauri::command] #[tauri::command]
pub async fn copy_icon_file(path: String, icon_info: feat::IconInfo) -> CmdResult<String> { #[specta::specta]
feat::copy_icon_file(path, icon_info).await pub async fn copy_icon_file(path: std::string::String, icon_info: feat::IconInfo) -> CmdResult<std::string::String> {
let path: String = path.into();
feat::copy_icon_file(path, icon_info).await.map(Into::into)
} }
+16 -5
View File
@@ -4,33 +4,44 @@ use feat::LocalBackupFile;
use smartstring::alias::String; use smartstring::alias::String;
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn create_local_backup() -> CmdResult<()> { pub async fn create_local_backup() -> CmdResult<()> {
feat::create_local_backup().await.stringify_err() feat::create_local_backup().await.stringify_err()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn list_local_backup() -> CmdResult<Vec<LocalBackupFile>> { pub async fn list_local_backup() -> CmdResult<Vec<LocalBackupFile>> {
feat::list_local_backup().await.stringify_err() feat::list_local_backup().await.stringify_err()
} }
#[tauri::command] #[tauri::command]
pub async fn delete_local_backup(filename: String) -> CmdResult<()> { #[specta::specta]
pub async fn delete_local_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
feat::delete_local_backup(filename).await.stringify_err() feat::delete_local_backup(filename).await.stringify_err()
} }
#[tauri::command] #[tauri::command]
pub async fn restore_local_backup(filename: String) -> CmdResult<()> { #[specta::specta]
pub async fn restore_local_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
feat::restore_local_backup(filename) feat::restore_local_backup(filename)
.await .await
.map_err(|error| super::proxy_aware_error(&error)) .map_err(|error| super::proxy_aware_error(&error))
} }
#[tauri::command] #[tauri::command]
pub async fn import_local_backup(source: String) -> CmdResult<String> { #[specta::specta]
feat::import_local_backup(source).await.stringify_err() pub async fn import_local_backup(source: std::string::String) -> CmdResult<std::string::String> {
let source: String = source.into();
feat::import_local_backup(source).await.map(Into::into).stringify_err()
} }
#[tauri::command] #[tauri::command]
pub async fn export_local_backup(filename: String, destination: String) -> CmdResult<()> { #[specta::specta]
pub async fn export_local_backup(filename: std::string::String, destination: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
let destination: String = destination.into();
feat::export_local_backup(filename, destination).await.stringify_err() feat::export_local_backup(filename, destination).await.stringify_err()
} }
+36 -24
View File
@@ -12,30 +12,33 @@ use crate::{
}, },
}; };
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
use serde_yaml_ng::Mapping;
use smartstring::alias::String; use smartstring::alias::String;
use tokio::fs; use tokio::fs;
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn copy_clash_env() -> CmdResult { pub async fn copy_clash_env() -> CmdResult {
feat::copy_clash_env().await; feat::copy_clash_env().await.stringify_err()
Ok(())
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn get_clash_info() -> CmdResult<ClashInfo> { pub async fn get_clash_info() -> CmdResult<ClashInfo> {
Ok(Config::clash().await.data_arc().get_client_info()) Ok(Config::clash().await.data_arc().get_client_info())
} }
#[tauri::command] #[tauri::command]
pub async fn patch_clash_config(payload: Mapping) -> CmdResult { #[specta::specta]
feat::patch_clash(&payload) pub async fn patch_clash_config(payload: crate::ipc::YamlMapping) -> CmdResult {
feat::patch_clash(&payload.0)
.await .await
.map_err(|error| proxy_aware_coded_error(&error, "CLASH_CONFIG_UPDATE_FAILED")) .map_err(|error| proxy_aware_coded_error(&error, "CLASH_CONFIG_UPDATE_FAILED"))
} }
#[tauri::command] #[tauri::command]
pub async fn patch_clash_mode(payload: String) -> CmdResult { #[specta::specta]
pub async fn patch_clash_mode(payload: std::string::String) -> CmdResult {
let payload: String = payload.into();
feat::change_clash_mode(payload) feat::change_clash_mode(payload)
.await .await
.with_error_code("CLASH_MODE_UPDATE_FAILED") .with_error_code("CLASH_MODE_UPDATE_FAILED")
@@ -43,12 +46,15 @@ pub async fn patch_clash_mode(payload: String) -> CmdResult {
/// Reads the saved mode without depending on strict mihomo `/configs` deserialization. /// Reads the saved mode without depending on strict mihomo `/configs` deserialization.
#[tauri::command] #[tauri::command]
pub async fn get_clash_mode() -> CmdResult<Option<String>> { #[specta::specta]
Ok(Config::clash().await.data_arc().get_mode().map(Into::into)) pub async fn get_clash_mode() -> CmdResult<Option<std::string::String>> {
Ok(Config::clash().await.data_arc().get_mode())
} }
#[tauri::command] #[tauri::command]
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFailure>> { #[specta::specta]
pub async fn change_clash_core(clash_core: std::string::String) -> CmdResult<Option<CommandFailure>> {
let clash_core: String = clash_core.into();
logging!(info, Type::Config, "changing core to {clash_core}"); logging!(info, Type::Config, "changing core to {clash_core}");
match CoreManager::global().change_core(&clash_core).await { match CoreManager::global().change_core(&clash_core).await {
@@ -80,6 +86,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn restart_core() -> CmdResult { pub async fn restart_core() -> CmdResult {
logging_error!(Type::Core, profiles_save_file_safe().await); logging_error!(Type::Core, profiles_save_file_safe().await);
let result = CoreManager::global() let result = CoreManager::global()
@@ -94,6 +101,7 @@ pub async fn restart_core() -> CmdResult {
/// Replaces the managed core binary atomically instead of letting mihomo overwrite itself. /// Replaces the managed core binary atomically instead of letting mihomo overwrite itself.
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeReport> { pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeReport> {
let report = feat::upgrade_core(force).await.with_error_code("CORE_UPGRADE_FAILED")?; let report = feat::upgrade_core(force).await.with_error_code("CORE_UPGRADE_FAILED")?;
if report.upgraded { if report.upgraded {
@@ -103,7 +111,9 @@ pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeRepor
} }
#[tauri::command] #[tauri::command]
pub async fn test_delay(url: String) -> CmdResult<u32> { #[specta::specta]
pub async fn test_delay(url: std::string::String) -> CmdResult<u32> {
let url: String = url.into();
let result = match feat::test_delay(url).await { let result = match feat::test_delay(url).await {
Ok(delay) => delay, Ok(delay) => delay,
Err(e) => { Err(e) => {
@@ -115,13 +125,14 @@ pub async fn test_delay(url: String) -> CmdResult<u32> {
} }
#[tauri::command] #[tauri::command]
pub async fn save_dns_config(dns_config: Mapping) -> CmdResult { #[specta::specta]
pub async fn save_dns_config(dns_config: crate::ipc::YamlMapping) -> CmdResult {
use crate::utils::dirs; use crate::utils::dirs;
use tokio::fs; use tokio::fs;
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG); let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config).stringify_err()?; let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config.0).stringify_err()?;
fs::write(&dns_path, yaml_str).await.stringify_err()?; fs::write(&dns_path, yaml_str).await.stringify_err()?;
logging!(info, Type::Config, "DNS config saved to {dns_path:?}"); logging!(info, Type::Config, "DNS config saved to {dns_path:?}");
@@ -129,22 +140,27 @@ pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn take_dns_override_notice() -> bool { pub fn take_dns_override_notice() -> bool {
crate::config::dns::take_dns_override_notice() crate::config::dns::take_dns_override_notice()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn set_dns_override( pub async fn set_dns_override(
profile_uid: String, profile_uid: std::string::String,
enabled: bool, enabled: bool,
confirmation: Option<String>, confirmation: Option<std::string::String>,
) -> CmdResult<feat::DnsOverrideOutcome> { ) -> CmdResult<feat::DnsOverrideOutcome> {
let profile_uid: String = profile_uid.into();
let confirmation = confirmation.map(String::from);
feat::set_dns_override(profile_uid, enabled, confirmation) feat::set_dns_override(profile_uid, enabled, confirmation)
.await .await
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED")) .map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn apply_dns_config(apply: bool) -> CmdResult { pub async fn apply_dns_config(apply: bool) -> CmdResult {
if apply { if apply {
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG); let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
@@ -163,7 +179,7 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
); );
})?; })?;
let patch_config = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| { serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
logging!( logging!(
error, error,
Type::Config, Type::Config,
@@ -172,13 +188,6 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
); );
})?; })?;
let mut patch = serde_yaml_ng::Mapping::new();
patch.insert("dns".into(), patch_config.into());
Config::runtime().await.edit_draft(|d| {
d.patch_config(&patch);
});
CoreManager::global().update_config_checked().await.map_err(|error| { CoreManager::global().update_config_checked().await.map_err(|error| {
logging!(error, Type::Config, "failed to apply the config with DNS: {error:#}"); logging!(error, Type::Config, "failed to apply the config with DNS: {error:#}");
proxy_aware_error(&error) proxy_aware_error(&error)
@@ -202,20 +211,22 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
} }
#[tauri::command] #[tauri::command]
pub async fn get_dns_config_content() -> CmdResult<Option<String>> { #[specta::specta]
pub async fn get_dns_config_content() -> CmdResult<Option<std::string::String>> {
use crate::utils::dirs; use crate::utils::dirs;
use tokio::fs; use tokio::fs;
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG); let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
match fs::read_to_string(&dns_path).await { match fs::read_to_string(&dns_path).await {
Ok(content) => Ok(Some(content.into())), Ok(content) => Ok(Some(content)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(CommandFailure::plain(error)), Err(error) => Err(CommandFailure::plain(error)),
} }
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> { pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
let app_dir = dirs::app_home_dir().stringify_err()?; let app_dir = dirs::app_home_dir().stringify_err()?;
let dns_path = app_dir.join(constants::files::DNS_CONFIG); let dns_path = app_dir.join(constants::files::DNS_CONFIG);
@@ -232,6 +243,7 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn get_clash_logs() -> CmdResult<Vec<std::string::String>> { pub async fn get_clash_logs() -> CmdResult<Vec<std::string::String>> {
let logs = CoreManager::global().get_clash_logs().await.unwrap_or_default(); let logs = CoreManager::global().get_clash_logs().await.unwrap_or_default();
Ok(logs) Ok(logs)
+1
View File
@@ -3,6 +3,7 @@ use crate::core::lightweight;
use super::CmdResult; use super::CmdResult;
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn entry_lightweight_mode() -> CmdResult { pub async fn entry_lightweight_mode() -> CmdResult {
lightweight::entry_lightweight_mode().await; lightweight::entry_lightweight_mode().await;
Ok(()) Ok(())
+2
View File
@@ -5,11 +5,13 @@ use crate::{
}; };
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn probe_listener(request: ListenerProbe) -> CmdResult<ListenerProbeOutcome> { pub async fn probe_listener(request: ListenerProbe) -> CmdResult<ListenerProbeOutcome> {
feat::probe_listener(request).await.stringify_err() feat::probe_listener(request).await.stringify_err()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult<SaveProxyPortsOutcome> { pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult<SaveProxyPortsOutcome> {
feat::save_proxy_ports(settings) feat::save_proxy_ports(settings)
.await .await
@@ -4,6 +4,7 @@ use std::time::Duration;
use tauri::{command, ipc::Channel}; use tauri::{command, ipc::Channel};
#[command] #[command]
#[specta::specta]
pub fn get_unlock_items() -> Vec<UnlockItem> { pub fn get_unlock_items() -> Vec<UnlockItem> {
clash_verge_media_unlock::default_unlock_items() clash_verge_media_unlock::default_unlock_items()
} }
@@ -26,6 +27,7 @@ async fn create_client() -> Result<Client, String> {
} }
#[command] #[command]
#[specta::specta]
pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<UnlockItem>, String> { pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<UnlockItem>, String> {
let client = create_client().await?; let client = create_client().await?;
Ok(clash_verge_media_unlock::check_media_unlock(&client, |item| { Ok(clash_verge_media_unlock::check_media_unlock(&client, |item| {
@@ -35,6 +37,7 @@ pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<
} }
#[command] #[command]
#[specta::specta]
pub async fn check_media_unlock_item(name: String) -> Result<UnlockItem, String> { pub async fn check_media_unlock_item(name: String) -> Result<UnlockItem, String> {
let client = create_client().await?; let client = create_client().await?;
clash_verge_media_unlock::check_media_unlock_item(&client, &name).await clash_verge_media_unlock::check_media_unlock_item(&client, &name).await
+1 -1
View File
@@ -4,7 +4,7 @@ use smartstring::alias::String;
pub type CmdResult<T = ()> = Result<T, CommandFailure>; pub type CmdResult<T = ()> = Result<T, CommandFailure>;
/// Structured command failure returned to the frontend. /// Structured command failure returned to the frontend.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)]
pub struct CommandFailure { pub struct CommandFailure {
/// Stable frontend identifier, when classified. /// Stable frontend identifier, when classified.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
+34 -20
View File
@@ -3,13 +3,12 @@ use crate::cmd::StringifyErr as _;
use crate::core::{proxy_control, sysopt::Sysopt}; use crate::core::{proxy_control, sysopt::Sysopt};
use gethostname::gethostname; use gethostname::gethostname;
use network_interface::NetworkInterface;
use serde_yaml_ng::Mapping;
use sysproxy::{Autoproxy, Sysproxy}; use sysproxy::{Autoproxy, Sysproxy};
use tauri_plugin_clash_verge_sysinfo; use tauri_plugin_clash_verge_sysinfo;
#[tauri::command] #[tauri::command]
pub async fn get_sys_proxy() -> CmdResult<Mapping> { #[specta::specta]
pub async fn get_sys_proxy() -> CmdResult<SystemProxy> {
Sysopt::global().wait_idle().await; Sysopt::global().wait_idle().await;
// With no network service there is no proxy configured anywhere, which reads as disabled. // With no network service there is no proxy configured anywhere, which reads as disabled.
let sys_proxy = match Sysproxy::get_system_proxy() { let sys_proxy = match Sysproxy::get_system_proxy() {
@@ -17,40 +16,40 @@ pub async fn get_sys_proxy() -> CmdResult<Mapping> {
other => other.stringify_err()?, other => other.stringify_err()?,
}; };
let Sysproxy { let Sysproxy {
ref host, host,
ref bypass, bypass,
ref port, port,
ref enable, enable,
} = sys_proxy; } = sys_proxy;
let mut map = Mapping::new(); Ok(SystemProxy {
map.insert("enable".into(), (*enable).into()); enable,
map.insert("server".into(), format!("{}:{}", host, port).into()); server: format!("{host}:{port}"),
map.insert("bypass".into(), bypass.as_str().into()); bypass,
Ok(map) })
} }
#[tauri::command] #[tauri::command]
pub async fn get_auto_proxy() -> CmdResult<Mapping> { #[specta::specta]
pub async fn get_auto_proxy() -> CmdResult<AutoProxy> {
Sysopt::global().wait_idle().await; Sysopt::global().wait_idle().await;
let auto_proxy = match Autoproxy::get_auto_proxy() { let auto_proxy = match Autoproxy::get_auto_proxy() {
Err(error) if proxy_control::is_missing_network_service(&error) => Autoproxy::default(), Err(error) if proxy_control::is_missing_network_service(&error) => Autoproxy::default(),
other => other.stringify_err()?, other => other.stringify_err()?,
}; };
let Autoproxy { ref enable, ref url } = auto_proxy; let Autoproxy { enable, url } = auto_proxy;
let mut map = Mapping::new(); Ok(AutoProxy { enable, url })
map.insert("enable".into(), (*enable).into());
map.insert("url".into(), url.as_str().into());
Ok(map)
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn get_embedded_server_port() -> CmdResult<u16> { pub fn get_embedded_server_port() -> CmdResult<u16> {
crate::utils::server::embedded_server_port().stringify_err() crate::utils::server::embedded_server_port().stringify_err()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn get_system_hostname() -> String { pub fn get_system_hostname() -> String {
match gethostname().into_string() { match gethostname().into_string() {
Ok(name) => name, Ok(name) => name,
@@ -62,12 +61,14 @@ pub fn get_system_hostname() -> String {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn get_network_interfaces() -> Vec<String> { pub fn get_network_interfaces() -> Vec<String> {
tauri_plugin_clash_verge_sysinfo::list_network_interfaces() tauri_plugin_clash_verge_sysinfo::list_network_interfaces()
} }
#[tauri::command] #[tauri::command]
pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> { #[specta::specta]
pub fn get_network_interfaces_info() -> CmdResult<Vec<crate::ipc::NetworkInterfaceView>> {
use network_interface::{NetworkInterface, NetworkInterfaceConfig as _}; use network_interface::{NetworkInterface, NetworkInterfaceConfig as _};
let names = get_network_interfaces(); let names = get_network_interfaces();
@@ -77,9 +78,22 @@ pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
for interface in interfaces { for interface in interfaces {
if names.contains(&interface.name) { if names.contains(&interface.name) {
result.push(interface); result.push(interface.into());
} }
} }
Ok(result) Ok(result)
} }
#[derive(serde::Serialize, specta::Type)]
pub struct SystemProxy {
pub enable: bool,
pub server: String,
pub bypass: String,
}
#[derive(serde::Serialize, specta::Type)]
pub struct AutoProxy {
pub enable: bool,
pub url: String,
}
+114 -17
View File
@@ -33,6 +33,7 @@ fn profile_import_error(err: &anyhow::Error) -> std::string::String {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> { pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
let draft = Config::profiles().await; let draft = Config::profiles().await;
let data = draft.data_arc(); let data = draft.data_arc();
@@ -40,6 +41,7 @@ pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> { pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
match feat::enhance_profiles().await { match feat::enhance_profiles().await {
Ok(outcome) if outcome.is_valid() => { Ok(outcome) if outcome.is_valid() => {
@@ -65,6 +67,7 @@ pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
#[tauri::command] #[tauri::command]
#[tracing::instrument(skip_all, level = "info", fields(url = %help::mask_url(&url), uid = tracing::field::Empty))] #[tracing::instrument(skip_all, level = "info", fields(url = %help::mask_url(&url), uid = tracing::field::Empty))]
#[specta::specta]
pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult { pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await { let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
Ok(it) => it, Ok(it) => it,
@@ -94,7 +97,10 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
} }
#[tauri::command] #[tauri::command]
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult { #[specta::specta]
pub async fn reorder_profile(active_id: std::string::String, over_id: std::string::String) -> CmdResult {
let active_id: String = active_id.into();
let over_id: String = over_id.into();
match profiles_reorder_safe(&active_id, &over_id).await { match profiles_reorder_safe(&active_id, &over_id).await {
Ok(_) => { Ok(_) => {
logging!(info, Type::Cmd, "重新排序配置文件: {} -> {}", active_id, over_id); logging!(info, Type::Cmd, "重新排序配置文件: {} -> {}", active_id, over_id);
@@ -114,16 +120,16 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
} }
#[tauri::command] #[tauri::command]
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult { #[specta::specta]
pub async fn create_profile(item: ProfileCreate, file_data: Option<std::string::String>) -> CmdResult {
let item: PrfItem = item.into();
let file_data = file_data.map(String::from);
match profiles_append_item_with_filedata_safe(&item, file_data).await { match profiles_append_item_with_filedata_safe(&item, file_data).await {
Ok(_) => { Ok(_) => {
profiles_save_file_safe() profiles_save_file_safe()
.await .await
.with_error_code("PROFILE_CREATE_FAILED")?; .with_error_code("PROFILE_CREATE_FAILED")?;
logging_error!(Type::Timer, Timer::global().refresh().await); logging_error!(Type::Timer, Timer::global().refresh().await);
if let Some(uid) = &item.uid {
handle::Handle::notify_profile_changed(uid);
}
Ok(()) Ok(())
} }
Err(err) => Err(coded_error("PROFILE_CREATE_FAILED", err)), Err(err) => Err(coded_error("PROFILE_CREATE_FAILED", err)),
@@ -131,7 +137,9 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
} }
#[tauri::command] #[tauri::command]
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult { #[specta::specta]
pub async fn update_profile(index: std::string::String, option: Option<PrfOption>) -> CmdResult {
let index: String = index.into();
match feat::update_profile(&index, option.as_ref(), true).await { match feat::update_profile(&index, option.as_ref(), true).await {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(e) => { Err(e) => {
@@ -142,7 +150,9 @@ pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResu
} }
#[tauri::command] #[tauri::command]
pub async fn delete_profile(index: String) -> CmdResult { #[specta::specta]
pub async fn delete_profile(index: std::string::String) -> CmdResult {
let index: String = index.into();
let profile_write_guard = PROFILE_WRITE_LOCK.lock().await; let profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
let profiles = Config::profiles().await; let profiles = Config::profiles().await;
@@ -313,9 +323,85 @@ async fn perform_config_update(
} }
} }
#[derive(Default, serde::Deserialize, specta::Type)]
#[serde(default)]
pub struct ProfileSelection {
#[specta(type = Option<std::string::String>)]
pub current: Option<String>,
}
#[derive(Default, serde::Deserialize, specta::Type)]
#[serde(default)]
pub struct ProfileCreate {
#[serde(rename = "type")]
#[specta(type = Option<std::string::String>)]
pub itype: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub desc: Option<String>,
#[specta(type = Option<std::string::String>)]
pub url: Option<String>,
pub option: Option<PrfOption>,
}
impl From<ProfileCreate> for PrfItem {
fn from(item: ProfileCreate) -> Self {
Self {
itype: item.itype,
name: item.name,
desc: item.desc,
url: item.url,
option: item.option,
..Self::default()
}
}
}
#[derive(Default, serde::Deserialize, specta::Type)]
#[serde(default)]
pub struct ProfilePatch {
#[serde(rename = "type")]
#[specta(type = Option<std::string::String>)]
pub itype: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub desc: Option<String>,
#[specta(type = Option<std::string::String>)]
pub file: Option<String>,
#[specta(type = Option<std::string::String>)]
pub url: Option<String>,
pub selected: Option<Vec<crate::config::PrfSelected>>,
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
pub extra: Option<crate::config::PrfExtra>,
pub updated: Option<usize>,
pub option: Option<PrfOption>,
}
impl From<ProfilePatch> for PrfItem {
fn from(patch: ProfilePatch) -> Self {
Self {
itype: patch.itype,
name: patch.name,
desc: patch.desc,
file: patch.file,
url: patch.url,
selected: patch.selected,
latency_test_urls: patch.latency_test_urls,
extra: patch.extra,
updated: patch.updated,
option: patch.option,
..Self::default()
}
}
}
#[tauri::command] #[tauri::command]
#[tracing::instrument(skip_all, level = "info", fields(target = ?profiles.current))] #[tracing::instrument(skip_all, level = "info", fields(target = ?profiles.current))]
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationOutcome> { #[specta::specta]
pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult<ValidationOutcome> {
if CURRENT_SWITCHING_PROFILE if CURRENT_SWITCHING_PROFILE
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err() .is_err()
@@ -325,11 +411,17 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
} }
let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await; let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
let profiles = IProfiles {
current: profiles.current,
items: None,
};
let target_profile = profiles.current.as_ref(); let target_profile = profiles.current.as_ref();
let previous_profile = Config::profiles().await.data_arc().current.clone(); let previous_profile = Config::profiles().await.data_arc().current.clone();
Config::profiles().await.edit_draft(|d| d.patch_config(&profiles)); Config::profiles().await.edit_draft(|d| {
d.patch_config(&profiles);
});
perform_config_update(target_profile, previous_profile.as_ref()) perform_config_update(target_profile, previous_profile.as_ref())
.await .await
@@ -337,15 +429,17 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
} }
pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult<ValidationOutcome> { pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult<ValidationOutcome> {
let profiles = IProfiles { let profiles = ProfileSelection {
current: Some(profile_index), current: Some(profile_index),
items: None,
}; };
patch_profiles_config(profiles).await patch_profiles_config(profiles).await
} }
#[tauri::command] #[tauri::command]
pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult { #[specta::specta]
pub async fn patch_profile(index: std::string::String, profile: ProfilePatch) -> CmdResult {
let profile: PrfItem = profile.into();
let index: String = index.into();
let profiles = Config::profiles().await; let profiles = Config::profiles().await;
let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index) let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index)
&& let Some(new_option) = profile.option.as_ref() && let Some(new_option) = profile.option.as_ref()
@@ -385,7 +479,8 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
} }
#[tauri::command] #[tauri::command]
pub async fn view_profile(index: String) -> CmdResult { #[specta::specta]
pub async fn view_profile(index: std::string::String) -> CmdResult {
let profiles = Config::profiles().await; let profiles = Config::profiles().await;
let profiles_ref = profiles.latest_arc(); let profiles_ref = profiles.latest_arc();
let file = profiles_ref let file = profiles_ref
@@ -409,7 +504,8 @@ pub async fn view_profile(index: String) -> CmdResult {
} }
#[tauri::command] #[tauri::command]
pub async fn read_profile_file(index: String) -> CmdResult<String> { #[specta::specta]
pub async fn read_profile_file(index: std::string::String) -> CmdResult<std::string::String> {
let item = { let item = {
let profiles = Config::profiles().await; let profiles = Config::profiles().await;
let profiles_ref = profiles.latest_arc(); let profiles_ref = profiles.latest_arc();
@@ -429,7 +525,7 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
.join(file.as_str()); .join(file.as_str());
match tokio::fs::try_exists(&path).await { match tokio::fs::try_exists(&path).await {
Ok(true) => {} Ok(true) => {}
Ok(false) => return Ok(String::new()), Ok(false) => return Ok(std::string::String::new()),
Err(err) => { Err(err) => {
return Err(coded_error( return Err(coded_error(
"PROFILE_READ_FAILED", "PROFILE_READ_FAILED",
@@ -440,11 +536,12 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
} }
let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?; let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?;
Ok(data) Ok(data.into())
} }
#[tauri::command] #[tauri::command]
pub async fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> { #[specta::specta]
pub async fn get_next_update_time(uid: std::string::String) -> CmdResult<Option<i64>> {
let timer = Timer::global(); let timer = Timer::global();
let next_time = timer.get_next_update_time(&uid).await; let next_time = timer.get_next_update_time(&uid).await;
Ok(next_time) Ok(next_time)
+4
View File
@@ -18,6 +18,7 @@ use std::{
/// Merges one selection pair into fresh backend state to avoid stale-list overwrites. /// Merges one selection pair into fresh backend state to avoid stale-list overwrites.
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> { pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> {
crate::config::profiles::record_selected_node(&group_name, &node) crate::config::profiles::record_selected_node(&group_name, &node)
.await .await
@@ -25,6 +26,7 @@ pub async fn record_selected_node(group_name: String, node: String) -> CmdResult
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn forget_selected_node(group_name: String) -> CmdResult<()> { pub async fn forget_selected_node(group_name: String) -> CmdResult<()> {
crate::config::profiles::forget_selected_node(&group_name) crate::config::profiles::forget_selected_node(&group_name)
.await .await
@@ -104,6 +106,7 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> { pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
proxy_view(None).await proxy_view(None).await
} }
@@ -135,6 +138,7 @@ pub(crate) async fn proxy_view(provider_timeout: Option<std::time::Duration>) ->
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn sync_tray_proxy_selection() -> CmdResult<()> { pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
if TRAY_SYNC_RUNNING if TRAY_SYNC_RUNNING
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
+41 -36
View File
@@ -1,44 +1,57 @@
use super::CmdResult; use super::CmdResult;
use crate::{cmd::StringifyErr as _, config::Config, core::CoreManager, utils::yaml_emitter}; use crate::{cmd::StringifyErr as _, config::Config, core::CoreManager, utils::yaml_emitter};
use anyhow::{Context as _, anyhow}; use anyhow::{Context as _, anyhow};
use clash_verge_logging::{Type, logging};
use serde_yaml_ng::Mapping;
use smartstring::alias::String; use smartstring::alias::String;
use std::collections::HashMap; use std::collections::HashMap;
#[tauri::command] #[tauri::command]
pub async fn get_runtime_config() -> CmdResult<Option<Mapping>> { #[specta::specta]
Ok(Config::runtime().await.latest_arc().config.clone()) pub async fn get_runtime_config() -> CmdResult<Option<crate::ipc::YamlMapping>> {
Ok(Config::runtime()
.await
.latest_arc()
.config
.clone()
.map(crate::ipc::YamlMapping))
} }
#[tauri::command] #[tauri::command]
pub async fn get_runtime_yaml() -> CmdResult<String> { #[specta::specta]
pub async fn get_runtime_yaml() -> CmdResult<std::string::String> {
let runtime = Config::runtime().await; let runtime = Config::runtime().await;
let runtime = runtime.latest_arc(); let runtime = runtime.latest_arc();
let config = runtime.config.as_ref(); let config = runtime.config.as_ref();
config config
.ok_or_else(|| anyhow!("failed to parse config to yaml file")) .ok_or_else(|| anyhow!("failed to parse config to yaml file"))
.and_then(|config| { .and_then(|config| yaml_emitter::to_mihomo_config_string(config).context("failed to convert config to yaml"))
yaml_emitter::to_mihomo_config_string(config)
.context("failed to convert config to yaml")
.map(|s| s.into())
})
.stringify_err() .stringify_err()
} }
#[tauri::command] #[tauri::command]
pub async fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String)>>> { #[specta::specta]
Ok(Config::runtime().await.latest_arc().chain_logs.clone()) pub async fn get_runtime_logs() -> CmdResult<RuntimeLogs> {
Ok(RuntimeLogs(Config::runtime().await.latest_arc().chain_logs.clone()))
}
#[derive(serde::Serialize, specta::Type)]
#[serde(transparent)]
pub struct RuntimeLogs(
#[specta(type = HashMap<std::string::String, Vec<(std::string::String, std::string::String)>>)]
HashMap<String, Vec<(String, String)>>,
);
#[tauri::command]
#[specta::specta]
pub fn take_discarded_keys_notice() -> Option<std::string::String> {
crate::enhance::take_discarded_keys_notice().map(Into::into)
} }
#[tauri::command] #[tauri::command]
pub fn take_discarded_keys_notice() -> Option<String> { #[specta::specta]
crate::enhance::take_discarded_keys_notice() pub async fn get_runtime_proxy_chain_config(
} proxy_chain_exit_node: std::string::String,
) -> CmdResult<std::string::String> {
#[tauri::command]
pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult<String> {
let runtime = Config::runtime().await; let runtime = Config::runtime().await;
let runtime = runtime.latest_arc(); let runtime = runtime.latest_arc();
@@ -59,7 +72,7 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
false false
} }
}) { }) {
proxies_chain.push(proxy.to_owned()); proxies_chain.push(proxy);
proxy_name = proxy.get("dialer-proxy").map(|x| x.as_str()); proxy_name = proxy.get("dialer-proxy").map(|x| x.as_str());
} }
@@ -68,18 +81,17 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
.find(|proxy| proxy.get("name").map(|x| x.as_str()) == proxy_name) .find(|proxy| proxy.get("name").map(|x| x.as_str()) == proxy_name)
&& !proxies_chain.is_empty() && !proxies_chain.is_empty()
{ {
proxies_chain.push(entry_proxy.to_owned()); proxies_chain.push(entry_proxy);
} }
proxies_chain.reverse(); proxies_chain.reverse();
let mut config: HashMap<String, Vec<serde_yaml_ng::Value>> = HashMap::new(); let mut config: HashMap<String, Vec<&serde_yaml_ng::Value>> = HashMap::new();
config.insert("proxies".into(), proxies_chain); config.insert("proxies".into(), proxies_chain);
yaml_emitter::to_mihomo_config_string(&config) yaml_emitter::to_mihomo_config_string(&config)
.context("YAML generation failed") .context("YAML generation failed")
.map(|s| s.into())
.stringify_err() .stringify_err()
} else { } else {
Err("failed to get proxies or proxy-groups".into()) Err("failed to get proxies or proxy-groups".into())
@@ -87,20 +99,13 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
} }
#[tauri::command] #[tauri::command]
pub async fn update_proxy_chain_config_in_runtime(proxy_chain_config: Option<serde_yaml_ng::Value>) -> CmdResult<()> { #[specta::specta]
match CoreManager::global() pub async fn update_proxy_chain_config_in_runtime(
proxy_chain_config: Option<crate::ipc::YamlValue>,
) -> CmdResult<crate::core::validate::ValidationOutcome> {
let proxy_chain_config = proxy_chain_config.map(|value| value.0);
CoreManager::global()
.update_runtime_config(|d| d.update_proxy_chain_config(proxy_chain_config)) .update_runtime_config(|d| d.update_proxy_chain_config(proxy_chain_config))
.await .await
{ .stringify_err()
Ok(outcome) if outcome.is_valid() => {}
Ok(outcome) => logging!(
warn,
Type::Core,
"Failed to apply runtime proxy chain config: {}",
outcome
),
Err(err) => logging!(error, Type::Core, "Failed to apply runtime proxy chain config: {err:#}"),
}
Ok(())
} }
+7 -1
View File
@@ -16,7 +16,13 @@ use tokio::fs;
/// 保存profiles的配置 /// 保存profiles的配置
#[tauri::command] #[tauri::command]
pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult<ValidationOutcome> { #[specta::specta]
pub async fn save_profile_file(
index: std::string::String,
file_data: Option<std::string::String>,
) -> CmdResult<ValidationOutcome> {
let index: String = index.into();
let file_data = file_data.map(String::from);
let file_data = match file_data { let file_data = match file_data {
Some(d) => d, Some(d) => d,
None => return Ok(ValidationOutcome::Valid), None => return Ok(ValidationOutcome::Valid),
+13 -2
View File
@@ -8,11 +8,11 @@ use crate::{
}, },
}; };
#[derive(Debug, PartialEq, Eq, serde::Serialize)] #[derive(Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(tag = "status", rename_all = "camelCase")] #[serde(tag = "status", rename_all = "camelCase")]
pub enum ServiceInstallOutcome { pub enum ServiceInstallOutcome {
Installed, Installed,
#[cfg(any(windows, test))] #[cfg_attr(not(any(windows, test)), allow(dead_code))]
Sidecar { Sidecar {
reason: String, reason: String,
}, },
@@ -82,11 +82,13 @@ where
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn install_service() -> CmdResult<ServiceInstallOutcome> { pub async fn install_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn uninstall_service() -> CmdResult { pub async fn uninstall_service() -> CmdResult {
CoreManager::global() CoreManager::global()
.uninstall_service_and_start_sidecar() .uninstall_service_and_start_sidecar()
@@ -95,16 +97,19 @@ pub async fn uninstall_service() -> CmdResult {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn reinstall_service() -> CmdResult<ServiceInstallOutcome> { pub async fn reinstall_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> { pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn continue_with_sidecar() -> CmdResult { pub async fn continue_with_sidecar() -> CmdResult {
crate::core::CoreManager::global() crate::core::CoreManager::global()
.continue_with_sidecar(None) .continue_with_sidecar(None)
@@ -113,31 +118,37 @@ pub async fn continue_with_sidecar() -> CmdResult {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn get_sidecar_failure() -> crate::core::notification::SidecarFailureSnapshot { pub fn get_sidecar_failure() -> crate::core::notification::SidecarFailureSnapshot {
crate::core::notification::sidecar_failure_snapshot() crate::core::notification::sidecar_failure_snapshot()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> { pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> {
crate::core::service::take_service_fallback_notice() crate::core::service::take_service_fallback_notice()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn get_core_startup_error() -> Option<crate::core::manager::CoreFailure> { pub fn get_core_startup_error() -> Option<crate::core::manager::CoreFailure> {
crate::core::CoreManager::global().get_startup_error() crate::core::CoreManager::global().get_startup_error()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn take_service_repair_notice() -> bool { pub fn take_service_repair_notice() -> bool {
crate::core::service::take_service_repair_notice() crate::core::service::take_service_repair_notice()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn take_service_owner_notice() -> Option<String> { pub fn take_service_owner_notice() -> Option<String> {
crate::core::service::take_service_owner_notice() crate::core::service::take_service_owner_notice()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn sync_runtime_providers() { pub fn sync_runtime_providers() {
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) { if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE); request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE);
+2
View File
@@ -5,11 +5,13 @@ use crate::core::{
/// Returns one coherent core/service snapshot instead of independently refreshed state. /// Returns one coherent core/service snapshot instead of independently refreshed state.
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn get_runtime_state() -> Result<RunStateView, String> { pub async fn get_runtime_state() -> Result<RunStateView, String> {
Ok(RUN_STATE.settled().await.to_view()) Ok(RUN_STATE.settled().await.to_view())
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn get_pending_failures() -> Vec<PendingFailure> { pub async fn get_pending_failures() -> Vec<PendingFailure> {
notification::pending_failures() notification::pending_failures()
} }
+1
View File
@@ -22,6 +22,7 @@ mod platform {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn invoke_uwp_tool() -> CmdResult { pub async fn invoke_uwp_tool() -> CmdResult {
platform::invoke_uwp_tool() platform::invoke_uwp_tool()
} }
+2
View File
@@ -9,12 +9,14 @@ use clash_verge_draft::SharedDraft;
/// 获取Verge配置 /// 获取Verge配置
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn get_verge_config() -> CmdResult<SharedDraft<IVerge>> { pub async fn get_verge_config() -> CmdResult<SharedDraft<IVerge>> {
feat::fetch_verge_config().await.stringify_err() feat::fetch_verge_config().await.stringify_err()
} }
/// 修改Verge配置 /// 修改Verge配置
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn patch_verge_config(payload: IVerge) -> CmdResult { pub async fn patch_verge_config(payload: IVerge) -> CmdResult {
let operation = system_proxy_operation(&payload); let operation = system_proxy_operation(&payload);
let result = match operation { let result = match operation {
+22 -6
View File
@@ -4,11 +4,18 @@ use crate::{
config::{Config, IVerge}, config::{Config, IVerge},
core, feat, core, feat,
}; };
use reqwest_dav::list_cmd::ListFile;
use smartstring::alias::String; use smartstring::alias::String;
#[tauri::command] #[tauri::command]
pub async fn save_webdav_config(url: String, username: String, password: String) -> CmdResult<()> { #[specta::specta]
pub async fn save_webdav_config(
url: std::string::String,
username: std::string::String,
password: std::string::String,
) -> CmdResult<()> {
let url: String = url.into();
let username: String = username.into();
let password: String = password.into();
let patch = IVerge { let patch = IVerge {
webdav_url: Some(url), webdav_url: Some(url),
webdav_username: Some(username), webdav_username: Some(username),
@@ -25,22 +32,31 @@ pub async fn save_webdav_config(url: String, username: String, password: String)
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn create_webdav_backup() -> CmdResult<()> { pub async fn create_webdav_backup() -> CmdResult<()> {
feat::create_backup_and_upload_webdav().await.stringify_err() feat::create_backup_and_upload_webdav().await.stringify_err()
} }
#[tauri::command] #[tauri::command]
pub async fn list_webdav_backup() -> CmdResult<Vec<ListFile>> { #[specta::specta]
feat::list_wevdav_backup().await.stringify_err() pub async fn list_webdav_backup() -> CmdResult<Vec<crate::ipc::WebDavFile>> {
feat::list_wevdav_backup()
.await
.map(|files| files.into_iter().map(Into::into).collect())
.stringify_err()
} }
#[tauri::command] #[tauri::command]
pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> { #[specta::specta]
pub async fn delete_webdav_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
feat::delete_webdav_backup(filename).await.stringify_err() feat::delete_webdav_backup(filename).await.stringify_err()
} }
#[tauri::command] #[tauri::command]
pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> { #[specta::specta]
pub async fn restore_webdav_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
feat::restore_webdav_backup(filename) feat::restore_webdav_backup(filename)
.await .await
.map_err(|error| super::proxy_aware_error(&error)) .map_err(|error| super::proxy_aware_error(&error))
+6 -6
View File
@@ -308,7 +308,7 @@ impl IClashTemp {
} }
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
pub struct ClashInfo { pub struct ClashInfo {
/// clash core port /// clash core port
pub mixed_port: u16, pub mixed_port: u16,
@@ -364,14 +364,14 @@ fn test_clash_info() {
assert_eq!(get_case(8888, "192.168.1.1:80800"), get_result(8888, "127.0.0.1:9097")); assert_eq!(get_case(8888, "192.168.1.1:80800"), get_result(8888, "127.0.0.1:9097"));
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct IClashExternalControllerCors { pub struct IClashExternalControllerCors {
pub allow_origins: Option<Vec<String>>, pub allow_origins: Option<Vec<String>>,
pub allow_private_network: Option<bool>, pub allow_private_network: Option<bool>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct IClash { pub struct IClash {
pub mixed_port: Option<u16>, pub mixed_port: Option<u16>,
@@ -387,7 +387,7 @@ pub struct IClash {
pub external_controller_cors: Option<IClashExternalControllerCors>, pub external_controller_cors: Option<IClashExternalControllerCors>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct IClashTUN { pub struct IClashTUN {
pub enable: Option<bool>, pub enable: Option<bool>,
@@ -397,7 +397,7 @@ pub struct IClashTUN {
pub dns_hijack: Option<Vec<String>>, pub dns_hijack: Option<Vec<String>>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct IClashDNS { pub struct IClashDNS {
pub enable: Option<bool>, pub enable: Option<bool>,
@@ -414,7 +414,7 @@ pub struct IClashDNS {
pub nameserver_policy: Option<Vec<String>>, pub nameserver_policy: Option<Vec<String>>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct IClashFallbackFilter { pub struct IClashFallbackFilter {
pub geoip: Option<bool>, pub geoip: Option<bool>,
+2 -1
View File
@@ -106,10 +106,11 @@ impl DnsOverrideState {
} }
} }
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize, specta::Type)]
pub struct ProfileDnsSettings { pub struct ProfileDnsSettings {
pub enabled: bool, pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub confirmation: Option<String>, pub confirmation: Option<String>,
} }
+24 -11
View File
@@ -27,28 +27,34 @@ pub(super) fn normalize_profile_home_url(raw: &str) -> Option<String> {
Some(url.to_string().into()) Some(url.to_string().into())
} }
#[derive(Debug, Clone, Deserialize, Serialize, Default)] #[derive(Debug, Clone, Deserialize, Serialize, Default, specta::Type)]
#[serde(default)]
pub struct PrfItem { pub struct PrfItem {
#[specta(type = Option<std::string::String>)]
pub uid: Option<String>, pub uid: Option<String>,
/// enum value: remote | local | script | merge /// enum value: remote | local | script | merge
#[serde(rename = "type")] #[serde(rename = "type")]
#[specta(type = Option<std::string::String>)]
pub itype: Option<String>, pub itype: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>, pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub file: Option<String>, pub file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub desc: Option<String>, pub desc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub url: Option<String>, pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub selected: Option<Vec<PrfSelected>>, pub selected: Option<Vec<PrfSelected>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>, pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -60,19 +66,24 @@ pub struct PrfItem {
pub option: Option<PrfOption>, pub option: Option<PrfOption>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub home: Option<String>, pub home: Option<String>,
#[serde(skip)] #[serde(skip)]
#[specta(type = Option<std::string::String>)]
pub file_data: Option<String>, pub file_data: Option<String>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(default)]
pub struct PrfSelected { pub struct PrfSelected {
#[specta(type = Option<std::string::String>)]
pub name: Option<String>, pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub now: Option<String>, pub now: Option<String>,
} }
#[derive(Default, Debug, Clone, Copy, Deserialize, Serialize)] #[derive(Default, Debug, Clone, Copy, Deserialize, Serialize, specta::Type)]
pub struct PrfExtra { pub struct PrfExtra {
pub upload: u64, pub upload: u64,
pub download: u64, pub download: u64,
@@ -80,9 +91,11 @@ pub struct PrfExtra {
pub expire: u64, pub expire: u64,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(default)]
pub struct PrfOption { pub struct PrfOption {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub user_agent: Option<String>, pub user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -105,15 +118,15 @@ pub struct PrfOption {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub allow_auto_update: Option<bool>, pub allow_auto_update: Option<bool>,
#[specta(type = Option<std::string::String>)]
pub merge: Option<String>, pub merge: Option<String>,
#[specta(type = Option<std::string::String>)]
pub script: Option<String>, pub script: Option<String>,
#[specta(type = Option<std::string::String>)]
pub rules: Option<String>, pub rules: Option<String>,
#[specta(type = Option<std::string::String>)]
pub proxies: Option<String>, pub proxies: Option<String>,
#[specta(type = Option<std::string::String>)]
pub groups: Option<String>, pub groups: Option<String>,
} }
+3 -1
View File
@@ -48,8 +48,10 @@ const SELECTED_NODES_SETTLE_INTERVAL: Duration = Duration::from_secs(1);
const SELECTED_NODES_FIRST_PASS_BUDGET: Duration = Duration::from_secs(3); const SELECTED_NODES_FIRST_PASS_BUDGET: Duration = Duration::from_secs(3);
/// Define the `profiles.yaml` schema /// Define the `profiles.yaml` schema
#[derive(Default, Debug, Clone, Deserialize, Serialize)] #[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
#[serde(default)]
pub struct IProfiles { pub struct IProfiles {
#[specta(type = Option<std::string::String>)]
pub current: Option<String>, pub current: Option<String>,
pub items: Option<Vec<PrfItem>>, pub items: Option<Vec<PrfItem>>,
+45 -14
View File
@@ -11,26 +11,30 @@ use serde::{Deserialize, Serialize};
use smartstring::alias::String; use smartstring::alias::String;
/// ### `verge.yaml` schema /// ### `verge.yaml` schema
#[derive(Default, Debug, Clone, Deserialize, Serialize)] #[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
#[serde(default)]
pub struct IVerge { pub struct IVerge {
/// silent | error | warn | info | debug | trace /// silent | error | warn | info | debug | trace
#[specta(type = Option<std::string::String>)]
pub app_log_level: Option<String>, pub app_log_level: Option<String>,
/// Maximum app log size in KiB. /// Maximum app log size in KiB.
pub app_log_max_size: Option<u64>, pub app_log_max_size: Option<u64>,
pub app_log_max_count: Option<usize>, pub app_log_max_count: Option<usize>,
#[specta(type = Option<std::string::String>)]
pub language: Option<String>, pub language: Option<String>,
/// `light` or `dark` or `system` /// `light` or `dark` or `system`
#[specta(type = Option<std::string::String>)]
pub theme_mode: Option<String>, pub theme_mode: Option<String>,
#[specta(type = Option<std::string::String>)]
pub tray_event: Option<String>, pub tray_event: Option<String>,
#[specta(type = Option<std::string::String>)]
pub env_type: Option<String>, pub env_type: Option<String>,
#[specta(type = Option<std::string::String>)]
pub start_page: Option<String>, pub start_page: Option<String>,
#[specta(type = Option<std::string::String>)]
pub startup_script: Option<String>, pub startup_script: Option<String>,
pub traffic_graph: Option<bool>, pub traffic_graph: Option<bool>,
@@ -48,18 +52,23 @@ pub struct IVerge {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub tray_icon: Option<String>, pub tray_icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub menu_icon: Option<String>, pub menu_icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<Vec<std::string::String>>)]
pub menu_order: Option<Vec<String>>, pub menu_order: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub proxy_group_tools_position: Option<String>, pub proxy_group_tools_position: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub notice_position: Option<String>, pub notice_position: Option<String>,
pub collapse_navbar: Option<bool>, pub collapse_navbar: Option<bool>,
@@ -84,39 +93,43 @@ pub struct IVerge {
pub enable_dns_settings: Option<bool>, pub enable_dns_settings: Option<bool>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
#[specta(type = std::collections::BTreeMap<std::string::String, super::dns::ProfileDnsSettings>)]
pub profile_dns_settings: std::collections::BTreeMap<String, super::dns::ProfileDnsSettings>, pub profile_dns_settings: std::collections::BTreeMap<String, super::dns::ProfileDnsSettings>,
pub use_default_bypass: Option<bool>, pub use_default_bypass: Option<bool>,
#[specta(type = Option<std::string::String>)]
pub system_proxy_bypass: Option<String>, pub system_proxy_bypass: Option<String>,
pub proxy_guard_duration: Option<u64>, pub proxy_guard_duration: Option<u64>,
pub proxy_auto_config: Option<bool>, pub proxy_auto_config: Option<bool>,
#[specta(type = Option<std::string::String>)]
pub pac_file_content: Option<String>, pub pac_file_content: Option<String>,
#[specta(type = Option<std::string::String>)]
pub proxy_host: Option<String>, pub proxy_host: Option<String>,
pub theme_setting: Option<IVergeTheme>, pub theme_setting: Option<IVergeTheme>,
#[specta(type = Option<Vec<std::string::String>>)]
pub web_ui_list: Option<Vec<String>>, pub web_ui_list: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub clash_core: Option<String>, pub clash_core: Option<String>,
/// format: {func},{key} /// format: {func},{key}
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<Vec<std::string::String>>)]
pub hotkeys: Option<Vec<String>>, pub hotkeys: Option<Vec<String>>,
pub enable_global_hotkey: Option<bool>, pub enable_global_hotkey: Option<bool>,
#[specta(type = Option<crate::ipc::JsonValue>)]
pub home_cards: Option<serde_json::Value>, pub home_cards: Option<serde_json::Value>,
pub auto_close_connection: Option<bool>, pub auto_close_connection: Option<bool>,
pub auto_check_update: Option<bool>, pub auto_check_update: Option<bool>,
#[specta(type = Option<std::string::String>)]
pub default_latency_test: Option<String>, pub default_latency_test: Option<String>,
pub default_latency_timeout: Option<i16>, pub default_latency_timeout: Option<i16>,
@@ -171,6 +184,7 @@ pub struct IVerge {
skip_serializing_if = "Option::is_none", skip_serializing_if = "Option::is_none",
default default
)] )]
#[specta(type = Option<std::string::String>)]
pub webdav_url: Option<String>, pub webdav_url: Option<String>,
#[serde( #[serde(
@@ -179,6 +193,7 @@ pub struct IVerge {
skip_serializing_if = "Option::is_none", skip_serializing_if = "Option::is_none",
default default
)] )]
#[specta(type = Option<std::string::String>)]
pub webdav_username: Option<String>, pub webdav_username: Option<String>,
#[serde( #[serde(
@@ -187,12 +202,14 @@ pub struct IVerge {
skip_serializing_if = "Option::is_none", skip_serializing_if = "Option::is_none",
default default
)] )]
#[specta(type = Option<std::string::String>)]
pub webdav_password: Option<String>, pub webdav_password: Option<String>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub enable_tray_speed: Option<bool>, pub enable_tray_speed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub tray_proxy_groups_display_mode: Option<String>, pub tray_proxy_groups_display_mode: Option<String>,
pub tray_inline_outbound_modes: Option<bool>, pub tray_inline_outbound_modes: Option<bool>,
@@ -209,27 +226,41 @@ pub struct IVerge {
pub enable_external_controller: Option<bool>, pub enable_external_controller: Option<bool>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize)] #[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
#[serde(default)]
pub struct IVergeTestItem { pub struct IVergeTestItem {
#[specta(type = Option<std::string::String>)]
pub uid: Option<String>, pub uid: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>, pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub icon: Option<String>, pub icon: Option<String>,
#[specta(type = Option<std::string::String>)]
pub url: Option<String>, pub url: Option<String>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize)] #[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
#[serde(default)]
pub struct IVergeTheme { pub struct IVergeTheme {
#[specta(type = Option<std::string::String>)]
pub primary_color: Option<String>, pub primary_color: Option<String>,
#[specta(type = Option<std::string::String>)]
pub secondary_color: Option<String>, pub secondary_color: Option<String>,
#[specta(type = Option<std::string::String>)]
pub primary_text: Option<String>, pub primary_text: Option<String>,
#[specta(type = Option<std::string::String>)]
pub secondary_text: Option<String>, pub secondary_text: Option<String>,
#[specta(type = Option<std::string::String>)]
pub info_color: Option<String>, pub info_color: Option<String>,
#[specta(type = Option<std::string::String>)]
pub error_color: Option<String>, pub error_color: Option<String>,
#[specta(type = Option<std::string::String>)]
pub warning_color: Option<String>, pub warning_color: Option<String>,
#[specta(type = Option<std::string::String>)]
pub success_color: Option<String>, pub success_color: Option<String>,
#[specta(type = Option<std::string::String>)]
pub font_family: Option<String>, pub font_family: Option<String>,
#[specta(type = Option<std::string::String>)]
pub css_injection: Option<String>, pub css_injection: Option<String>,
} }
+6 -6
View File
@@ -38,7 +38,7 @@ pub(crate) fn proxy_listener_keys() -> impl Iterator<Item = &'static str> {
PROXY_LISTENERS.iter().map(|(key, _, _)| *key) PROXY_LISTENERS.iter().map(|(key, _, _)| *key)
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProxyPortSettings { pub struct ProxyPortSettings {
pub mixed_port: u16, pub mixed_port: u16,
@@ -48,27 +48,27 @@ pub struct ProxyPortSettings {
pub tproxy: ToggleableProxyPort, pub tproxy: ToggleableProxyPort,
} }
#[derive(Clone, Copy, Debug, Deserialize)] #[derive(Clone, Copy, Debug, Deserialize, specta::Type)]
pub struct ToggleableProxyPort { pub struct ToggleableProxyPort {
pub enabled: bool, pub enabled: bool,
pub port: u16, pub port: u16,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ListenerProbe { pub struct ListenerProbe {
pub address: String, pub address: String,
pub transports: Vec<ListenerTransport>, pub transports: Vec<ListenerTransport>,
} }
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash, Serialize)] #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash, Serialize, specta::Type)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ListenerTransport { pub enum ListenerTransport {
Tcp, Tcp,
Udp, Udp,
} }
#[derive(Debug, PartialEq, Eq, Serialize)] #[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(tag = "status", rename_all = "camelCase")] #[serde(tag = "status", rename_all = "camelCase")]
pub enum ListenerProbeOutcome { pub enum ListenerProbeOutcome {
Available, Available,
@@ -77,7 +77,7 @@ pub enum ListenerProbeOutcome {
Indeterminate { message: String }, Indeterminate { message: String },
} }
#[derive(Debug, PartialEq, Eq, Serialize)] #[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(tag = "status", rename_all = "camelCase")] #[serde(tag = "status", rename_all = "camelCase")]
pub enum SaveProxyPortsOutcome { pub enum SaveProxyPortsOutcome {
Saved, Saved,
+2 -2
View File
@@ -57,7 +57,7 @@ fn claim_core_readiness_generation(state: &AtomicU64, captured_generation: u64)
.is_ok() .is_ok()
} }
#[derive(Clone, Copy, Debug, serde::Serialize, PartialEq, Eq)] #[derive(Clone, Copy, Debug, serde::Serialize, PartialEq, Eq, specta::Type)]
pub enum RunningMode { pub enum RunningMode {
Service, Service,
Sidecar, Sidecar,
@@ -75,7 +75,7 @@ impl fmt::Display for RunningMode {
} }
/// Why the Core is not running, kept until a Core starts again. /// Why the Core is not running, kept until a Core starts again.
#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)] #[derive(Clone, Debug, serde::Serialize, PartialEq, Eq, specta::Type)]
#[serde(tag = "kind", content = "detail", rename_all = "camelCase")] #[serde(tag = "kind", content = "detail", rename_all = "camelCase")]
pub enum CoreFailure { pub enum CoreFailure {
StartFailed(String), StartFailed(String),
+1 -4
View File
@@ -52,10 +52,7 @@ impl Handle {
/// Sent on every transition, so the frontend does not have to poll to notice that the Core /// Sent on every transition, so the frontend does not have to poll to notice that the Core
/// stopped or that the Service came back. /// stopped or that the Service came back.
pub fn notify_run_state(state: &crate::core::runstate::RunStateView) { pub fn notify_run_state(state: &crate::core::runstate::RunStateView) {
let Ok(state) = serde_json::to_value(state) else { Self::send_event(FrontendEvent::RunStateChanged { state: state.clone() });
return;
};
Self::send_event(FrontendEvent::RunStateChanged { state });
} }
pub fn notify_profile_changed(profile_id: &String) { pub fn notify_profile_changed(profile_id: &String) {
+2 -2
View File
@@ -3,9 +3,8 @@ pub use announce::{Refresh, announce};
pub mod desktop; pub mod desktop;
pub mod handle; pub mod handle;
pub mod notification; pub mod notification;
pub use notification::frontend_wire_contract;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, specta::Type)]
pub enum NoticeStatus { pub enum NoticeStatus {
#[serde(rename = "info")] #[serde(rename = "info")]
Info, Info,
@@ -83,6 +82,7 @@ impl NoticeStatus {
/// Every variant in golden order. The wire-contract export and the golden /// Every variant in golden order. The wire-contract export and the golden
/// test both derive their variant list from here; expected strings stay /// test both derive their variant list from here; expected strings stay
/// hand-written in the test so renames cannot slip through. /// hand-written in the test so renames cannot slip through.
#[cfg(test)]
pub const ALL: [Self; 34] = [ pub const ALL: [Self; 34] = [
Self::Info, Self::Info,
Self::ImportSubUrlOk, Self::ImportSubUrlOk,
+61 -58
View File
@@ -1,6 +1,7 @@
use super::NoticeStatus; use super::NoticeStatus;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use parking_lot::Mutex; use parking_lot::Mutex;
#[cfg(test)]
use serde_json::json; use serde_json::json;
use smartstring::alias::String; use smartstring::alias::String;
use std::sync::Arc; use std::sync::Arc;
@@ -31,7 +32,7 @@ pub enum FrontendEvent<'a> {
uid: &'a String, uid: &'a String,
}, },
RunStateChanged { RunStateChanged {
state: serde_json::Value, state: crate::core::runstate::RunStateView,
}, },
PendingFailuresChanged, PendingFailuresChanged,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -42,7 +43,7 @@ pub enum FrontendEvent<'a> {
/// Operation associated with a pending failure. /// Operation associated with a pending failure.
#[allow(clippy::enum_variant_names)] #[allow(clippy::enum_variant_names)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum FailedOperation { pub enum FailedOperation {
SystemProxyEnable, SystemProxyEnable,
@@ -72,12 +73,14 @@ impl FailedOperation {
} }
/// Latest unresolved failure for one stable code. /// Latest unresolved failure for one stable code.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PendingFailure { pub struct PendingFailure {
/// Stable code used as the table key. /// Stable code used as the table key.
#[specta(type = std::string::String)]
pub code: String, pub code: String,
/// Full diagnostic context chain. /// Full diagnostic context chain.
#[specta(type = std::string::String)]
pub detail: String, pub detail: String,
pub operation: FailedOperation, pub operation: FailedOperation,
/// Monotonic identity for repeated failures under the same code. /// Monotonic identity for repeated failures under the same code.
@@ -241,9 +244,10 @@ pub fn what_was_asked() -> FailedOperation {
static PENDING_FAILURES: LazyLock<FailureTable> = LazyLock::new(FailureTable::default); static PENDING_FAILURES: LazyLock<FailureTable> = LazyLock::new(FailureTable::default);
/// A removal has a revision too, so a delayed read cannot restore a resolved failure. /// A removal has a revision too, so a delayed read cannot restore a resolved failure.
#[derive(Debug, Default, Clone, serde::Serialize)] #[derive(Debug, Default, Clone, serde::Serialize, specta::Type)]
pub struct SidecarFailureSnapshot { pub struct SidecarFailureSnapshot {
pub revision: u64, pub revision: u64,
#[specta(type = Option<std::string::String>)]
pub detail: Option<String>, pub detail: Option<String>,
} }
@@ -327,24 +331,7 @@ impl NotificationSystem {
} }
fn serialize_event(event: FrontendEvent) -> (&'static str, Result<serde_json::Value, serde_json::Error>) { fn serialize_event(event: FrontendEvent) -> (&'static str, Result<serde_json::Value, serde_json::Error>) {
match event { serialize_frontend_event(event)
FrontendEvent::RefreshClash => ("verge://refresh-clash-config", Ok(json!("yes"))),
FrontendEvent::RefreshVerge => ("verge://refresh-verge-config", Ok(json!("yes"))),
FrontendEvent::RefreshProfiles => ("verge://refresh-profiles", Ok(json!("yes"))),
FrontendEvent::RefreshProxyConfig => ("verge://refresh-proxy-config", Ok(serde_json::Value::Null)),
FrontendEvent::NoticeMessage { status, message } => (
"verge://notice-message",
serde_json::to_value((status, message.as_ref())),
),
FrontendEvent::ProfileChanged { current_profile_id } => ("profile-changed", Ok(json!(current_profile_id))),
FrontendEvent::TimerUpdated { profile_index } => ("verge://timer-updated", Ok(json!(profile_index))),
FrontendEvent::ProfileUpdateStarted { uid } => ("profile-update-started", Ok(json!({ "uid": uid }))),
FrontendEvent::ProfileUpdateCompleted { uid } => ("profile-update-completed", Ok(json!({ "uid": uid }))),
FrontendEvent::RunStateChanged { state } => ("verge://run-state-changed", Ok(state)),
FrontendEvent::PendingFailuresChanged => ("verge://pending-failures-changed", Ok(serde_json::Value::Null)),
#[cfg(target_os = "linux")]
FrontendEvent::ThemeChanged { theme } => ("tauri://theme-changed", serde_json::to_value(theme)),
}
} }
pub(crate) fn send_event(app_handle: AppHandle, event: FrontendEvent) { pub(crate) fn send_event(app_handle: AppHandle, event: FrontendEvent) {
@@ -381,20 +368,21 @@ mod wire_tests {
} }
/// One sample per `FrontendEvent` variant, paired with its wire name and payload. /// One sample per `FrontendEvent` variant, paired with its wire name and payload.
/// Shared by the wire golden test and the contract export so the two cannot drift. #[cfg(test)]
fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str, serde_json::Value)> { fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str, serde_json::Value)> {
let state = crate::core::runstate::RunState {
health: Default::default(),
pending: None,
sidecar_allowed: false,
mode: crate::core::manager::RunningMode::NotRunning,
is_admin: false,
op_in_flight: false,
}
.to_view();
vec![ vec![
( (FrontendEvent::RefreshClash, "verge://refresh-clash-config", json!(null)),
FrontendEvent::RefreshClash, (FrontendEvent::RefreshVerge, "verge://refresh-verge-config", json!(null)),
"verge://refresh-clash-config", (FrontendEvent::RefreshProfiles, "verge://refresh-profiles", json!(null)),
json!("yes"),
),
(
FrontendEvent::RefreshVerge,
"verge://refresh-verge-config",
json!("yes"),
),
(FrontendEvent::RefreshProfiles, "verge://refresh-profiles", json!("yes")),
( (
FrontendEvent::RefreshProxyConfig, FrontendEvent::RefreshProxyConfig,
"verge://refresh-proxy-config", "verge://refresh-proxy-config",
@@ -414,11 +402,9 @@ fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str,
json!("profile"), json!("profile"),
), ),
( (
FrontendEvent::RunStateChanged { FrontendEvent::RunStateChanged { state: state.clone() },
state: json!({"running": true}),
},
"verge://run-state-changed", "verge://run-state-changed",
json!({"running": true}), serde_json::to_value(state).unwrap_or(serde_json::Value::Null),
), ),
( (
FrontendEvent::PendingFailuresChanged, FrontendEvent::PendingFailuresChanged,
@@ -445,24 +431,41 @@ fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str,
] ]
} }
/// Machine-readable form of the frontend wire contract: every event name with a macro_rules! frontend_events {
/// serialized sample payload, plus every notice status string. The sample ($( $field:ident: $payload:ty = $name:literal, $pattern:pat => $value:expr; )*) => {
/// payload's JSON shape (string / null / array / object) is what the frontend #[derive(serde::Serialize, specta::Type)]
/// generator maps to TypeScript types. `ThemeChanged` is a Tauri built-in pub struct VergeEventPayloads {
/// linux-only window event, not part of this contract. $(#[serde(rename = $name)] pub $field: $payload,)*
pub fn frontend_wire_contract() -> serde_json::Value { }
let uid = String::from("sample");
let events: Vec<_> = sample_frontend_events(&uid) fn serialize_frontend_event(event: FrontendEvent) -> (&'static str, Result<serde_json::Value, serde_json::Error>) {
.into_iter() match event {
.map(|(event, _, _)| { $($pattern => {
let (name, payload) = NotificationSystem::serialize_event(event); let payload: $payload = $value;
json!({ "name": name, "sample": payload.unwrap_or(serde_json::Value::Null) }) ($name, serde_json::to_value(payload))
}) },)*
.collect(); #[cfg(target_os = "linux")]
let statuses: Vec<_> = NoticeStatus::ALL FrontendEvent::ThemeChanged { theme } => ("tauri://theme-changed", serde_json::to_value(theme)),
.iter() }
// A unit variant with a string rename cannot fail to serialize. }
.filter_map(|status| serde_json::to_value(status).ok()) };
.collect(); }
json!({ "version": 1, "events": events, "noticeStatuses": statuses })
frontend_events! {
refresh_clash: () = "verge://refresh-clash-config", FrontendEvent::RefreshClash => ();
refresh_verge: () = "verge://refresh-verge-config", FrontendEvent::RefreshVerge => ();
refresh_profiles: () = "verge://refresh-profiles", FrontendEvent::RefreshProfiles => ();
refresh_proxy: () = "verge://refresh-proxy-config", FrontendEvent::RefreshProxyConfig => ();
notice: (NoticeStatus, std::string::String) = "verge://notice-message", FrontendEvent::NoticeMessage { status, ref message } => (status, message.to_string());
profile_changed: std::string::String = "profile-changed", FrontendEvent::ProfileChanged { current_profile_id } => current_profile_id.to_string();
timer_updated: std::string::String = "verge://timer-updated", FrontendEvent::TimerUpdated { profile_index } => profile_index.to_string();
profile_started: ProfileUpdatePayload = "profile-update-started", FrontendEvent::ProfileUpdateStarted { uid } => ProfileUpdatePayload { uid: uid.to_string() };
profile_completed: ProfileUpdatePayload = "profile-update-completed", FrontendEvent::ProfileUpdateCompleted { uid } => ProfileUpdatePayload { uid: uid.to_string() };
run_state: crate::core::runstate::RunStateView = "verge://run-state-changed", FrontendEvent::RunStateChanged { state } => state;
pending_failures: () = "verge://pending-failures-changed", FrontendEvent::PendingFailuresChanged => ();
}
#[derive(serde::Serialize, specta::Type)]
pub struct ProfileUpdatePayload {
pub uid: std::string::String,
} }
+43 -19
View File
@@ -1,6 +1,6 @@
use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::collections::{BTreeMap, BTreeSet, HashMap};
use serde::Serialize; use serde::{Serialize, Serializer, ser::SerializeMap as _};
use tauri_plugin_mihomo::models::{ use tauri_plugin_mihomo::models::{
DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType, DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
}; };
@@ -20,7 +20,7 @@ pub struct GroupScope {
pub struct ProxyViewBuilder; pub struct ProxyViewBuilder;
#[derive(Debug, PartialEq, Eq, Serialize)] #[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProxyViewV1 { pub struct ProxyViewV1 {
pub schema_version: u8, pub schema_version: u8,
@@ -34,21 +34,21 @@ pub struct ProxyViewV1 {
pub providers: Vec<ProxyProviderView>, pub providers: Vec<ProxyProviderView>,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ProxyViewOrderSource { pub enum ProxyViewOrderSource {
Runtime, Runtime,
Fallback, Fallback,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ProxyViewProviderState { pub enum ProxyViewProviderState {
Ready, Ready,
Unavailable, Unavailable,
} }
#[derive(Debug, PartialEq, Eq, Serialize)] #[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
pub struct ProxyCapabilities { pub struct ProxyCapabilities {
pub udp: bool, pub udp: bool,
pub xudp: bool, pub xudp: bool,
@@ -57,13 +57,35 @@ pub struct ProxyCapabilities {
pub smux: bool, pub smux: bool,
} }
#[derive(Debug, PartialEq, Eq, Serialize)] #[derive(Serialize, specta::Type)]
struct ProxyLatencyHistory<'a> {
#[specta(type = Vec<crate::ipc::DelayHistory>)]
history: &'a [DelayHistory],
}
fn serialize_proxy_latency_history<S: Serializer>(
extra: &HashMap<String, Extra>,
serializer: S,
) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(extra.len()))?;
for (url, extra) in extra {
map.serialize_entry(
url,
&ProxyLatencyHistory {
history: &extra.history,
},
)?;
}
map.end()
}
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProxyGroupView { pub struct ProxyGroupView {
pub name: String, pub name: String,
#[serde(rename = "type")] #[serde(rename = "type")]
#[specta(type = String)]
pub proxy_type: ProxyType, pub proxy_type: ProxyType,
pub alive: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub now: Option<String>, pub now: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -74,22 +96,28 @@ pub struct ProxyGroupView {
pub icon: Option<String>, pub icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub test_url: Option<String>, pub test_url: Option<String>,
#[specta(type = Vec<crate::ipc::DelayHistory>)]
pub history: Vec<DelayHistory>, pub history: Vec<DelayHistory>,
#[serde(serialize_with = "serialize_proxy_latency_history")]
#[specta(type = HashMap<String, ProxyLatencyHistory<'static>>)]
pub extra: HashMap<String, Extra>, pub extra: HashMap<String, Extra>,
#[serde(flatten)] #[serde(flatten)]
pub capabilities: ProxyCapabilities, pub capabilities: ProxyCapabilities,
pub members: Vec<ProxyMemberRef>, pub members: Vec<ProxyMemberRef>,
} }
#[derive(Debug, PartialEq, Eq, Serialize)] #[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProxyNodeView { pub struct ProxyNodeView {
pub record_id: String, pub record_id: String,
pub name: String, pub name: String,
#[serde(rename = "type")] #[serde(rename = "type")]
#[specta(type = String)]
pub proxy_type: ProxyType, pub proxy_type: ProxyType,
pub alive: bool, #[specta(type = Vec<crate::ipc::DelayHistory>)]
pub history: Vec<DelayHistory>, pub history: Vec<DelayHistory>,
#[serde(serialize_with = "serialize_proxy_latency_history")]
#[specta(type = HashMap<String, ProxyLatencyHistory<'static>>)]
pub extra: HashMap<String, Extra>, pub extra: HashMap<String, Extra>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
@@ -104,7 +132,7 @@ pub struct ProxyNodeView {
pub source: ProxyNodeSource, pub source: ProxyNodeSource,
} }
#[derive(Debug, PartialEq, Eq, Serialize)] #[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(tag = "kind", rename_all = "camelCase")] #[serde(tag = "kind", rename_all = "camelCase")]
pub enum ProxyNodeSource { pub enum ProxyNodeSource {
Core { Core {
@@ -119,7 +147,7 @@ pub enum ProxyNodeSource {
}, },
} }
#[derive(Debug, PartialEq, Eq, Serialize)] #[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(tag = "kind", rename_all = "camelCase")] #[serde(tag = "kind", rename_all = "camelCase")]
pub enum ProxyMemberRef { pub enum ProxyMemberRef {
Group { Group {
@@ -136,7 +164,7 @@ pub enum ProxyMemberRef {
}, },
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, specta::Type)]
pub enum ProxyMemberUnresolvedReason { pub enum ProxyMemberUnresolvedReason {
#[serde(rename = "missing")] #[serde(rename = "missing")]
Missing, Missing,
@@ -146,7 +174,7 @@ pub enum ProxyMemberUnresolvedReason {
ProviderUnavailable, ProviderUnavailable,
} }
#[derive(Debug, PartialEq, Eq, Serialize)] #[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProxyProviderView { pub struct ProxyProviderView {
pub name: String, pub name: String,
@@ -158,7 +186,7 @@ pub struct ProxyProviderView {
pub proxy_record_ids: Vec<String>, pub proxy_record_ids: Vec<String>,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, specta::Type)]
pub enum ProxyProviderVehicleType { pub enum ProxyProviderVehicleType {
#[serde(rename = "HTTP")] #[serde(rename = "HTTP")]
Http, Http,
@@ -168,7 +196,7 @@ pub enum ProxyProviderVehicleType {
Inline, Inline,
} }
#[derive(Debug, PartialEq, Eq, Serialize)] #[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
pub struct ProxySubscriptionInfo { pub struct ProxySubscriptionInfo {
pub upload: i64, pub upload: i64,
pub download: i64, pub download: i64,
@@ -402,7 +430,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
icon, icon,
now, now,
test_url, test_url,
alive,
history, history,
extra, extra,
udp, udp,
@@ -431,7 +458,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
ProxyGroupView { ProxyGroupView {
name, name,
proxy_type, proxy_type,
alive,
now, now,
fixed, fixed,
hidden, hidden,
@@ -456,7 +482,6 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
hidden, hidden,
icon, icon,
test_url, test_url,
alive,
history, history,
extra, extra,
udp, udp,
@@ -472,7 +497,6 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
record_id, record_id,
name, name,
proxy_type, proxy_type,
alive,
history, history,
extra, extra,
id, id,
+21 -11
View File
@@ -14,13 +14,13 @@ pub enum ServiceHealth {
} }
impl ServiceHealth { impl ServiceHealth {
const fn kind(&self) -> &'static str { const fn kind(&self) -> ServiceHealthKind {
match self { match self {
Self::Unknown => "unknown", Self::Unknown => ServiceHealthKind::Unknown,
Self::Ready => "ready", Self::Ready => ServiceHealthKind::Ready,
Self::NotInstalled => "notInstalled", Self::NotInstalled => ServiceHealthKind::NotInstalled,
Self::VersionMismatch => "versionMismatch", Self::VersionMismatch => ServiceHealthKind::VersionMismatch,
Self::Unavailable(_) => "unavailable", Self::Unavailable(_) => ServiceHealthKind::Unavailable,
} }
} }
@@ -32,7 +32,7 @@ impl ServiceHealth {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum PendingAction { pub enum PendingAction {
Install, Install,
@@ -118,11 +118,11 @@ impl RunState {
} }
} }
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RunStateView { pub struct RunStateView {
pub mode: RunningMode, pub mode: RunningMode,
pub service: &'static str, pub service: ServiceHealthKind,
pub service_unavailable_reason: Option<String>, pub service_unavailable_reason: Option<String>,
pub pending_action: Option<PendingAction>, pub pending_action: Option<PendingAction>,
pub sidecar_allowed: bool, pub sidecar_allowed: bool,
@@ -232,7 +232,7 @@ mod tests {
let view = run_state.to_view(); let view = run_state.to_view();
assert_eq!(view.service, "unavailable"); assert_eq!(view.service, ServiceHealthKind::Unavailable);
assert_eq!(view.service_unavailable_reason.as_deref(), Some("socket refused")); assert_eq!(view.service_unavailable_reason.as_deref(), Some("socket refused"));
assert_eq!(view.pending_action, Some(PendingAction::Reinstall)); assert_eq!(view.pending_action, Some(PendingAction::Reinstall));
assert!(view.tun_capable, "elevation alone makes TUN possible"); assert!(view.tun_capable, "elevation alone makes TUN possible");
@@ -244,7 +244,7 @@ mod tests {
fn a_healthy_view_reports_no_unavailable_reason() { fn a_healthy_view_reports_no_unavailable_reason() {
let view = state(ServiceHealth::Ready, false, false).to_view(); let view = state(ServiceHealth::Ready, false, false).to_view();
assert_eq!(view.service, "ready"); assert_eq!(view.service, ServiceHealthKind::Ready);
assert_eq!(view.service_unavailable_reason, None); assert_eq!(view.service_unavailable_reason, None);
assert!(view.service_usable); assert!(view.service_usable);
assert!(!view.service_needs_attention); assert!(!view.service_needs_attention);
@@ -350,3 +350,13 @@ mod tests {
assert!(settled.tun_should_be_disabled(true)); assert!(settled.tun_should_be_disabled(true));
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum ServiceHealthKind {
Unknown,
Ready,
NotInstalled,
VersionMismatch,
Unavailable,
}
+1 -1
View File
@@ -44,7 +44,7 @@ static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None); static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
/// Why the Service was unavailable when the core fell back to Sidecar. /// Why the Service was unavailable when the core fell back to Sidecar.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(tag = "kind", content = "reason", rename_all = "camelCase")] #[serde(tag = "kind", content = "reason", rename_all = "camelCase")]
pub enum ServiceFallbackNotice { pub enum ServiceFallbackNotice {
Unavailable, Unavailable,
+1 -1
View File
@@ -261,7 +261,7 @@ impl Default for Sysopt {
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
static DEFAULT_BYPASS: &str = "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;<local>"; static DEFAULT_BYPASS: &str = "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;*.local;<local>";
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
static BYPASS_SEPARATOR: &str = ";"; static BYPASS_SEPARATOR: &str = ";";
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
+9 -1
View File
@@ -1160,7 +1160,15 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
logging!(error, Type::Tray, "Failed to close all connections from tray: {err}"); logging!(error, Type::Tray, "Failed to close all connections from tray: {err}");
} }
} }
MenuIds::COPY_ENV => feat::copy_clash_env().await, MenuIds::COPY_ENV => {
if let Err(err) = feat::copy_clash_env().await {
logging!(
error,
Type::Tray,
"Failed to copy proxy environment variables from tray: {err:#}"
);
}
}
MenuIds::CONF_DIR => { MenuIds::CONF_DIR => {
let _ = cmd::open_app_dir().await; let _ = cmd::open_app_dir().await;
} }
+2 -2
View File
@@ -34,7 +34,7 @@ impl SilentUpdater {
} }
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, specta::Type)]
struct UpdateCacheMeta { struct UpdateCacheMeta {
version: String, version: String,
downloaded_at: String, downloaded_at: String,
@@ -214,7 +214,7 @@ fn nsis_language_id(app_language: &str) -> &'static str {
} }
/// Same wire shape as the updater plugin's JS `DownloadEvent`. /// Same wire shape as the updater plugin's JS `DownloadEvent`.
#[derive(Clone, Serialize)] #[derive(Clone, Serialize, specta::Type)]
#[serde(tag = "event", content = "data")] #[serde(tag = "event", content = "data")]
pub enum DownloadEvent { pub enum DownloadEvent {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
+11 -5
View File
@@ -51,7 +51,7 @@ pub struct CoreConfigValidator {
is_processing: AtomicBool, is_processing: AtomicBool,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum ValidationErrorKind { pub enum ValidationErrorKind {
FileMissing, FileMissing,
@@ -94,7 +94,7 @@ impl ValidationErrorKind {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum ValidationSkipReason { pub enum ValidationSkipReason {
Exiting, Exiting,
@@ -110,12 +110,18 @@ impl fmt::Display for ValidationSkipReason {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, specta::Type)]
#[serde(tag = "status", rename_all = "camelCase")] #[serde(tag = "status", rename_all = "camelCase")]
pub enum ValidationOutcome { pub enum ValidationOutcome {
Valid, Valid,
Invalid { kind: ValidationErrorKind, message: String }, Invalid {
Skipped { reason: ValidationSkipReason }, kind: ValidationErrorKind,
#[specta(type = std::string::String)]
message: String,
},
Skipped {
reason: ValidationSkipReason,
},
Busy, Busy,
} }
+4 -1
View File
@@ -16,10 +16,13 @@ use smartstring::alias::String;
use std::path::PathBuf; use std::path::PathBuf;
use tokio::fs; use tokio::fs;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, specta::Type)]
pub struct LocalBackupFile { pub struct LocalBackupFile {
#[specta(type = std::string::String)]
pub filename: String, pub filename: String,
#[specta(type = std::string::String)]
pub path: String, pub path: String,
#[specta(type = std::string::String)]
pub last_modified: String, pub last_modified: String,
pub content_length: u64, pub content_length: u64,
} }
+3 -13
View File
@@ -34,12 +34,10 @@ static STAGING_GENERATION: AtomicU64 = AtomicU64::new(0);
/// `.rollback` and `.old` are fixed paths, so two upgrades must not overlap. /// `.rollback` and `.old` are fixed paths, so two upgrades must not overlap.
static UPGRADE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); static UPGRADE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize, specta::Type)]
pub struct CoreUpgradeReport { pub struct CoreUpgradeReport {
/// False when the managed core was already at the latest version. /// False when the managed core was already at the latest version.
pub upgraded: bool, pub upgraded: bool,
pub from: std::string::String,
pub to: std::string::String,
} }
pub async fn upgrade_core(force: bool) -> Result<CoreUpgradeReport> { pub async fn upgrade_core(force: bool) -> Result<CoreUpgradeReport> {
@@ -62,11 +60,7 @@ pub async fn upgrade_core(force: bool) -> Result<CoreUpgradeReport> {
logging!(debug, Type::Core, "core upgrade: latest version resolved via {proxy:?}"); logging!(debug, Type::Core, "core upgrade: latest version resolved via {proxy:?}");
if !force && installed == latest { if !force && installed == latest {
return Ok(CoreUpgradeReport { return Ok(CoreUpgradeReport { upgraded: false });
upgraded: false,
from: installed,
to: latest,
});
} }
let package = download_package(proxy, alpha, &latest).await?; let package = download_package(proxy, alpha, &latest).await?;
@@ -150,11 +144,7 @@ pub async fn upgrade_core(force: bool) -> Result<CoreUpgradeReport> {
#[cfg(windows)] #[cfg(windows)]
let _ = std::fs::remove_file(target.with_extension("old")); let _ = std::fs::remove_file(target.with_extension("old"));
Ok(CoreUpgradeReport { Ok(CoreUpgradeReport { upgraded: true })
upgraded: true,
from: installed,
to: latest,
})
} }
/// Whether this upgrade handed bytes to the Service's approved core directory. /// Whether this upgrade handed bytes to the Service's approved core directory.
+5 -2
View File
@@ -15,11 +15,14 @@ use crate::{
utils::dirs, utils::dirs,
}; };
#[derive(Serialize)] #[derive(Serialize, specta::Type)]
#[serde(tag = "status", rename_all = "snake_case")] #[serde(tag = "status", rename_all = "snake_case")]
pub enum DnsOverrideOutcome { pub enum DnsOverrideOutcome {
Applied, Applied,
ConfirmationRequired { source: String }, ConfirmationRequired {
#[specta(type = std::string::String)]
source: String,
},
} }
pub async fn set_dns_override( pub async fn set_dns_override(
+4 -1
View File
@@ -8,10 +8,13 @@ use std::path::{Component, Path, PathBuf};
use tokio::fs; use tokio::fs;
use tokio::io::AsyncWriteExt as _; use tokio::io::AsyncWriteExt as _;
#[derive(Debug, serde::Serialize, serde::Deserialize)] #[derive(Debug, serde::Serialize, serde::Deserialize, specta::Type)]
pub struct IconInfo { pub struct IconInfo {
#[specta(type = std::string::String)]
name: String, name: String,
#[specta(type = std::string::String)]
previous_t: String, previous_t: String,
#[specta(type = std::string::String)]
current_t: String, current_t: String,
} }
+6 -6
View File
@@ -11,6 +11,7 @@ use crate::{
window_manager::WindowManager, window_manager::WindowManager,
}, },
}; };
use anyhow::Context as _;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use std::env; use std::env;
use tauri_plugin_clipboard_manager::ClipboardExt as _; use tauri_plugin_clipboard_manager::ClipboardExt as _;
@@ -95,7 +96,7 @@ pub async fn toggle_tun_mode(not_save_file: Option<bool>) -> bool {
} }
} }
pub async fn copy_clash_env() { pub async fn copy_clash_env() -> anyhow::Result<()> {
let env_ip = env::var("CLASH_VERGE_REV_IP").ok(); let env_ip = env::var("CLASH_VERGE_REV_IP").ok();
let verge_cfg = Config::verge().await.latest_arc(); let verge_cfg = Config::verge().await.latest_arc();
let ip = env_ip let ip = env_ip
@@ -133,12 +134,11 @@ pub async fn copy_clash_env() {
} }
"fish" => format!("set -x http_proxy {http_proxy}; set -x https_proxy {http_proxy}"), "fish" => format!("set -x http_proxy {http_proxy}; set -x https_proxy {http_proxy}"),
_ => { _ => {
logging!(error, Type::ProxyMode, "copy_clash_env: Invalid env type! {env_type}"); anyhow::bail!("Invalid environment variable format: {env_type}");
return;
} }
}; };
if let Err(err) = clipboard.write_text(&export_text) { clipboard
logging!(error, Type::ProxyMode, "Failed to write to clipboard: {err}"); .write_text(export_text)
} .context("Failed to write proxy environment variables to clipboard")
} }
+1 -3
View File
@@ -130,7 +130,7 @@ pub async fn open_or_close_dashboard() {
logging!(info, Type::Window, "Window toggle result: {result:?}"); logging!(info, Type::Window, "Window toggle result: {result:?}");
} }
pub async fn quit() -> clash_verge_signal::ShutdownOutcome { pub async fn quit() {
logging!(debug, Type::System, "启动退出流程"); logging!(debug, Type::System, "启动退出流程");
// 设置退出标志 // 设置退出标志
handle::Handle::global().set_is_exiting(); handle::Handle::global().set_is_exiting();
@@ -165,13 +165,11 @@ pub async fn quit() -> clash_verge_signal::ShutdownOutcome {
NoticeStatus::AppQuitCoreStopFailed, NoticeStatus::AppQuitCoreStopFailed,
cleanup_result.stop_error.unwrap_or_default(), cleanup_result.stop_error.unwrap_or_default(),
); );
return clash_verge_signal::ShutdownOutcome::Canceled;
} }
utils::server::shutdown_embedded_server(); utils::server::shutdown_embedded_server();
let app_handle = handle::Handle::app_handle(); let app_handle = handle::Handle::app_handle();
app_handle.exit(if cleanup_result.all_success { 0 } else { 1 }); app_handle.exit(if cleanup_result.all_success { 0 } else { 1 });
clash_verge_signal::ShutdownOutcome::Committed
} }
pub async fn clean_async() -> CleanupResult { pub async fn clean_async() -> CleanupResult {
+234
View File
@@ -0,0 +1,234 @@
use crate::cmd;
pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tauri_specta::Builder::new()
.types(&platform_types())
.commands(tauri_specta::collect_commands![
tauri_plugin_clash_verge_sysinfo::commands::get_system_info,
tauri_plugin_clash_verge_sysinfo::commands::get_app_uptime,
tauri_plugin_clash_verge_sysinfo::commands::export_diagnostic_info::<tauri::Wry>,
cmd::probe_listener,
cmd::save_proxy_ports,
cmd::get_sys_proxy,
cmd::get_auto_proxy,
cmd::get_embedded_server_port,
cmd::open_app_dir,
cmd::open_logs_dir,
cmd::open_core_dir,
cmd::get_network_interfaces,
cmd::get_system_hostname,
cmd::restart_app,
cmd::install_update,
cmd::cancel_update_download,
cmd::restart_core,
cmd::upgrade_clash_core,
cmd::get_runtime_state,
cmd::get_pending_failures,
cmd::get_sidecar_failure,
cmd::entry_lightweight_mode,
cmd::install_service,
cmd::uninstall_service,
cmd::reinstall_service,
cmd::repair_service,
cmd::continue_with_sidecar,
cmd::sync_runtime_providers,
cmd::get_clash_info,
cmd::patch_clash_config,
cmd::patch_clash_mode,
cmd::get_clash_mode,
cmd::change_clash_core,
cmd::get_runtime_config,
cmd::get_proxy_view,
cmd::get_runtime_yaml,
cmd::get_runtime_logs,
cmd::get_runtime_proxy_chain_config,
cmd::update_proxy_chain_config_in_runtime,
cmd::invoke_uwp_tool,
cmd::copy_clash_env,
cmd::sync_tray_proxy_selection,
cmd::record_selected_node,
cmd::forget_selected_node,
cmd::save_dns_config,
cmd::apply_dns_config,
cmd::set_dns_override,
cmd::take_dns_override_notice,
cmd::take_service_fallback_notice,
cmd::get_core_startup_error,
cmd::take_service_repair_notice,
cmd::take_service_owner_notice,
cmd::take_discarded_keys_notice,
cmd::get_dns_config_content,
cmd::validate_dns_config,
cmd::get_clash_logs,
cmd::get_verge_config,
cmd::patch_verge_config,
cmd::test_delay,
cmd::get_app_dir,
cmd::copy_icon_file,
cmd::download_icon_cache,
cmd::open_devtools,
cmd::exit_app,
cmd::get_network_interfaces_info,
cmd::get_profiles,
cmd::enhance_profiles,
cmd::patch_profiles_config,
cmd::view_profile,
cmd::patch_profile,
cmd::create_profile,
cmd::import_profile,
cmd::reorder_profile,
cmd::update_profile,
cmd::delete_profile,
cmd::read_profile_file,
cmd::save_profile_file,
cmd::get_next_update_time,
cmd::create_local_backup,
cmd::list_local_backup,
cmd::delete_local_backup,
cmd::restore_local_backup,
cmd::import_local_backup,
cmd::export_local_backup,
cmd::create_webdav_backup,
cmd::save_webdav_config,
cmd::list_webdav_backup,
cmd::delete_webdav_backup,
cmd::restore_webdav_backup,
cmd::get_unlock_items,
cmd::check_media_unlock,
cmd::check_media_unlock_item,
])
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
.dangerously_cast_bigints_to_number()
.typ::<crate::core::notify::notification::VergeEventPayloads>()
}
// Tauri transports YAML values as JSON; keep YAML storage out of the IPC type graph.
#[derive(serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(transparent)]
pub struct YamlMapping(#[specta(type = std::collections::HashMap<String, JsonValue>)] pub serde_yaml_ng::Mapping);
#[derive(serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(transparent)]
pub struct YamlValue(#[specta(type = JsonValue)] pub serde_yaml_ng::Value);
#[derive(serde::Serialize, specta::Type)]
pub struct WebDavFile {
pub href: String,
#[specta(type = String)]
pub last_modified: chrono::DateTime<chrono::Utc>,
}
impl From<reqwest_dav::list_cmd::ListFile> for WebDavFile {
fn from(file: reqwest_dav::list_cmd::ListFile) -> Self {
Self {
href: file.href,
last_modified: file.last_modified,
}
}
}
#[derive(serde::Serialize, specta::Type)]
pub struct NetworkInterfaceView {
pub name: String,
pub addr: Vec<InterfaceAddress>,
pub mac_addr: Option<String>,
}
#[derive(serde::Serialize, serde::Deserialize, specta::Type)]
pub enum InterfaceAddress {
V4(IpAddress),
V6(IpAddress),
}
#[derive(serde::Serialize, serde::Deserialize, specta::Type)]
pub struct IpAddress {
pub ip: String,
}
impl From<network_interface::NetworkInterface> for NetworkInterfaceView {
fn from(interface: network_interface::NetworkInterface) -> Self {
Self {
name: interface.name,
addr: interface
.addr
.into_iter()
.map(|address| match address {
network_interface::Addr::V4(address) => InterfaceAddress::V4(IpAddress {
ip: address.ip.to_string(),
}),
network_interface::Addr::V6(address) => InterfaceAddress::V6(IpAddress {
ip: address.ip.to_string(),
}),
})
.collect(),
mac_addr: interface.mac_addr,
}
}
}
#[derive(serde::Serialize, serde::Deserialize, specta::Type)]
pub struct DelayHistory {
pub time: String,
pub delay: u16,
}
fn platform_types() -> specta::Types {
use specta::datatype::{DataType, Fields};
let mut types = specta::Types::default().register::<crate::config::IVerge>();
<PlatformVergeFields as specta::Type>::definition(&mut types);
let platform = types
.into_unsorted_iter()
.find(|ty| ty.name == "PlatformVergeFields")
.and_then(|ty| ty.ty.clone());
let Some(DataType::Struct(platform)) = platform else {
unreachable!()
};
let Fields::Named(platform) = platform.fields else {
unreachable!()
};
types.iter_mut(|ty| {
if ty.name != "IVerge" {
return;
}
let Some(DataType::Struct(config)) = &mut ty.ty else {
return;
};
let Fields::Named(fields) = &mut config.fields else {
return;
};
fields
.fields
.retain(|(name, _)| !platform.fields.iter().any(|(platform_name, _)| name == platform_name));
fields.fields.extend(platform.fields.iter().cloned());
});
types
}
// These fields exist only on some hosts; IPC generation covers every desktop target.
#[derive(serde::Serialize, serde::Deserialize, specta::Type)]
struct PlatformVergeFields {
#[specta(optional)]
tray_icon: Option<String>,
#[specta(optional)]
verge_redir_port: Option<u16>,
#[specta(optional)]
verge_redir_enabled: Option<bool>,
#[specta(optional)]
verge_tproxy_port: Option<u16>,
#[specta(optional)]
verge_tproxy_enabled: Option<bool>,
#[specta(optional)]
enable_tray_speed: Option<bool>,
}
#[derive(serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(untagged)]
pub enum JsonValue {
Null(()),
Bool(bool),
Number(f64),
String(String),
// Specta resolves fields in nested functions, where Self is unavailable.
Array(#[specta(type = Vec<JsonValue>)] Vec<Self>),
Object(#[specta(type = std::collections::HashMap<String, JsonValue>)] std::collections::HashMap<String, Self>),
}
+6 -97
View File
@@ -7,6 +7,7 @@ mod constants;
mod core; mod core;
mod enhance; mod enhance;
mod feat; mod feat;
pub mod ipc;
mod process; mod process;
pub mod utils; pub mod utils;
@@ -26,9 +27,6 @@ use tauri_plugin_deep_link::DeepLinkExt as _;
pub static APP_HANDLE: OnceLock<AppHandle> = OnceLock::new(); pub static APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
// Re-exported for src/bin/frontend-contract.rs; the wire contract stays owned
// by core::notify.
pub use crate::core::notify::frontend_wire_contract;
/// Application initialization helper functions /// Application initialization helper functions
mod app_init { mod app_init {
use super::*; use super::*;
@@ -127,100 +125,10 @@ mod app_init {
} }
pub fn generate_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static { pub fn generate_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
tauri::generate_handler![ {
tauri_plugin_clash_verge_sysinfo::commands::get_system_info, let builder = crate::ipc::builder();
tauri_plugin_clash_verge_sysinfo::commands::get_app_uptime, move |invoke| builder.invoke_handler()(invoke)
tauri_plugin_clash_verge_sysinfo::commands::export_diagnostic_info, }
cmd::probe_listener,
cmd::save_proxy_ports,
cmd::get_sys_proxy,
cmd::get_auto_proxy,
cmd::get_embedded_server_port,
cmd::open_app_dir,
cmd::open_logs_dir,
cmd::open_core_dir,
cmd::get_network_interfaces,
cmd::get_system_hostname,
cmd::restart_app,
cmd::install_update,
cmd::cancel_update_download,
cmd::restart_core,
cmd::upgrade_clash_core,
cmd::get_runtime_state,
cmd::get_pending_failures,
cmd::get_sidecar_failure,
cmd::entry_lightweight_mode,
cmd::install_service,
cmd::uninstall_service,
cmd::reinstall_service,
cmd::repair_service,
cmd::continue_with_sidecar,
cmd::sync_runtime_providers,
cmd::get_clash_info,
cmd::patch_clash_config,
cmd::patch_clash_mode,
cmd::get_clash_mode,
cmd::change_clash_core,
cmd::get_runtime_config,
cmd::get_proxy_view,
cmd::get_runtime_yaml,
cmd::get_runtime_logs,
cmd::get_runtime_proxy_chain_config,
cmd::update_proxy_chain_config_in_runtime,
cmd::invoke_uwp_tool,
cmd::copy_clash_env,
cmd::sync_tray_proxy_selection,
cmd::record_selected_node,
cmd::forget_selected_node,
cmd::save_dns_config,
cmd::apply_dns_config,
cmd::set_dns_override,
cmd::take_dns_override_notice,
cmd::take_service_fallback_notice,
cmd::get_core_startup_error,
cmd::take_service_repair_notice,
cmd::take_service_owner_notice,
cmd::take_discarded_keys_notice,
cmd::get_dns_config_content,
cmd::validate_dns_config,
cmd::get_clash_logs,
cmd::get_verge_config,
cmd::patch_verge_config,
cmd::test_delay,
cmd::get_app_dir,
cmd::copy_icon_file,
cmd::download_icon_cache,
cmd::open_devtools,
cmd::exit_app,
cmd::get_network_interfaces_info,
cmd::get_profiles,
cmd::enhance_profiles,
cmd::patch_profiles_config,
cmd::view_profile,
cmd::patch_profile,
cmd::create_profile,
cmd::import_profile,
cmd::reorder_profile,
cmd::update_profile,
cmd::delete_profile,
cmd::read_profile_file,
cmd::save_profile_file,
cmd::get_next_update_time,
cmd::create_local_backup,
cmd::list_local_backup,
cmd::delete_local_backup,
cmd::restore_local_backup,
cmd::import_local_backup,
cmd::export_local_backup,
cmd::create_webdav_backup,
cmd::save_webdav_config,
cmd::list_webdav_backup,
cmd::delete_webdav_backup,
cmd::restore_webdav_backup,
cmd::get_unlock_items,
cmd::check_media_unlock,
cmd::check_media_unlock_item,
]
} }
} }
@@ -322,6 +230,7 @@ pub fn run() -> std::process::ExitCode {
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
resolve::resolve_setup_async(); resolve::resolve_setup_async();
resolve::resolve_setup_sync(); resolve::resolve_setup_sync();
#[cfg(target_os = "linux")]
resolve::init_signal(); resolve::init_signal();
})) { })) {
log_setup_panic("window-core", panic); log_setup_panic("window-core", panic);
+1 -1
View File
@@ -16,7 +16,7 @@ fn main() -> ExitCode {
#[cfg(not(all(feature = "perf-harness", target_os = "macos")))] #[cfg(not(all(feature = "perf-harness", target_os = "macos")))]
fn main() -> ExitCode { fn main() -> ExitCode {
let default_parallelism = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); let default_parallelism = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let worker_limit = std::cmp::min(default_parallelism, 8); let worker_limit = std::cmp::min(default_parallelism, 4);
let blocking_limit = 2 * worker_limit; let blocking_limit = 2 * worker_limit;
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
+2 -1
View File
@@ -20,7 +20,6 @@ use crate::{
utils::{init, server, window_manager::WindowManager}, utils::{init, server, window_manager::WindowManager},
}; };
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
use clash_verge_signal;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub mod dns; pub mod dns;
@@ -189,6 +188,8 @@ async fn init_silent_updater() {
logging!(info, Type::Setup, "Silent updater initialized"); logging!(info, Type::Setup, "Silent updater initialized");
} }
// Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
#[cfg(target_os = "linux")]
pub(crate) fn init_signal() { pub(crate) fn init_signal() {
logging!(debug, Type::Setup, "Initializing signal handlers..."); logging!(debug, Type::Setup, "Initializing signal handlers...");
clash_verge_signal::register(feat::quit); clash_verge_signal::register(feat::quit);
+1 -52
View File
@@ -39,8 +39,6 @@ static INSTANCE_LOCK: OnceLock<std::fs::File> = OnceLock::new();
const PAC_INITIAL_AVAILABLE: bool = false; const PAC_INITIAL_AVAILABLE: bool = false;
static PAC_AVAILABLE: AtomicBool = AtomicBool::new(PAC_INITIAL_AVAILABLE); static PAC_AVAILABLE: AtomicBool = AtomicBool::new(PAC_INITIAL_AVAILABLE);
static COMMANDS_READY: AtomicBool = AtomicBool::new(false); static COMMANDS_READY: AtomicBool = AtomicBool::new(false);
#[cfg(feature = "verge-dev")]
static DEV_QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SingletonDisposition { pub enum SingletonDisposition {
@@ -164,55 +162,12 @@ fn instance_auth(token: String) -> impl warp::Filter<Extract = (), Error = warp:
.untuple_one() .untuple_one()
} }
#[cfg(feature = "verge-dev")]
fn dev_quit_route<F>(
token: String,
commands_ready: &'static AtomicBool,
quit_requested: &'static AtomicBool,
trigger: F,
) -> impl warp::Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone
where
F: Fn() + Clone + Send + Sync + 'static,
{
instance_auth(token)
.and(warp::path!("commands" / "dev" / "quit"))
.and(warp::post())
.and_then(move || {
let trigger = trigger.clone();
async move {
if !commands_ready.load(Ordering::Acquire) {
return Ok::<_, warp::Rejection>(warp::reply::with_status(
String::from("starting"),
warp::http::StatusCode::SERVICE_UNAVAILABLE,
));
}
if quit_requested
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
trigger();
}
Ok(warp::reply::with_status(
String::from("accepted"),
warp::http::StatusCode::ACCEPTED,
))
}
})
}
#[cfg(feature = "verge-dev")]
fn release_dev_quit_latch(quit_requested: &AtomicBool, outcome: clash_verge_signal::ShutdownOutcome) {
if matches!(outcome, clash_verge_signal::ShutdownOutcome::Canceled) {
quit_requested.store(false, Ordering::Release);
}
}
fn start_embedded_server(listener: tokio::net::TcpListener, token: String) { fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
let (shutdown_tx, shutdown_rx) = oneshot::channel(); let (shutdown_tx, shutdown_rx) = oneshot::channel();
let _ = SHUTDOWN_SENDER.set(Mutex::new(Some(shutdown_tx))); let _ = SHUTDOWN_SENDER.set(Mutex::new(Some(shutdown_tx)));
#[cfg(feature = "verge-dev")] #[cfg(feature = "verge-dev")]
let auth = instance_auth(token.clone()); let auth = instance_auth(token);
#[cfg(not(feature = "verge-dev"))] #[cfg(not(feature = "verge-dev"))]
let auth = instance_auth(token); let auth = instance_auth(token);
@@ -278,12 +233,6 @@ fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
}); });
let commands = auth.clone().and(visible).or(auth.and(scheme)).or(pac); let commands = auth.clone().and(visible).or(auth.and(scheme)).or(pac);
#[cfg(feature = "verge-dev")]
let commands = commands.or(dev_quit_route(token, &COMMANDS_READY, &DEV_QUIT_REQUESTED, || {
AsyncHandler::spawn(|| async {
release_dev_quit_latch(&DEV_QUIT_REQUESTED, crate::feat::quit().await);
});
}));
AsyncHandler::spawn(move || async move { AsyncHandler::spawn(move || async move {
warp::serve(commands) warp::serve(commands)
.incoming(listener) .incoming(listener)
+33 -11
View File
@@ -27,8 +27,12 @@ const TRAY_STATUS_ITEM_EXTRA_WIDTH: f64 = 30.0;
const TRAY_STATUS_ITEM_MIN_LENGTH: f64 = 58.0; const TRAY_STATUS_ITEM_MIN_LENGTH: f64 = 58.0;
const NS_VARIABLE_STATUS_ITEM_LENGTH: f64 = -1.0; const NS_VARIABLE_STATUS_ITEM_LENGTH: f64 = -1.0;
/// (button_height, mono_advance, attributes).
type CachedAttrs = (f64, f64, Retained<NSDictionary<NSString, AnyObject>>);
thread_local! { thread_local! {
static LAST_DISPLAY_STR: RefCell<String> = const { RefCell::new(String::new()) }; static LAST_DISPLAY_STR: RefCell<String> = const { RefCell::new(String::new()) };
static CACHED_ATTRS: RefCell<Option<CachedAttrs>> = const { RefCell::new(None) };
} }
fn format_tray_speed(up: u64, down: u64) -> String { fn format_tray_speed(up: u64, down: u64) -> String {
@@ -74,8 +78,26 @@ fn create_attributed_string(
} }
} }
fn status_item_length_for_speed(attr_str: &NSAttributedString) -> f64 { /// Attributes and per-glyph advance, rebuilt only when the button height changes.
(attr_str.size().width.ceil() + TRAY_STATUS_ITEM_EXTRA_WIDTH).max(TRAY_STATUS_ITEM_MIN_LENGTH) fn cached_attributes(button_height: f64) -> (f64, Retained<NSDictionary<NSString, AnyObject>>) {
CACHED_ATTRS.with(|cache| {
if let Some((height, advance, attrs)) = cache.borrow().as_ref()
&& *height == button_height
{
return (*advance, attrs.clone());
}
let attrs = build_attributes(button_height);
let probe = NSString::from_str("0");
let advance = create_attributed_string(&probe, Some(&attrs)).size().width;
*cache.borrow_mut() = Some((button_height, advance, attrs.clone()));
(advance, attrs)
})
}
/// Longest line times the monospaced advance; matches `size().width` for the ASCII-only speed text.
fn arithmetic_width(text: &str, advance: f64) -> f64 {
let glyphs = text.split('\n').map(str::len).max().unwrap_or(0) as f64;
glyphs * advance
} }
fn sync_click_target_frame(button: &NSStatusBarButton) { fn sync_click_target_frame(button: &NSStatusBarButton) {
@@ -89,7 +111,7 @@ fn sync_click_target_frame(button: &NSStatusBarButton) {
} }
/// `with_inner_tray_icon` should run this on the main thread; fail safely if it does not. /// `with_inner_tray_icon` should run this on the main thread; fail safely if it does not.
fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &NSString, show_speed: bool) { fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &str, show_speed: bool) {
let Some(mtm) = MainThreadMarker::new() else { let Some(mtm) = MainThreadMarker::new() else {
logging!(warn, Type::Tray, "托盘速率富文本设置跳过:非主线程调用"); logging!(warn, Type::Tray, "托盘速率富文本设置跳过:非主线程调用");
return; return;
@@ -97,17 +119,19 @@ fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &NSStrin
let Some(button) = status_item.button(mtm) else { let Some(button) = status_item.button(mtm) else {
return; return;
}; };
let ns_string = NSString::from_str(text);
let attr_str = if show_speed { let attr_str = if show_speed {
let attrs = build_attributes(button.bounds().size.height); let (advance, attrs) = cached_attributes(button.bounds().size.height);
let attrs: &NSDictionary<NSString, AnyObject> = &attrs; let attrs: &NSDictionary<NSString, AnyObject> = &attrs;
let attr_str = create_attributed_string(text, Some(attrs)); let attr_str = create_attributed_string(&ns_string, Some(attrs));
let length = status_item_length_for_speed(&attr_str); let width = arithmetic_width(text, advance);
let length = (width.ceil() + TRAY_STATUS_ITEM_EXTRA_WIDTH).max(TRAY_STATUS_ITEM_MIN_LENGTH);
if status_item.length() != length { if status_item.length() != length {
status_item.setLength(length); status_item.setLength(length);
} }
attr_str attr_str
} else { } else {
create_attributed_string(text, None) create_attributed_string(&ns_string, None)
}; };
button.setAttributedTitle(&attr_str); button.setAttributedTitle(&attr_str);
sync_click_target_frame(&button); sync_click_target_frame(&button);
@@ -121,8 +145,7 @@ pub fn set_speed_attributed_title(status_item: &NSStatusItem, up: u64, down: u64
return; return;
} }
*last = speed_text; *last = speed_text;
let ns_string = NSString::from_str(last.as_str()); apply_status_item_attributed_title(status_item, last.as_str(), true);
apply_status_item_attributed_title(status_item, &ns_string, true);
}); });
} }
@@ -130,7 +153,6 @@ pub fn clear_speed_attributed_title(status_item: &NSStatusItem) {
LAST_DISPLAY_STR.with(|last| { LAST_DISPLAY_STR.with(|last| {
last.borrow_mut().clear(); last.borrow_mut().clear();
}); });
let empty = NSString::from_str("");
status_item.setLength(NS_VARIABLE_STATUS_ITEM_LENGTH); status_item.setLength(NS_VARIABLE_STATUS_ITEM_LENGTH);
apply_status_item_attributed_title(status_item, &empty, false); apply_status_item_attributed_title(status_item, "", false);
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"version": "2.5.8", "version": "2.5.9",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": { "bundle": {
"active": true, "active": true,
+4 -4
View File
@@ -423,7 +423,7 @@ interface PersistentProxySelectProps
value: string value: string
selectedName: string selectedName: string
groupName: string groupName: string
fixed?: string fixed?: string | null
selectedDelay: number selectedDelay: number
options: ProxyOption[] options: ProxyOption[]
onChange: (value: string) => void onChange: (value: string) => void
@@ -785,8 +785,8 @@ export const CurrentProxyCard = () => {
// The profile selection is the durable source across core restarts and run modes. // The profile selection is the durable source across core restarts and run modes.
handleSelectChange( handleSelectChange(
selectedGroup.name, selectedGroup.name,
previousProxy, previousProxy!,
selectedGroup.fixed, selectedGroup.fixed!,
)({ )({
target: { value: nextName }, target: { value: nextName },
}) })
@@ -940,7 +940,7 @@ export const CurrentProxyCard = () => {
unsortedProxyOptions, unsortedProxyOptions,
sortType, sortType,
delays.of, delays.of,
defaultLatencyTimeout, defaultLatencyTimeout!,
), ),
[ [
delays, delays,
+9 -25
View File
@@ -56,26 +56,7 @@ const openProfileHome = (url: string) => {
void openExternalUrl(url).catch(showNotice.error) void openExternalUrl(url).catch(showNotice.error)
} }
// 使用类型定义,而不是导入 type ProfileItem = IProfileItem
interface ProfileExtra {
upload: number
download: number
total: number
expire: number
}
interface ProfileItem {
uid: string
type?: 'local' | 'remote' | 'merge' | 'script'
name?: string
desc?: string
file?: string
url?: string
updated?: number
extra?: ProfileExtra
home?: string
option?: any
}
interface HomeProfileCardProps { interface HomeProfileCardProps {
current: ProfileItem | null | undefined current: ProfileItem | null | undefined
@@ -298,10 +279,13 @@ export const HomeProfileCard = ({
setUpdating(true) setUpdating(true)
try { try {
await mutate(() => updateProfile(current.uid, current.option), { await mutate(
id: `update-profile:${current.uid}`, () => updateProfile(current.uid!, current.option ?? undefined),
errorNotice: false, {
}) id: `update-profile:${current.uid}`,
errorNotice: false,
},
)
onProfileUpdated?.() onProfileUpdated?.()
// 刷新首页数据 // 刷新首页数据
@@ -345,7 +329,7 @@ export const HomeProfileCard = ({
flex: 1, flex: 1,
}, },
}} }}
title={current.name} title={current.name!}
> >
<span>{current.name}</span> <span>{current.name}</span>
<LaunchOutlined <LaunchOutlined
+1 -1
View File
@@ -201,7 +201,7 @@ export const ProxyTunCard: FC = () => {
onClick={() => handleTabChange('tun')} onClick={() => handleTabChange('tun')}
icon={TroubleshootRounded} icon={TroubleshootRounded}
label={t('settings.sections.system.toggles.tunMode')} label={t('settings.sections.system.toggles.tunMode')}
hasIndicator={enable_tun_mode && isTunModeAvailable} hasIndicator={enable_tun_mode! && isTunModeAvailable}
/> />
</Stack> </Stack>
+1 -1
View File
@@ -164,7 +164,7 @@ export const TestCard = () => {
}} }}
> >
{testList.map((item, index) => ( {testList.map((item, index) => (
<SortableItem key={item.uid} id={item.uid} index={index}> <SortableItem key={item.uid} id={item.uid!} index={index}>
<TestItem <TestItem
itemData={item} itemData={item}
onEdit={() => viewerRef.current?.edit(item)} onEdit={() => viewerRef.current?.edit(item)}
+18 -18
View File
@@ -120,7 +120,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
) )
const setLoading = useCallback( const setLoading = useCallback(
(loading: boolean) => { (loading: boolean) => {
setProfileLoading([itemData.uid], loading) setProfileLoading([itemData.uid!], loading)
}, },
[itemData.uid, setProfileLoading], [itemData.uid, setProfileLoading],
) )
@@ -140,7 +140,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
debugLog(`强制刷新定时器任务`) debugLog(`强制刷新定时器任务`)
} }
const nextUpdate = await getNextUpdateTime(itemData.uid) const nextUpdate = await getNextUpdateTime(itemData.uid!)
debugLog(`获取到下次更新时间结果:`, nextUpdate) debugLog(`获取到下次更新时间结果:`, nextUpdate)
if (nextUpdate) { if (nextUpdate) {
@@ -245,7 +245,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const hasHome = !!itemData.home // only subscription url has home page const hasHome = !!itemData.home // only subscription url has home page
const { upload = 0, download = 0, total = 0 } = extra ?? {} const { upload = 0, download = 0, total = 0 } = extra ?? {}
const from = parseUrl(itemData.url) const from = parseUrl(itemData.url!)
const description = itemData.desc const description = itemData.desc
const expire = parseExpire(extra?.expire) const expire = parseExpire(extra?.expire)
const progress = Math.min( const progress = Math.min(
@@ -253,7 +253,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
100, 100,
) )
const loading = loadingCache.has(itemData.uid) const loading = loadingCache.has(itemData.uid!)
const [, forceRefresh] = useReducer((value: number) => value + 1, 0) const [, forceRefresh] = useReducer((value: number) => value + 1, 0)
useEffect(() => { useEffect(() => {
@@ -263,7 +263,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const handler = () => { const handler = () => {
const now = Date.now() const now = Date.now()
const lastUpdate = updated * 1000 const lastUpdate = updated! * 1000
if (now - lastUpdate >= 24 * 36e5) return if (now - lastUpdate >= 24 * 36e5) return
const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4 const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4
@@ -293,7 +293,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const [confirmOpen, setConfirmOpen] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false)
const [qrOpen, setQrOpen] = useState(false) const [qrOpen, setQrOpen] = useState(false)
const loadProfileDocument = useCallback(() => readProfileFile(uid), [uid]) const loadProfileDocument = useCallback(() => readProfileFile(uid!), [uid])
const loadMergeDocument = useCallback( const loadMergeDocument = useCallback(
() => readProfileFile(option?.merge ?? ''), () => readProfileFile(option?.merge ?? ''),
[option?.merge], [option?.merge],
@@ -370,7 +370,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const onOpenFile = useLockFn(async () => { const onOpenFile = useLockFn(async () => {
setAnchorEl(null) setAnchorEl(null)
try { try {
await mutate(() => viewProfile(itemData.uid), { await mutate(() => viewProfile(itemData.uid!), {
id: 'view-profile', id: 'view-profile',
errorNotice: false, errorNotice: false,
}) })
@@ -402,7 +402,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
try { try {
const payload = Object.keys(option).length > 0 ? option : undefined const payload = Object.keys(option).length > 0 ? option : undefined
await mutate(() => updateProfile(itemData.uid, payload), { await mutate(() => updateProfile(itemData.uid!, payload), {
id: `update-profile:${itemData.uid}`, id: `update-profile:${itemData.uid}`,
errorNotice: false, errorNotice: false,
}) })
@@ -593,7 +593,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const handleSaveProfileDocument = useLockFn(async () => { const handleSaveProfileDocument = useLockFn(async () => {
const currentValue = profileDocument.value const currentValue = profileDocument.value
const result = await mutate(() => saveProfileFile(uid, currentValue), { const result = await mutate(() => saveProfileFile(uid!, currentValue), {
id: `save-profile-file:${uid}`, id: `save-profile-file:${uid}`,
errorNotice: false, errorNotice: false,
}) })
@@ -728,7 +728,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
variant="h6" variant="h6"
component="h2" component="h2"
noWrap noWrap
title={name} title={name!}
> >
{name} {name}
</Typography> </Typography>
@@ -794,7 +794,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
title={ title={
showNextUpdate showNextUpdate
? t('profiles.components.profileItem.tooltips.showLast') ? t('profiles.components.profileItem.tooltips.showLast')
: `${t('shared.labels.updateTime')}: ${parseExpire(updated)}\n${t('profiles.components.profileItem.tooltips.showNext')}` : `${t('shared.labels.updateTime')}: ${parseExpire(updated!)}\n${t('profiles.components.profileItem.tooltips.showNext')}`
} }
sx={{ sx={{
fontSize: 14, fontSize: 14,
@@ -812,8 +812,8 @@ const ProfileItemBase = (props: ProfileItemProps) => {
> >
{showNextUpdate {showNextUpdate
? nextUpdateTime ? nextUpdateTime
: updated > 0 : updated! > 0
? dayjs(updated * 1000).fromNow() ? dayjs(updated! * 1000).fromNow()
: ''} : ''}
</Typography> </Typography>
</Box> </Box>
@@ -831,7 +831,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
) : ( ) : (
<Box sx={{ ...boxStyle, fontSize: 12, justifyContent: 'flex-end' }}> <Box sx={{ ...boxStyle, fontSize: 12, justifyContent: 'flex-end' }}>
<span title={t('shared.labels.updateTime')}> <span title={t('shared.labels.updateTime')}>
{parseExpire(updated)} {parseExpire(updated!)}
</span> </span>
</Box> </Box>
)} )}
@@ -896,7 +896,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
<RulesEditorViewer <RulesEditorViewer
groupsUid={option?.groups ?? ''} groupsUid={option?.groups ?? ''}
mergeUid={option?.merge ?? ''} mergeUid={option?.merge ?? ''}
profileUid={uid} profileUid={uid!}
property={option?.rules ?? ''} property={option?.rules ?? ''}
open={true} open={true}
onSave={onSave} onSave={onSave}
@@ -905,7 +905,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
)} )}
{proxiesOpen && ( {proxiesOpen && (
<ProxiesEditorViewer <ProxiesEditorViewer
profileUid={uid} profileUid={uid!}
property={option?.proxies ?? ''} property={option?.proxies ?? ''}
open={true} open={true}
onSave={onSave} onSave={onSave}
@@ -916,7 +916,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
<GroupsEditorViewer <GroupsEditorViewer
mergeUid={option?.merge ?? ''} mergeUid={option?.merge ?? ''}
proxiesUid={option?.proxies ?? ''} proxiesUid={option?.proxies ?? ''}
profileUid={uid} profileUid={uid!}
property={option?.groups ?? ''} property={option?.groups ?? ''}
open={true} open={true}
onSave={onSave} onSave={onSave}
@@ -976,7 +976,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
{qrOpen && itemData.url && ( {qrOpen && itemData.url && (
<QrViewer <QrViewer
open={true} open={true}
value={`${itemData.url}${itemData.url.includes('?') ? '&' : '?'}name=${encodeURIComponent(name)}`} value={`${itemData.url}${itemData.url.includes('?') ? '&' : '?'}name=${encodeURIComponent(name!)}`}
onClose={() => setQrOpen(false)} onClose={() => setQrOpen(false)}
/> />
)} )}
+14 -8
View File
@@ -115,7 +115,13 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
} }
const name = form.name || `${form.type} file` const name = form.name || `${form.type} file`
const item = { ...form, name, option } const item = {
type: form.type,
name,
desc: form.desc,
option,
...(form.type === 'remote' ? { url: form.url } : {}),
}
const isRemote = form.type === 'remote' const isRemote = form.type === 'remote'
const isUpdate = openType === 'edit' const isUpdate = openType === 'edit'
@@ -139,7 +145,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
t('profiles.modals.profileForm.errors.uidMissing'), t('profiles.modals.profileForm.errors.uidMissing'),
) )
} }
await mutate(() => patchProfile(form.uid, item), { await mutate(() => patchProfile(form.uid!, item), {
id: 'patch-profile', id: 'patch-profile',
errorNotice: false, errorNotice: false,
}) })
@@ -157,7 +163,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
t('profiles.modals.profileForm.errors.uidMissing'), t('profiles.modals.profileForm.errors.uidMissing'),
) )
} }
await mutate(() => patchProfile(form.uid, item), { await mutate(() => patchProfile(form.uid!, item), {
id: 'patch-profile', id: 'patch-profile',
errorNotice: false, errorNotice: false,
}) })
@@ -190,13 +196,13 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
t('profiles.modals.profileForm.errors.uidMissing'), t('profiles.modals.profileForm.errors.uidMissing'),
) )
} }
await mutate(() => patchProfile(form.uid, retryItem), { await mutate(() => patchProfile(form.uid!, retryItem), {
id: 'patch-profile', id: 'patch-profile',
errorNotice: false, errorNotice: false,
}) })
await mutate( await mutate(
() => patchProfile(form.uid, { option: originalOptions }), () => patchProfile(form.uid!, { option: originalOptions }),
{ {
id: 'patch-profile', id: 'patch-profile',
errorNotice: false, errorNotice: false,
@@ -413,7 +419,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
<InputLabel> <InputLabel>
{t('profiles.modals.profileForm.fields.useSystemProxy')} {t('profiles.modals.profileForm.fields.useSystemProxy')}
</InputLabel> </InputLabel>
<Switch checked={field.value} {...field} color="primary" /> <Switch checked={field.value!} {...field} color="primary" />
</StyledBox> </StyledBox>
)} )}
/> />
@@ -426,7 +432,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
<InputLabel> <InputLabel>
{t('profiles.modals.profileForm.fields.useClashProxy')} {t('profiles.modals.profileForm.fields.useClashProxy')}
</InputLabel> </InputLabel>
<Switch checked={field.value} {...field} color="primary" /> <Switch checked={field.value!} {...field} color="primary" />
</StyledBox> </StyledBox>
)} )}
/> />
@@ -439,7 +445,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
<InputLabel> <InputLabel>
{t('profiles.modals.profileForm.fields.acceptInvalidCerts')} {t('profiles.modals.profileForm.fields.acceptInvalidCerts')}
</InputLabel> </InputLabel>
<Switch checked={field.value} {...field} color="primary" /> <Switch checked={field.value!} {...field} color="primary" />
</StyledBox> </StyledBox>
)} )}
/> />
@@ -684,6 +684,9 @@ export const RulesEditorViewer = (props: Props) => {
ruleType.name !== 'SUB-RULE' && ( ruleType.name !== 'SUB-RULE' && (
<TextField <TextField
autoComplete="new-password" autoComplete="new-password"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
size="small" size="small"
sx={{ minWidth: '240px' }} sx={{ minWidth: '240px' }}
slotProps={{ slotProps={{
+28 -19
View File
@@ -445,10 +445,14 @@ export const ProxyChain = ({
if (isConnected) { if (isConnected) {
setIsConnecting(true) setIsConnecting(true)
try { try {
await mutate(() => updateProxyChainConfigInRuntime(null), { const result = await mutate(
id: 'update-proxy-chain-runtime', () => updateProxyChainConfigInRuntime(null),
errorNotice: false, {
}) id: 'update-proxy-chain-runtime',
errorNotice: false,
},
)
if (!result.ok) return
const targetGroup = const targetGroup =
mode === 'global' mode === 'global'
@@ -461,7 +465,7 @@ export const ProxyChain = ({
id: `select-proxy-node:${targetGroup}`, id: `select-proxy-node:${targetGroup}`,
errorNotice: false, errorNotice: false,
}) })
recordSelection(targetGroup, 'DIRECT') await recordSelection(targetGroup, 'DIRECT')
} catch { } catch {
if (currentProxyChain.length >= 1) { if (currentProxyChain.length >= 1) {
try { try {
@@ -473,7 +477,7 @@ export const ProxyChain = ({
errorNotice: false, errorNotice: false,
}, },
) )
recordSelection(targetGroup, currentProxyChain[0].name) await recordSelection(targetGroup, currentProxyChain[0].name)
} catch { } catch {
// ignore // ignore
} }
@@ -519,10 +523,14 @@ export const ProxyChain = ({
// 第一步:保存链式代理配置 // 第一步:保存链式代理配置
const chainProxies = currentProxyChain.map((node) => node.name) const chainProxies = currentProxyChain.map((node) => node.name)
debugLog('Saving chain config:', chainProxies) debugLog('Saving chain config:', chainProxies)
await mutate(() => updateProxyChainConfigInRuntime(chainProxies), { const result = await mutate(
id: 'update-proxy-chain-runtime', () => updateProxyChainConfigInRuntime(chainProxies),
errorNotice: false, {
}) id: 'update-proxy-chain-runtime',
errorNotice: false,
},
)
if (!result.ok) return
debugLog('Chain configuration saved successfully') debugLog('Chain configuration saved successfully')
// 第二步:连接到代理链的最后一个节点 // 第二步:连接到代理链的最后一个节点
@@ -545,12 +553,12 @@ export const ProxyChain = ({
) )
// The chain moves the group like any other selection, so the profile has to learn about // The chain moves the group like any other selection, so the profile has to learn about
// it: what the profile holds is what gets re-applied the next time the core starts. // it: what the profile holds is what gets re-applied the next time the core starts.
recordSelection(targetGroup || 'GLOBAL', lastNode.name) await recordSelection(targetGroup || 'GLOBAL', lastNode.name)
localStorage.setItem('proxy-chain-group', targetGroup || 'GLOBAL') localStorage.setItem('proxy-chain-group', targetGroup || 'GLOBAL')
localStorage.setItem('proxy-chain-exit-node', lastNode.name) localStorage.setItem('proxy-chain-exit-node', lastNode.name)
// 刷新代理信息以更新连接状态 // 刷新代理信息以更新连接状态
refreshProxy() await refreshProxy()
debugLog('Successfully connected to proxy chain') debugLog('Successfully connected to proxy chain')
} catch (error) { } catch (error) {
console.error('Failed to connect to proxy chain:', error) console.error('Failed to connect to proxy chain:', error)
@@ -619,14 +627,15 @@ export const ProxyChain = ({
<IconButton <IconButton
size="small" size="small"
onClick={() => { onClick={() => {
mutate(() => updateProxyChainConfigInRuntime(null), { void mutate(() => updateProxyChainConfigInRuntime(null), {
id: 'update-proxy-chain-runtime', id: 'update-proxy-chain-runtime',
errorNotice: false, onFulfilled: () => {
}) localStorage.removeItem('proxy-chain-group')
localStorage.removeItem('proxy-chain-group') localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-exit-node') localStorage.removeItem('proxy-chain-items')
localStorage.removeItem('proxy-chain-items') onUpdateChain([])
onUpdateChain([]) },
}).catch(() => {})
}} }}
sx={{ sx={{
color: theme.palette.error.main, color: theme.palette.error.main,
@@ -5,11 +5,11 @@ export type ProxyEmptyStateReason =
| 'no-proxy-info' | 'no-proxy-info'
type ProfileSummary = { type ProfileSummary = {
current?: string current?: string | null
items?: Array<{ items?: Array<{
uid?: string uid?: string | null
type?: string type?: string | null
}> }> | null
} }
interface ProxyListStateInput { interface ProxyListStateInput {
@@ -20,7 +20,7 @@ interface ProxyListStateInput {
isRunningModePending: boolean isRunningModePending: boolean
} }
const isSubscriptionProfile = (item: { type?: string }): boolean => const isSubscriptionProfile = (item: { type?: string | null }): boolean =>
item.type === 'remote' || item.type === 'local' item.type === 'remote' || item.type === 'local'
/** Decides only pre-render states; the built list owns whether it has renderable content. */ /** Decides only pre-render states; the built list owns whether it has renderable content. */
+14 -8
View File
@@ -400,20 +400,26 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
setRuleMenuAnchor(null) setRuleMenuAnchor(null)
} }
const handleGroupSelect = (groupName: string) => { const handleGroupSelect = async (groupName: string) => {
onGroupSelect(groupName)
handleGroupMenuClose()
if (mode === 'rule') { if (mode === 'rule') {
mutate(() => updateProxyChainConfigInRuntime(null), { try {
id: 'update-proxy-chain-runtime', const result = await mutate(
errorNotice: false, () => updateProxyChainConfigInRuntime(null),
}) {
id: 'update-proxy-chain-runtime',
},
)
if (!result.ok) return
} catch {
return
}
localStorage.removeItem('proxy-chain-group') localStorage.removeItem('proxy-chain-group')
localStorage.removeItem('proxy-chain-exit-node') localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-items') localStorage.removeItem('proxy-chain-items')
setProxyChain([]) setProxyChain([])
} }
onGroupSelect(groupName)
handleGroupMenuClose()
} }
const handleCloseDuplicateWarning = useCallback(() => { const handleCloseDuplicateWarning = useCallback(() => {
+2 -2
View File
@@ -198,7 +198,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
{toolsOnLeft && proxyCount} {toolsOnLeft && proxyCount}
<ProxyGroupTools <ProxyGroupTools
side={toolsOnLeft ? 'left' : 'right'} side={toolsOnLeft ? 'left' : 'right'}
url={group.testUrl} url={group.testUrl!}
groupName={group.name} groupName={group.name}
headState={headState!} headState={headState!}
onLocation={() => onLocation(group)} onLocation={() => onLocation(group)}
@@ -247,7 +247,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) {
return ( return (
<ProxyHead <ProxyHead
sx={{ pl: 2, pr: 3, mt: 0.5, mb: 1 }} sx={{ pl: 2, pr: 3, mt: 0.5, mb: 1 }}
url={group.testUrl} url={group.testUrl!}
groupName={group.name} groupName={group.name}
headState={headState!} headState={headState!}
onLocation={() => onLocation(group)} onLocation={() => onLocation(group)}
@@ -17,7 +17,6 @@ const node = (memberIndex: number, delay: number, name = `${memberIndex}`) =>
history: [{ delay }], history: [{ delay }],
extra: { extra: {
'http://cp.cloudflare.com/generate_204': { 'http://cp.cloudflare.com/generate_204': {
alive: true,
history: [{ time: new Date(0).toISOString(), delay }], history: [{ time: new Date(0).toISOString(), delay }],
}, },
}, },
+7 -7
View File
@@ -110,13 +110,13 @@ const CHAIN_DELAY_GROUP = 'chain-mode'
const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({ const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
name: CHAIN_DELAY_GROUP, name: CHAIN_DELAY_GROUP,
type: 'Selector', type: 'Selector',
alive: true,
udp: false, udp: false,
xudp: false, xudp: false,
tfo: false, tfo: false,
mptcp: false, mptcp: false,
smux: false, smux: false,
history: [], history: [],
extra: {},
members, members,
}) })
@@ -237,7 +237,7 @@ export const useRenderList = (
selected?.name ?? CHAIN_DELAY_GROUP, selected?.name ?? CHAIN_DELAY_GROUP,
'', '',
0, 0,
latencyTimeout, latencyTimeout!,
) )
if (col > 1) { if (col > 1) {
return groupOccurrences(occurrences, col).map((memberCol) => ({ return groupOccurrences(occurrences, col).map((memberCol) => ({
@@ -289,8 +289,8 @@ export const useRenderList = (
key: group.name, key: group.name,
group, group,
headState, headState,
icon: group.icon, icon: group.icon!,
testUrl: group.testUrl, testUrl: group.testUrl!,
}, },
] ]
@@ -300,7 +300,7 @@ export const useRenderList = (
group.name, group.name,
headState.filterText, headState.filterText,
headState.sortType, headState.sortType,
latencyTimeout, latencyTimeout!,
{ {
matchCase: headState.filterMatchCase, matchCase: headState.filterMatchCase,
matchWholeWord: headState.filterMatchWholeWord, matchWholeWord: headState.filterMatchWholeWord,
@@ -337,11 +337,11 @@ export const useRenderList = (
} }
cache.set(group.name, { cache.set(group.name, {
now: group.now, now: group.now!,
members: group.members, members: group.members,
headState, headState,
col, col,
latencyTimeout, latencyTimeout: latencyTimeout!,
delays: groupDelays.get(group.name), delays: groupDelays.get(group.name),
items: ret, items: ret,
}) })
@@ -1,20 +1,11 @@
import Visibility from '@mui/icons-material/Visibility' import { Button, Grid, Stack, TextField } from '@mui/material'
import VisibilityOff from '@mui/icons-material/VisibilityOff'
import {
TextField,
Button,
Grid,
Stack,
IconButton,
InputAdornment,
} from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { useState, useRef, memo, useEffect } from 'react' import { memo, useEffect, useRef } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { saveWebdavConfig, createWebdavBackup } from '@/services/cmds' import { createWebdavBackup, saveWebdavConfig } from '@/services/cmds'
import { mutate } from '@/services/mutate' import { mutate } from '@/services/mutate'
import { errorDetail, showNotice } from '@/services/notice-service' import { errorDetail, showNotice } from '@/services/notice-service'
import { import {
@@ -43,16 +34,15 @@ export const BackupConfigViewer = memo(
const { t } = useTranslation() const { t } = useTranslation()
const { verge, mutateVerge } = useVerge() const { verge, mutateVerge } = useVerge()
const { webdav_url, webdav_username, webdav_password } = verge || {} const { webdav_url, webdav_username, webdav_password } = verge || {}
const [showPassword, setShowPassword] = useState(false)
const usernameRef = useRef<HTMLInputElement>(null) const usernameRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null) const passwordRef = useRef<HTMLInputElement>(null)
const urlRef = useRef<HTMLInputElement>(null) const urlRef = useRef<HTMLInputElement>(null)
const { register, handleSubmit, watch } = useForm<IWebDavConfig>({ const { register, handleSubmit, watch } = useForm<IWebDavConfig>({
defaultValues: { defaultValues: {
url: webdav_url, url: webdav_url!,
username: webdav_username, username: webdav_username!,
password: webdav_password, password: webdav_password!,
}, },
}) })
const url = watch('url') const url = watch('url')
@@ -68,10 +58,6 @@ export const BackupConfigViewer = memo(
const webdavStatus = getWebdavStatus(webdavSignature) const webdavStatus = getWebdavStatus(webdavSignature)
const shouldAutoInit = webdavStatus !== 'failed' const shouldAutoInit = webdavStatus !== 'failed'
const handleClickShowPassword = () => {
setShowPassword((prev) => !prev)
}
useEffect(() => { useEffect(() => {
if (!webdav_url || !webdav_username || !webdav_password) { if (!webdav_url || !webdav_username || !webdav_password) {
return return
@@ -215,7 +201,7 @@ export const BackupConfigViewer = memo(
<Grid size={{ xs: 6 }}> <Grid size={{ xs: 6 }}>
<TextField <TextField
label={t('shared.labels.password')} label={t('shared.labels.password')}
type={showPassword ? 'text' : 'password'} type="password"
variant="outlined" variant="outlined"
size="small" size="small"
autoCorrect="off" autoCorrect="off"
@@ -223,20 +209,6 @@ export const BackupConfigViewer = memo(
spellCheck="false" spellCheck="false"
inputRef={passwordRef} inputRef={passwordRef}
{...register('password')} {...register('password')}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={handleClickShowPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
},
}}
/> />
</Grid> </Grid>
</Grid> </Grid>
+1 -1
View File
@@ -196,7 +196,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { verge } = useVerge() const { verge } = useVerge()
const { current: currentProfile } = useProfiles() const { current: currentProfile } = useProfiles()
const dnsEnabled = currentProfile const dnsEnabled = currentProfile
? (verge?.profile_dns_settings?.[currentProfile.uid]?.enabled ?? ? (verge?.profile_dns_settings?.[currentProfile.uid!]?.enabled ??
verge?.enable_dns_settings ?? verge?.enable_dns_settings ??
false) false)
: false : false
@@ -138,7 +138,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
const defaultBypass = () => { const defaultBypass = () => {
if (isWindows) { if (isWindows) {
return 'localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;<local>' return 'localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;*.local;<local>'
} }
if (systemName === 'linux') { if (systemName === 'linux') {
return 'localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,::1' return 'localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,::1'
@@ -438,7 +438,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
<Switch <Switch
edge="end" edge="end"
disabled={!enabled} disabled={!enabled}
checked={value.pac} checked={value.pac!}
onChange={(_, e) => setValue((v) => ({ ...v, pac: e }))} onChange={(_, e) => setValue((v) => ({ ...v, pac: e }))}
/> />
</ListItem> </ListItem>
@@ -455,7 +455,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
<Switch <Switch
edge="end" edge="end"
disabled={!enabled} disabled={!enabled}
checked={value.guard} checked={value.guard!}
onChange={(_, e) => setValue((v) => ({ ...v, guard: e }))} onChange={(_, e) => setValue((v) => ({ ...v, guard: e }))}
sx={{ marginLeft: 'auto' }} sx={{ marginLeft: 'auto' }}
/> />
@@ -1,5 +1,4 @@
import { alpha, Box, Button, LinearProgress } from '@mui/material' import { alpha, Box, Button, LinearProgress } from '@mui/material'
import type { DownloadEvent } from '@tauri-apps/plugin-updater'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import type { Ref } from 'react' import type { Ref } from 'react'
import { import {
@@ -15,6 +14,7 @@ import type { Options as ReactMarkdownOptions } from 'react-markdown'
import { BaseDialog, DialogRef } from '@/components/base' import { BaseDialog, DialogRef } from '@/components/base'
import { useUpdate } from '@/hooks/use-update' import { useUpdate } from '@/hooks/use-update'
import type { DownloadEvent } from '@/services/bindings'
import { import {
cancelUpdateDownload, cancelUpdateDownload,
installUpdate, installUpdate,
+1 -1
View File
@@ -40,7 +40,7 @@ const SettingClash = ({ onError }: Props) => {
const { verge, mutateVerge } = useVerge() const { verge, mutateVerge } = useVerge()
const { current: currentProfile } = useProfiles() const { current: currentProfile } = useProfiles()
const dnsEnabled = currentProfile const dnsEnabled = currentProfile
? (verge?.profile_dns_settings?.[currentProfile.uid]?.enabled ?? ? (verge?.profile_dns_settings?.[currentProfile.uid!]?.enabled ??
verge?.enable_dns_settings ?? verge?.enable_dns_settings ??
false) false)
: false : false
+13 -2
View File
@@ -9,6 +9,7 @@ import { useVerge } from '@/hooks/use-verge'
import { navigationItems } from '@/pages/_navigation-meta' import { navigationItems } from '@/pages/_navigation-meta'
import { copyClashEnv } from '@/services/cmds' import { copyClashEnv } from '@/services/cmds'
import { supportedLanguages } from '@/services/i18n' import { supportedLanguages } from '@/services/i18n'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import getSystem from '@/utils/get-system' import getSystem from '@/utils/get-system'
@@ -75,8 +76,18 @@ const SettingVergeBasic = ({ onError }: Props) => {
} }
const onCopyClashEnv = useCallback(async () => { const onCopyClashEnv = useCallback(async () => {
await copyClashEnv() try {
showNotice.success('shared.feedback.notifications.common.copySuccess', 1000) await mutate(copyClashEnv, {
id: 'copy-clash-env',
onFulfilled: () =>
showNotice.success(
'shared.feedback.notifications.common.copySuccess',
1000,
),
})
} catch {
// The shared mutation boundary reports the failure.
}
}, []) }, [])
return ( return (
+14 -8
View File
@@ -1,5 +1,5 @@
import { LanguageRounded } from '@mui/icons-material' import { LanguageRounded } from '@mui/icons-material'
import { Box, Divider, MenuItem, Menu, styled, alpha } from '@mui/material' import { alpha, Box, Divider, Menu, MenuItem, styled } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -25,12 +25,18 @@ export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => {
const [position, setPosition] = useState({ left: 0, top: 0 }) const [position, setPosition] = useState({ left: 0, top: 0 })
const [delay, setDelay] = useState(-1) const [delay, setDelay] = useState(-1)
const { uid, name, icon, url } = itemData const { uid, name, icon, url } = itemData
const iconCachePath = useIconCache({ icon, cacheKey: uid }) const iconCachePath = useIconCache({ icon: icon!, cacheKey: uid! })
const onDelay = useCallback(async () => { const onDelay = useCallback(async () => {
setDelay(-2) setDelay(-2)
const result = await cmdTestDelay(url) try {
setDelay(result) const result = await cmdTestDelay(url!)
setDelay(result)
} catch (err) {
// A failed test must not leave the item stuck in the loading state.
setDelay(-1)
showNotice.error(err)
}
}, [url]) }, [url])
const onEditTest = () => { const onEditTest = () => {
@@ -41,7 +47,7 @@ export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => {
const onDelete = useLockFn(async () => { const onDelete = useLockFn(async () => {
setAnchorEl(null) setAnchorEl(null)
try { try {
removeTest(uid) removeTest(uid!)
} catch (err: any) { } catch (err: any) {
showNotice.error(err) showNotice.error(err)
} }
@@ -81,17 +87,17 @@ export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => {
<Box sx={{ display: 'flex', justifyContent: 'center' }}> <Box sx={{ display: 'flex', justifyContent: 'center' }}>
{icon.trim().startsWith('http') && ( {icon.trim().startsWith('http') && (
<img <img
alt={name} alt={name!}
src={iconCachePath === '' ? icon : iconCachePath} src={iconCachePath === '' ? icon : iconCachePath}
height="40px" height="40px"
/> />
)} )}
{icon.trim().startsWith('data') && ( {icon.trim().startsWith('data') && (
<img alt={name} src={icon} height="40px" /> <img alt={name!} src={icon!} height="40px" />
)} )}
{icon.trim().startsWith('<svg') && ( {icon.trim().startsWith('<svg') && (
<img <img
alt={name} alt={name!}
src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(icon)}`} src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(icon)}`}
height="40px" height="40px"
/> />
+3 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import type { ProfilePatch, ProfileSelection } from '@/services/bindings'
import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds' import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds'
import { mutate } from '@/services/mutate' import { mutate } from '@/services/mutate'
import { migrateProfileTestUrls } from '@/services/proxy-test-url' import { migrateProfileTestUrls } from '@/services/proxy-test-url'
@@ -46,7 +47,7 @@ export const useProfiles = () => {
}, []) }, [])
const patchProfiles = useCallback( const patchProfiles = useCallback(
async (value: Partial<IProfilesConfig>) => { async (value: ProfileSelection) => {
try { try {
const result = await mutate(() => patchProfilesConfig(value), { const result = await mutate(() => patchProfilesConfig(value), {
id: 'patch-profiles-config', id: 'patch-profiles-config',
@@ -77,7 +78,7 @@ export const useProfiles = () => {
) )
const patchCurrent = useCallback( const patchCurrent = useCallback(
async (value: Partial<IProfileItem>) => { async (value: ProfilePatch) => {
if (profiles?.current) { if (profiles?.current) {
const uid = profiles.current const uid = profiles.current
await mutate(() => patchProfile(uid, value), { await mutate(() => patchProfile(uid, value), {
+2 -2
View File
@@ -166,10 +166,10 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
const handleProxyGroupChange = useCallback( const handleProxyGroupChange = useCallback(
( (
group: { name: string; now?: string; fixed?: string }, group: { name: string; now?: string | null; fixed?: string | null },
proxy: { name: string }, proxy: { name: string },
) => { ) => {
changeProxy(group.name, proxy.name, group.now, group.fixed) changeProxy(group.name, proxy.name, group.now!, group.fixed!)
}, },
[changeProxy], [changeProxy],
) )
+6 -6
View File
@@ -5,7 +5,7 @@ import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { useSystemData } from '@/providers/app-data-context' import { useSystemData } from '@/providers/app-data-context'
import { import {
getAutotemProxy, getAutoProxy,
getEmbeddedServerPort, getEmbeddedServerPort,
patchVergeConfig, patchVergeConfig,
} from '@/services/cmds' } from '@/services/cmds'
@@ -21,8 +21,8 @@ export const useSystemProxyState = () => {
const { sysproxy } = useSystemData() const { sysproxy } = useSystemData()
const displayedMixedPort = useDisplayedMixedPort() const displayedMixedPort = useDisplayedMixedPort()
const { data: autoproxy } = useQuery({ const { data: autoproxy } = useQuery({
queryKey: ['getAutotemProxy'], queryKey: ['getAutoProxy'],
queryFn: getAutotemProxy, queryFn: getAutoProxy,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
refetchOnReconnect: true, refetchOnReconnect: true,
}) })
@@ -99,7 +99,7 @@ export const useSystemProxyState = () => {
} }
// Kept separate so an unreadable config cannot discard OS state that did read. // Kept separate so an unreadable config cannot discard OS state that did read.
try { try {
await revalidateQueries([['getSystemProxy'], ['getAutotemProxy']]) await revalidateQueries([['getSystemProxy'], ['getAutoProxy']])
} catch (error) { } catch (error) {
console.warn( console.warn(
'[system-proxy] rereading the OS proxy after a toggle failed:', '[system-proxy] rereading the OS proxy after a toggle failed:',
@@ -109,14 +109,14 @@ export const useSystemProxyState = () => {
// rather than stay live from a stale cache. // rather than stay live from a stale cache.
await Promise.all([ await Promise.all([
removeCacheData(['getSystemProxy']), removeCacheData(['getSystemProxy']),
removeCacheData(['getAutotemProxy']), removeCacheData(['getAutoProxy']),
]) ])
} }
} }
} }
const invalidateProxyState = () => const invalidateProxyState = () =>
revalidateQueries([['getSystemProxy'], ['getAutotemProxy']]) revalidateQueries([['getSystemProxy'], ['getAutoProxy']])
return { return {
indicator, indicator,

Some files were not shown because too many files have changed in this diff Show More