Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot] a097009679 Initial plan 2026-01-16 04:44:21 +00:00
Tunglies b146013984 perf: optimize filename matching logic in IProfiles by using a static regex 2026-01-16 12:13:59 +08:00
Tunglies 09dcb072c3 perf: simplify current_mapping and profiles_preview methods in IProfiles for improved readability 2026-01-16 12:05:17 +08:00
Tunglies c5233a9c2c perf: refactor PrfOption merge logic and streamline update_item method in IProfiles 2026-01-16 11:47:36 +08:00
Tunglies f246ea4469 perf: add PartialEq derive to PrfSelected and PrfExtra structs for improved comparison 2026-01-16 11:29:57 +08:00
Tunglies 733f7a139d perf: enhance profile reordering logic and adjust logging level 2026-01-15 23:59:27 +08:00
Tunglies 357a683f92 perf: improve config processing 2026-01-15 06:50:21 +08:00
Tunglies 5c9b46f031 chore: bump version to prerelease 2.4.5-rc.1 2026-01-14 16:55:27 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> f5e75d5287 chore(deps): update dependency node to v24.13.0 (#6087)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 14:40:47 +08:00
Slinetrac c2d8277a1a fix(connections): allow full-width header sorting without triggering on resize 2026-01-14 11:23:50 +08:00
Tunglies 66e98518a7 chore(ci): update autobuild setup for ARM architecture support 2026-01-13 18:42:41 +08:00
Tunglies 089b73bbfd chore(deps): update clash_verge_service_ipc to version 2.0.29 (#6073) 2026-01-13 18:30:54 +08:00
Slinetrac d2c52d09e1 chore(renovate): disable lockfile maintenance automerge 2026-01-12 15:10:52 +08:00
Slinetrac 84143ec761 chore(deps): bump npm deps 2026-01-12 14:50:54 +08:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Slinetrac
f451a26f8c chore(deps): lock file maintenance (#6063)
* chore(deps): lock file maintenance

* chore(deps): update Cargo.toml

* chore(deps): use git repo until the next release

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-01-12 14:45:33 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> e1220a189b chore(deps): lock file maintenance npm dependencies (#6064)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 05:12:01 +00:00
Tunglies 57d4149807 fix(config): improve runtime config fallback handling 2026-01-11 15:27:54 +08:00
Slinetrac 86c3b241b1 docs: Changelog.md 2026-01-10 11:04:55 +08:00
Sline a49000712d feat(tun-viewer): route-exclude-address GUI support (#6053) 2026-01-10 10:50:44 +08:00
歳納七夏 35b2066d4c build(tauri): add libayatana-appindicator3 dependency for linux packages (#6051) 2026-01-10 08:42:57 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 92e0762fc4 chore(deps): update dependency @actions/github to v7 (#6042)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 00:27:00 +08:00
Slinetrac 6b8630d357 docs: Changelog.md 2026-01-08 22:44:11 +08:00
Slinetrac a1e77070f0 chore(deps): bump clash-verge-service-ipc to 2.0.29 2026-01-08 22:29:10 +08:00
Slinetrac 6926744ca2 docs: Changelog.md 2026-01-08 14:12:48 +08:00
Slinetrac 13855b9bc2 perf(tun-viewer): run enhanceProfiles in background to avoid save blocking 2026-01-08 14:03:00 +08:00
Slinetrac 1889f18183 feat(notice): override context menu to copy error details 2026-01-07 13:17:56 +08:00
Slinetrac a981be80ef refactor(base): expand barrel exports and standardize imports 2026-01-06 15:02:10 +08:00
Slinetrac 60d3a1927b docs: Changelog.md 2026-01-05 16:18:44 +08:00
Slinetrac 620841592f fix(scheme): prevent double-decoding of subscription URLs #2609
Closes #2609
2026-01-05 15:36:01 +08:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Slinetrac
2128e1f788 chore(deps): lock file maintenance npm dependencies (#6015)
* chore(deps): lock file maintenance npm dependencies

* chore(deps): bump npm deps

* chore: fix eslint ref-name

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-01-05 13:53:22 +08:00
Slinetrac 256a3f697b chore(deps): update Cargo.toml 2026-01-05 13:26:28 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> a701450362 chore(deps): lock file maintenance (#6014)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 10:47:37 +08:00
Tunglies 9e4e0c81a4 refactor: clean up and improve code structure (#6010) 2026-01-04 19:21:45 +08:00
oomeow 421bbd090e feat: reconfig log dynamically (#5724) 2026-01-04 16:56:16 +08:00
Tunglies 4adf678480 chore: update Cargo.lock dependencies and increment tauri-plugin-mihomo version to 0.1.3 2026-01-04 16:29:01 +08:00
Slinetrac a9a782d5c9 docs: Changelog.md 2026-01-04 15:05:12 +08:00
Sline ee5e5ee8a6 feat(sysproxy-viewer): add visual editor for bypass list with chips display (#6007) 2026-01-04 14:58:50 +08:00
Slinetrac a940445081 chore(deps): revert tray-icon patch
Patch PR is merged.
2026-01-04 11:47:32 +08:00
Tunglies 65653594c7 chore: allow to update Cargo.lockl from git repo 2026-01-04 06:59:36 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> ac8f62bea2 chore(deps): update tauri-apps/tauri-action action to v0.6.1 (#6005)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-03 20:25:57 +00:00
Tunglies eb8ba8b369 refactor(tray): replace Arc<str> with Cow<'static, str> for menu texts and update tray event handling 2026-01-03 19:25:36 +08:00
Slinetrac c18821288e feat(sysproxy-viewer): make proxy bypass validation optional #4012 2026-01-03 17:10:10 +08:00
Slinetrac 7d40410dea chore: update tray-icon patch 2026-01-03 15:44:46 +08:00
AetherWing 349be20a6c chore(windows): remove redundant 3s delay from autostart task (#5996)
* fix(tray): retry tray creation with delay on Windows

* fix(tray): retry tray creation with delay on Windows

* chore(windows): remove redundant 3s delay from autostart task

* chore(windows): remove redundant 3s delay from autostart task
2026-01-02 15:18:01 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 1901a6c97c chore(deps): update dependency globals to v17 (#5997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-02 15:07:31 +08:00
Slinetrac bb72b92ae9 refactor(tray): unify tray icon retrieval into get_tray_icon 2026-01-02 14:28:17 +08:00
Slinetrac 8a1740d38b fix(tray): apply patch for tray-icon #5984
Closes #5984.
2026-01-02 13:54:47 +08:00
Tunglies d75d3bd86e fix(proxy): update system proxy state references to use configState 2026-01-01 17:40:56 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> b277a1e760 chore(deps): update dependency react-i18next to v16.5.1 (#5994)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-01 16:55:15 +08:00
Slinetrac 522eccdd0e refactor(icon): extract icon cache logic into reusable hook 2026-01-01 16:35:36 +08:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Slinetrac
f3b9eedcf7 chore(deps): update rust crate reqwest to 0.13.1 (#5990)
* chore(deps): update rust crate reqwest to 0.13.0

* fix: replace rustls-tls with rustls and update lock file

* fix(webdav): use tauri_plugin_http re-exported reqwest client

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2025-12-31 23:53:49 +08:00
Tunglies 3bbcdbe5ca fix(service): update update clash_verge_service_ipc to 2.0.27
chore(changelog): add IPC permission restrictions for macOS and Linux

refactor(service): remove outdated service version check logic
2025-12-31 21:35:34 +08:00
TungliesandKaguraNaku cceb0a6eb4 fix(permissions): manage umask for sidecar process in CoreManager on Unix
Co-authored-by: KaguraNaku <97681505+KaguraNaku@users.noreply.github.com>
2025-12-31 21:05:05 +08:00
Slinetrac cb5a2e7ce3 perf(proxy-chain): avoid duplicate proxy refresh in chain panel #5855 2025-12-31 16:42:56 +08:00
Slinetrac 609008f087 refactor(uri-parser): split parser into folder-based modules 2025-12-31 14:40:45 +08:00
Slinetrac bae3576e93 fix: update tauri-plugin-mihomo version in pnpm-lock.yaml 2025-12-30 23:58:43 +08:00
Tunglies 9ce343fb45 perf(i18n): update translate function to use Cow to aovid allocate 2025-12-30 18:32:19 +08:00
Tunglies cf08628200 fix(config): update home_cards reference to use as_ref() for improved handling 2025-12-30 18:09:10 +08:00
Tunglies c06c15450f perf(draft): update with_data_modify to use T instead of Box<T> for better performance
Performance improved around 11.7%, avoid to allocate stack and copy to heap.
2025-12-30 17:51:50 +08:00
Slinetrac 772b87e733 feat: add GUI support for AnyTLS/Mieru/Sudoku and AnyTLS URI parsing 2025-12-30 15:15:55 +08:00
Slinetrac c80c659180 fix(dns-viewer): disable spellcheck on DNS viewer text fields #5948
Closes #5948
2025-12-30 13:44:56 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 0cde6cfce9 chore(deps): update dependency react-error-boundary to v6.0.1 (#5969)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 10:33:24 +08:00
Slinetrac e6a0369036 fix(ui): hide scrollbars on side menu and hover jump navigator #5945
Closes #5945
2025-12-29 10:26:34 +08:00
Slinetrac ca50e35435 chore(deps): bump deps 2025-12-28 17:55:58 +08:00
Slinetrac 0193ba7bf9 refactor(seq): use if let filter for selector group dedup 2025-12-28 11:05:28 +08:00
Slinetrac c40cdf6b55 fix(seq): prepend newly added proxies to first selector group 2025-12-28 10:55:27 +08:00
Sline a82bcbe86e feat(proxy): auto-append new proxies to first selector group (#5965)
* feat(proxy): auto-append new proxies to first selector group

* docs: Changelog.md
2025-12-28 10:33:10 +08:00
Slinetrac 895e54f7ec fix(config): restrict restart_core_needed mutability to non-Windows to avoid unused-mut 2025-12-28 09:45:54 +08:00
Tunglies c41db51f81 feat: add governor crate for rate limiting and improve window/tray operation handling 2025-12-27 20:27:49 +08:00
Tunglies 2c1303c2bd feat: add bitflags crate and refactor UpdateFlags implementation 2025-12-27 17:09:08 +08:00
SlineandTunglies c8aeae3f83 crate(i18n): add clash-verge-i18n crate and integrate localization support (#5961)
* crate(i18n): add clash-verge-i18n crate and integrate localization support

* refactor(service): remove redundant reinstall_service functions for Windows, Linux, and macOS

* chore(i18n): align i18n key

* feat(i18n): unify scan roots and add backend Rust/YAML support to cleanup script

* chore(i18n): add scripts to package.json

* fix(tray): initialize i18n locale before setup

* refactor(i18n): move locale initialization into Config::init_config

* fix(i18n): refresh systray tooltip on language change and correct docs reference

* fix(tray): remove unnecessary locale synchronization to improve performance

---------

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2025-12-27 15:03:19 +08:00
Slinetrac 1b477ed0b2 chore: fix typo 2025-12-27 13:56:52 +08:00
Slinetrac 5aba848741 Revert "crate(i18n): add clash-verge-i18n crate and integrate localization support (#5959)"
This reverts commit 593751eda2.
2025-12-27 12:07:56 +08:00
TungliesandSlinetrac 593751eda2 crate(i18n): add clash-verge-i18n crate and integrate localization support (#5959)
* crate(i18n): add clash-verge-i18n crate and integrate localization support

* refactor(service): remove redundant reinstall_service functions for Windows, Linux, and macOS

* chore(i18n): align i18n key

* feat(i18n): unify scan roots and add backend Rust/YAML support to cleanup script

* chore(i18n): add scripts to package.json

---------

Co-authored-by: Slinetrac <realakayuki@gmail.com>
2025-12-27 11:33:58 +08:00
Slinetrac b53f54f3f4 fix(i18n): remove invalid i18n key usage 2025-12-27 09:24:15 +08:00
Tunglies bfb18cf003 refactor(profile): improve error handling for file not found case
refactor(merge): simplify deep_merge function signature
chore: remove unused fmt_bytes function and related tests
chore: clean up help module by removing unused macros
chore: remove format module from utils
2025-12-26 22:15:09 +08:00
Tunglies 9c6f5bc991 fix(profiles): add cleanup of orphaned files and adjust logging level 2025-12-26 17:17:18 +08:00
Slinetrac 63cd4905f9 refactor(notice): extract resolveNoticeMessage helper 2025-12-26 16:42:31 +08:00
歳納七夏 2417d064e1 feat: allow disabling proxy groups in tray icon (#5946)
* feat: allow disabling proxy groups in tray icon

* feat: allow disabling proxy groups in tray icon (update i11n)

* feat: allow disabling proxy groups in tray icon (fix)

* feat: allow disabling proxy groups in tray icon (less nesting)

* feat: allow disabling proxy groups in tray icon (changelog)
2025-12-26 16:29:05 +08:00
Slinetrac d91e19e166 chore: reorganize frontend files 2025-12-26 15:53:25 +08:00
Sline 65b4d8713d refactor: windows autostart (#5941)
* refactor(windows-autostart): switch to scheduled tasks with user/admin split

* fix(schtasks): decode stdout/stderr using Windows OEM/ANSI code pages

* refactor(ui): remove admin auto-launch warning and clean i18n

* feat(windows): user-level auto-launch via task XML

* docs: Changelog.md
2025-12-26 14:42:21 +08:00
Slinetrac a67abda72d refactor(theme): simplify useCustomTheme for Tauri only 2025-12-26 12:25:51 +08:00
Slinetrac 8e27834e35 fix(theme/windows): apply patch to tao to fix Windows theme sync 2025-12-26 12:01:44 +08:00
Slinetrac ee3f7df417 Revert "fix(theme/windows): switch to dark-light based theme detection"
This reverts commit 1c044f053f.
2025-12-26 11:59:13 +08:00
Tunglies f9b8a658a1 perf(draft): optimize memory layout by removing double indirection & implemented optimistic locking via Arc::ptr_eq for with_data_modify (#5942)
* perf(draft): optimize memory layout by removing double indirection

- Replace `Arc<Box<T>>` with `Arc<T>` to reduce pointer chasing and memory overhead.
- Leverage `Arc::from(Box<T>)` in async modify path for efficient ownership transfer.
- Fix race conditions in `edit_draft` by ensuring atomicity under write lock.
- Performance improved by ~16-24% across all operations (based on Criterion bench).

Benchmarks:
- latest_arc:     41.1ns (-24.2%)
- edit_draft:     92.2ns (-17.6%)
- apply:          89.8ns (-17.7%)
- async_modify:   66.0ns (-16.6%)

* perf(draft): implemented optimistic locking via Arc::ptr_eq for with_data_modify

Benchmarks confirm only a negligible ~2% (1.3ns) overhead for async operations, ensuring total data integrity during concurrent updates.
2025-12-25 16:44:23 +08:00
Slinetrac 1c044f053f fix(theme/windows): switch to dark-light based theme detection 2025-12-25 14:43:27 +08:00
Slinetrac 712b8ff19b refactor(linux): move Linux-specific mime.rs and workarounds.rs to utils/linux 2025-12-25 13:18:55 +08:00
Slinetrac 4ab2720ac4 refactor(tray): remove --no-tray code path and env-based tray bypass 2025-12-25 10:42:03 +08:00
Slinetrac af0e72d119 feat(linux): disable WebKit DMABUF renderer on NVIDIA GPUs at startup #5921 2025-12-25 10:34:27 +08:00
Slinetrac bd62a4ecc0 refactor(utils): extract freedesktop mimeapps handling into utils/mime 2025-12-25 10:02:07 +08:00
Tunglies 4ffb8b415f perf(traffic): optimize traffic data handling and improve performance 2025-12-24 13:40:03 +08:00
Slinetrac 0992556b4a feat(update-viewer): support rendering HTML in update markdown #5932
Closes #5932
2025-12-23 20:48:14 +08:00
Tunglies be6b53c760 ci: update Ubuntu sources to use 'noble' release and adjust package installation 2025-12-23 14:52:47 +08:00
Slinetrac 797c0f90aa chore(deps): bump npm deps 2025-12-23 11:59:49 +08:00
Slinetrac d52f00c1b1 feat(ui): add collapse/expand option to navbar context menu #5910
Closes #5910
2025-12-23 11:52:40 +08:00
Slinetrac f26abcd2a9 fix(backup): reload config.yaml and profiles.yaml after restore to avoid stale memory overwrite #5909
Closes #5909
2025-12-23 10:56:31 +08:00
Tunglies 863a80df43 ci: update Ubuntu version to 24.04 and adjust dependencies in autobuild workflow 2025-12-22 23:23:35 +08:00
oomeow 19accbd538 chore: rust related config for workspace (#5912)
* chore: move rust related config to workspace

* chore: cargo fmt
2025-12-22 15:28:55 +08:00
SukkaandSlinetrac 8e48e4ed10 chore(eslint): add eslint-plugin-react-compiler (#5918)
* chore(eslint): add `eslint-plugin-react-compiler`

* refactor: fix eslint warnings and avoid in-render mutations

---------

Co-authored-by: Slinetrac <realakayuki@gmail.com>
2025-12-22 12:51:20 +08:00
Slinetrac eafa08066d docs(notice): add example for passing Error directly with i18n key 2025-12-22 11:01:53 +08:00
Slinetrac 231517b5db chore(monaco): ignore worker errors in console 2025-12-21 23:52:16 +08:00
Slinetrac 45193e017f fix(monaco): Ensure monaco loader uses the bundled ESM instance instead of CDN 2025-12-21 21:38:05 +08:00
Slinetrac 2515deefed Revert "refactor(editor-viewer): simplify loading/save flow with timeout fallback (#5905)"
This reverts commit c84bb91f4a.
2025-12-21 20:28:38 +08:00
Sline c84bb91f4a refactor(editor-viewer): simplify loading/save flow with timeout fallback (#5905) 2025-12-21 17:10:52 +08:00
Tunglies af094bfcd7 refactor: remove port_scanner dependency and simplify port checking logic 2025-12-21 16:51:55 +08:00
Slinetrac a5752f7b00 docs: Changelog.md 2025-12-21 13:16:54 +08:00
Slinetrac 23e551e384 feat(tray): add optional inline outbound modes in tray menu #5881
Closes #5881
2025-12-21 10:33:19 +08:00
Slinetrac a0b12b8797 chore(deps): bump npm deps 2025-12-21 09:43:07 +08:00
Tunglies 16c3dcc616 fix(proxy): check if proxy port is in use #5891 2025-12-20 19:11:27 +08:00
Slinetrac 5afe11e55b fix(monaco): disable MUI Dialog focus enforcement for Monaco editor #5885
Closes #5885
2025-12-20 16:12:45 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 6f61759a39 chore(deps): update rust crate zip to v7 (#5886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-20 13:54:25 +08:00
CactusandSlinetrac 57b17ab8d3 feat: add navigation collapse functionality to layout (#5815)
* feat(layout): add collapsible navbar toggle in UI settings

* refactor(layout): adjust collapsed navbar styles

* docs: Changelog.md

---------

Co-authored-by: Slinetrac <realakayuki@gmail.com>
2025-12-19 18:15:20 +08:00
Sline fc84dc561c feat(notice): persist toast position preference (#5621)
* feat(notice): persist toast position preference

* docs: Changelog.md
2025-12-19 18:04:23 +08:00
Sline bd8eccdcea feat(backup): add local backup import flow (#5669)
* feat(backup): add local backup import flow

* refactor(backup): robustify history listing and propagate import error details

* docs: Changelog.md
2025-12-19 17:46:31 +08:00
SlineandTunglies b4e25951b4 feat(proxy): unify filter search box and sync filter options (#5819)
* feat(proxy): unify filter search box and sync filter options

* docs: fix JSDoc placement

* refactor(search): unify string matcher and stabilize regex filtering

* fix(search): treat invalid regex as matching nothing

* docs: update changelog to include advanced filter search feature for proxy page

---------

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2025-12-19 17:12:36 +08:00
zhuo-githubzhuocanhe <zhuocanhe>Tunglies
aa72fa9a42 fix: add DNS hijacking fix for macOS wired network(#5728)
* [bugfix][issues-2701]mac有线网络下dns劫持失败

* [bugfix][issues-2701]mac有线网络下dns劫持失败。删除弃用代码,删除打印端口日志

* fix(changelog): add DNS hijacking fix for macOS wired network

---------

Co-authored-by: zhuocanhe <zhuocanhe>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2025-12-19 17:05:47 +08:00
Tunglies f0ae631cb0 chore: update version to 2.4.5 and enhance changelog management scripts 2025-12-19 16:51:11 +08:00
237 changed files with 8358 additions and 7178 deletions
+3 -3
View File
@@ -307,7 +307,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -377,7 +377,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -505,7 +505,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
+38 -30
View File
@@ -46,7 +46,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -137,7 +137,7 @@ jobs:
target: aarch64-apple-darwin
- os: macos-latest
target: x86_64-apple-darwin
- os: ubuntu-22.04
- os: ubuntu-24.04
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
steps:
@@ -165,10 +165,19 @@ jobs:
cache-workspace-crates: true
- name: Install dependencies (ubuntu only)
if: matrix.os == 'ubuntu-22.04'
if: matrix.os == 'ubuntu-24.04'
run: |
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
sudo apt install \
libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev
- name: Install x86 OpenSSL (macOS only)
if: matrix.target == 'x86_64-apple-darwin'
@@ -187,7 +196,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
cache: "pnpm"
- name: Pnpm Cache
@@ -244,10 +253,10 @@ jobs:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
- os: ubuntu-24.04
target: aarch64-unknown-linux-gnu
arch: arm64
- os: ubuntu-22.04
- os: ubuntu-24.04
target: armv7-unknown-linux-gnueabihf
arch: armhf
runs-on: ${{ matrix.os }}
@@ -283,7 +292,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
cache: "pnpm"
- name: Pnpm Cache
@@ -304,38 +313,37 @@ jobs:
- name: Setup for linux
run: |
sudo ls -lR /etc/apt/
sudo dpkg --add-architecture ${{ matrix.arch }}
cat > /tmp/sources.list << EOF
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-security main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-updates main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-backports main multiverse universe restricted
sudo rm -f /etc/apt/sources.list.d/ubuntu.sources
sudo tee /etc/apt/sources.list << EOF
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble main restricted universe multiverse
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble-security main restricted universe multiverse
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble-updates main restricted universe multiverse
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble-backports main restricted universe multiverse
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main multiverse universe restricted
deb [arch=${{ matrix.arch }}] http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse
deb [arch=${{ matrix.arch }}] http://ports.ubuntu.com/ubuntu-ports noble-security main restricted universe multiverse
deb [arch=${{ matrix.arch }}] http://ports.ubuntu.com/ubuntu-ports noble-updates main restricted universe multiverse
deb [arch=${{ matrix.arch }}] http://ports.ubuntu.com/ubuntu-ports noble-backports main restricted universe multiverse
EOF
sudo mv /etc/apt/sources.list /etc/apt/sources.list.default
sudo mv /tmp/sources.list /etc/apt/sources.list
sudo dpkg --add-architecture ${{ matrix.arch }}
sudo apt-get update -y
sudo apt-get -f install -y
sudo apt-get install -y libglib2.0-dev-bin
sudo apt-get install -y \
linux-libc-dev:${{ matrix.arch }} \
libc6-dev:${{ matrix.arch }}
libc6-dev:${{ matrix.arch }} \
libicu-dev:${{ matrix.arch }}
sudo apt-get install -y \
libxslt1.1:${{ matrix.arch }} \
sudo apt-get install -y --no-install-recommends \
libxslt1-dev:${{ matrix.arch }} \
libwebkit2gtk-4.1-dev:${{ matrix.arch }} \
libayatana-appindicator3-dev:${{ matrix.arch }} \
libssl-dev:${{ matrix.arch }} \
patchelf:${{ matrix.arch }} \
librsvg2-dev:${{ matrix.arch }}
librsvg2-dev:${{ matrix.arch }} \
patchelf
- name: Install aarch64 tools
if: matrix.target == 'aarch64-unknown-linux-gnu'
@@ -433,7 +441,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
cache: "pnpm"
- name: Pnpm Cache
@@ -535,7 +543,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4.2.0
name: Install pnpm
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
cache: "pnpm"
- name: Pnpm Cache
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- uses: actions/setup-node@v6
if: steps.check_frontend.outputs.frontend == 'true'
with:
node-version: "24.12.0"
node-version: "24.13.0"
cache: "pnpm"
- name: Restore pnpm cache
+8 -8
View File
@@ -197,7 +197,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -218,7 +218,7 @@ jobs:
- name: Tauri build
# 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本
uses: tauri-apps/tauri-action@v0.6.0
uses: tauri-apps/tauri-action@v0.6.1
env:
NODE_OPTIONS: "--max_old_space_size=4096"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -281,7 +281,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -420,7 +420,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -448,7 +448,7 @@ jobs:
- name: Tauri build
id: build
uses: tauri-apps/tauri-action@v0.6.0
uses: tauri-apps/tauri-action@v0.6.1
env:
NODE_OPTIONS: "--max_old_space_size=4096"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -505,7 +505,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -531,7 +531,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -593,7 +593,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.12.0"
node-version: "24.13.0"
- uses: pnpm/action-setup@v4
name: Install pnpm
+2 -1
View File
@@ -11,4 +11,5 @@ scripts/_env.sh
.idea
.old
.eslintcache
target
.changelog_backups
target
+1
View File
@@ -2,6 +2,7 @@
# Changelog.md
# CONTRIBUTING.md
.changelog_backups
pnpm-lock.yaml
src-tauri/target/
Generated
+728 -784
View File
File diff suppressed because it is too large Load Diff
+20 -13
View File
@@ -1,11 +1,12 @@
[workspace]
members = [
"src-tauri",
"crates/clash-verge-draft",
"crates/clash-verge-logging",
"crates/clash-verge-signal",
"crates/tauri-plugin-clash-verge-sysinfo",
"crates/clash-verge-types",
"src-tauri",
"crates/clash-verge-draft",
"crates/clash-verge-logging",
"crates/clash-verge-signal",
"crates/tauri-plugin-clash-verge-sysinfo",
"crates/clash-verge-types",
"crates/clash-verge-i18n",
]
resolver = "2"
@@ -44,18 +45,19 @@ clash-verge-draft = { path = "crates/clash-verge-draft" }
clash-verge-logging = { path = "crates/clash-verge-logging" }
clash-verge-signal = { path = "crates/clash-verge-signal" }
clash-verge-types = { path = "crates/clash-verge-types" }
clash-verge-i18n = { path = "crates/clash-verge-i18n" }
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
tauri = { version = "2.9.5" }
tauri-plugin-clipboard-manager = "2.3.2"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.100"
criterion = { version = "0.7.0", features = ["async_tokio"] }
tokio = { version = "1.48.0", features = [
"rt-multi-thread",
"macros",
"time",
"sync",
criterion = { version = "0.8.1", features = ["async_tokio"] }
tokio = { version = "1.49.0", features = [
"rt-multi-thread",
"macros",
"time",
"sync",
] }
flexi_logger = "0.31.7"
log = "0.4.29"
@@ -64,13 +66,18 @@ smartstring = { version = "1.0.1" }
compact_str = { version = "0.9.0", features = ["serde"] }
serde = { version = "1.0.228" }
serde_json = { version = "1.0.145" }
serde_json = { version = "1.0.149" }
serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.10.0" }
# *** For Windows platform only ***
deelevate = "0.2.0"
# *********************************
[patch.crates-io]
# Patches until https://github.com/tauri-apps/tao/pull/1167 is merged.
tao = { git = "https://github.com/tauri-apps/tao" }
[workspace.lints.clippy]
correctness = { level = "deny", priority = -1 }
suspicious = { level = "deny", priority = -1 }
+33 -280
View File
@@ -1,296 +1,49 @@
## v2.4.4
## v2.4.5
- **Mihomo(Meta) 内核升级至 v1.19.17**
- **Mihomo(Meta) 内核升级至 v1.19.18**
### 🐞 修复问题
- Linux 无法切换 TUN 堆栈
- macOS service 启动项显示名称(试验性修改)
- macOS 非预期 Tproxy 端口设置
- 流量图缩放异常
- PAC 自动代理脚本内容无法动态调整
- 兼容从旧版服务模式升级
- Monaco 编辑器的行数上限
- 已删除节点在手动分组中导致配置无法加载
- 仪表盘与托盘状态不同步
- 彻底修复 macOS 连接页面显示异常
- windows 端监听关机信号失败
- 修复代理按钮和高亮状态不同步
- 修复侧边栏可能的未能正确跳转
- 修复解锁测试部分地区图标编码不正确
- 修复 IP 检测切页后强制刷新,改为仅在必要时更新
- 修复在搜索框输入不完整正则直接崩溃
- 修复创建窗口时在非简体中文环境或深色主题下的短暂闪烁
- 修复更新时加载进度条异常
- 升级内核失败导致内核不可用问题
- 修复 macOS 在安装和卸载服务时提示与操作不匹配
- 修复菜单排序模式拖拽异常
- 修复托盘菜单代理组前的异常勾选状态
- 修复 Windows 下自定义标题栏按钮在最小化 / 关闭后 hover 状态残留
- 修复直接覆盖 `config.yaml` 使用时无法展开代理组
- 修复 macOS 下应用启动时系统托盘图标颜色闪烁
- 修复应用静默启动模式下非全局热键一直抢占其他应用按键问题
- 修复首页当前节点卡片按延迟排序时,打开节点列表后,`timeout` 节点被排在正常节点前的问题
- 修复 macOS 有线网络 DNS 劫持失败
- 修复 Monaco 编辑器内右键菜单显示异常
- 修复设置代理端口时检查端口占用
- 修复 Monaco 编辑器初始化卡 Loading
- 修复恢复备份时 `config.yaml` / `profiles.yaml` 文件内字段未正确恢复
- 修复 Windows 下系统主题同步问题
- 修复 URL Schemes 无法正常导入
- 修复 Linux 下无法安装 TUN 服务
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 支持连接页面各个项目的排序
- 实现可选的自动备份
- 连接页面支持查看已关闭的连接(最近最多 500 个已关闭连接)
- 日志页面支持按时间倒序
- 增加「重新激活订阅」的全局快捷键
- WebView2 Runtime 修复构建升级到 133.0.3065.92
- 侧边栏右键新增「恢复默认排序」
- Linux 下新增对 TUN 「自动重定向」(`auto-redirect` 字段)的配置支持,默认关闭
- 允许代理页面允许高级过滤搜索
- 备份设置页面新增导入备份按钮
- 允许修改通知弹窗位置
- 支持收起导航栏(导航栏右键菜单 / 界面设置)
- 允许将出站模式显示在托盘一级菜单
- 允许禁用在托盘中显示代理组
- 支持在「编辑节点」中直接导入 AnyTLS URI 配置
- 支持关闭「验证代理绕过格式」
- 新增系统代理绕过和 TUN 排除自定义网段的可视化编辑器
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 网络请求改为使用 rustls,提升 TLS 兼容性
- rustls 避免因服务器证书链配置问题或较新 TLS 要求导致订阅无法导入
- 替换前端信息编辑组件,提供更好性能
- 优化后端内存和性能表现
- 防止退出时可能的禁用 TUN 失败
- 全新 i18n 支持方式
- 优化备份设置布局
- 优化流量图性能表现,实现动态 FPS 和窗口失焦自动暂停
- 性能优化系统状态获取
- 优化托盘菜单当前订阅检测逻辑
- 优化连接页面表格渲染
- 优化链式代理 UI 反馈
- 优化重启应用的资源清理逻辑
- 优化前端数据刷新
- 优化流量采样和数据处理
- 优化应用重启/退出时的资源清理性能, 大幅缩短执行时间
- 优化前端 WebSocket 连接机制
- 改进旧版 Service 需要重新安装检测流程
- 优化 macOS, Linux 和 Windows 系统信号处理
- 链式代理仅显示 Selector 类型规则组
- 优化 Windows 系统代理设置,不再依赖 `sysproxy.exe` 来设置代理
- 应用内更新日志支持解析并渲染 HTML 标签
- 性能优化前后端在渲染流量图时的资源
- 在 Linux NVIDIA 显卡环境下尝试禁用 WebKit DMABUF 渲染以规避潜在问题
- Windows 下自启动改为计划任务实现
- 改进托盘和窗口操作频率限制实现
- 使用「编辑节点」添加节点时,自动将节点添加到第一个 `select` 类型的代理组的第一位
- 隐藏侧边导航栏和悬浮跳转导航的滚动条
- 完善对 AnyTLS / Mieru / Sudoku 的 GUI 支持
- macOS 和 Linux 对服务 IPC 权限进一步限制
- 移除 Windows 自启动计划任务中冗余的 3 秒延时
- 右键错误通知可复制错误详情
- 保存 TUN 设置时优化执行流程,避免界面卡顿
- 补充 `deb` / `rpm` 依赖 `libayatana-appindicator`
- 「连接」表格标题的排序点击区域扩展到整列宽度
</details>
## v2.4.3
**发行代号:澜**
代号释义:澜象征平稳与融合,本次版本聚焦稳定性、兼容性、性能与体验优化,全面提升整体可靠性。
特别感谢 @Slinetrac, @oomeow, @Lythrilla, @Dragon1573 的出色贡献
### 🐞 修复问题
- 优化服务模式重装逻辑,避免不必要的重复检查
- 修复轻量模式退出无响应的问题
- 修复托盘轻量模式支持退出/进入
- 修复静默启动和自动进入轻量模式时,托盘状态刷新不再依赖窗口创建流程
- macOS Tun/系统代理 模式下图标大小不统一
- 托盘节点切换不再显示隐藏组
- 修复前端 IP 检测无法使用 ipapi, ipsb 提供商
- 修复MacOS 下 Tun开启后 系统代理无法打开的问题
- 修复服务模式启动时,修改、生成配置文件或重启内核可能导致页面卡死的问题
- 修复 Webdav 恢复备份不重启
- 修复 Linux 开机后无法正常代理需要手动设置
- 修复增加订阅或导入订阅文件时订阅页面无更新
- 修复系统代理守卫功能不工作
- 修复 KDE + Wayland 下多屏显示 UI 异常
- 修复 Windows 深色模式下首次启动客户端标题栏颜色异常
- 修复静默启动不加载完整 WebView 的问题
- 修复 Linux WebKit 网络进程的崩溃
- 修复无法导入订阅
- 修复实际导入成功但显示导入失败的问题
- 修复服务不可用时,自动关闭 Tun 模式导致应用卡死问题
- 修复删除订阅时未能实际删除相关文件
- 修复 macOS 连接界面显示异常
- 修复规则配置项在不同配置文件间全局共享导致切换被重置的问题
- 修复 Linux Wayland 下部分 GPU 可能出现的 UI 渲染问题
- 修复自动更新使版本回退的问题
- 修复首页自定义卡片在切换轻量模式时失效
- 修复悬浮跳转导航失效
- 修复小键盘热键映射错误
- 修复前端无法及时刷新操作状态
- 修复 macOS 从 Dock 栏退出轻量模式状态不同步
- 修复 Linux 系统主题切换不生效
- 修复 `允许自动更新` 字段使手动订阅刷新失效
- 修复轻量模式托盘状态不同步
- 修复一键导入订阅导致应用卡死崩溃的问题
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- **Mihomo(Meta) 内核升级至 v1.19.15**
- 支持前端修改日志(最大文件大小、最大保留数量)
- 新增链式代理图形化设置功能
- 新增系统标题栏与程序标题栏切换 (设置-页面设置-倾向系统标题栏)
- 监听关机事件,自动关闭系统代理
- 主界面“当前节点”卡片新增“延迟测试”按钮
- 新增批量选择配置文件功能
- Windows / Linux / MacOS 监听关机信号,优雅恢复网络设置
- 新增本地备份功能
- 主界面“当前节点”卡片新增自动延迟检测开关(默认关闭)
- 允许独立控制订阅自动更新
- 托盘 `更多` 中新增 `关闭所有连接` 按钮
- 新增左侧菜单栏的排序功能(右键点击左侧菜单栏)
- 托盘 `打开目录` 中新增 `应用日志` 和 `内核日志`
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 重构并简化服务模式启动检测流程,消除重复检测
- 重构并简化窗口创建流程
- 重构日志系统,单个日志默认最大 10 MB
- 优化前端资源占用
- 改进 macos 下系统代理设置的方法
- 优化 TUN 模式可用性的判断
- 移除流媒体检测的系统级提示(使用软件内通知)
- 优化后端 i18n 资源占用
- 改进 Linux 托盘支持并添加 `--no-tray` 选项
- Linux 现在在新生成的配置中默认将 TUN 栈恢复为 mixed 模式
- 为代理延迟测试的 URL 设置增加了保护以及添加了安全的备用 URL
- 更新了 Wayland 合成器检测逻辑,从而在 Hyprland 会话中保留原生 Wayland 后端
- 改进 Windows 和 Unix 的 服务连接方式以及权限,避免无法连接服务或内核
- 修改内核默认日志级别为 Info
- 支持通过桌面快捷方式重新打开应用
- 支持订阅界面输入链接后回车导入
- 选择按延迟排序时每次延迟测试自动刷新节点顺序
- 配置重载失败时自动重启核心
- 启用 TUN 前等待服务就绪
- 卸载 TUN 时会先关闭
- 优化应用启动页
- 优化首页当前节点对MATCH规则的支持
- 允许在 `界面设置` 修改 `悬浮跳转导航延迟`
- 添加热键绑定错误的提示信息
- 在 macOS 10.15 及更高版本默认包含 Mihomo-go122,以解决 Intel 架构 Mac 无法运行内核的问题
- Tun 模式不可用时,禁用系统托盘的 Tun 模式菜单
- 改进订阅更新方式,仍失败需打开订阅设置 `允许危险证书`
- 允许设置 Mihomo 端口范围 1000(含) - 65536(含)
</details>
## v2.4.2
### ✨ 新增功能
- 增加托盘节点选择
### 🚀 性能优化
- 优化前端首页加载速度
- 优化前端未使用 i18n 文件缓存
- 优化后端内存占用
- 优化后端启动速度
### 🐞 修复问题
- 修复首页节点切换失效的问题
- 修复和优化服务检查流程
- 修复2.4.1引入的订阅地址重定向报错问题
- 修复 rpm/deb 包名称问题
- 修复托盘轻量模式状态检测异常
- 修复通过 scheme 导入订阅崩溃
- 修复单例检测实效
- 修复启动阶段可能导致的无法连接内核
- 修复导入订阅无法 Auth Basic
### 👙 界面样式
- 简化和改进代理设置样式
## v2.4.1
### 🏆 重大改进
- **应用响应速度提升**:采用全新异步处理架构,大幅提升应用响应速度和稳定性
### ✨ 新增功能
- **Mihomo(Meta) 内核升级至 v1.19.13**
### 🚀 性能优化
- 优化热键响应速度,提升快捷键操作体验
- 改进服务管理响应性,减少系统服务操作等待时间
- 提升文件和配置处理性能
- 优化任务管理和日志记录效率
- 优化异步内存管理,减少内存占用并提升多任务处理效率
- 优化启动阶段初始化性能
### 🐞 修复问题
- 修复应用在某些操作中可能出现的响应延迟问题
- 修复任务管理中的潜在并发问题
- 修复通过托盘重启应用无法恢复
- 修复订阅在某些情况下无法导入
- 修复无法新建订阅时使用远程链接
- 修复卸载服务后的 tun 开关状态问题
- 修复页面快速切换订阅时导致崩溃
- 修复丢失工作目录时无法恢复环境
- 修复从轻量模式恢复导致崩溃
### 👙 界面样式
- 统一代理设置样式
### 🗑️ 移除内容
- 移除启动阶段自动清理过期订阅
## v2.4.0
**发行代号:融**
代号释义: 「融」象征融合与贯通,寓意新版本通过全新 IPC 通信机制 将系统各部分紧密衔接,打破壁垒,实现更高效的 数据流通与全面性能优化。
### 🏆 重大改进
- **核心通信架构升级**:采用全新通信机制,提升应用性能和稳定性
- **流量监控系统重构**:全新的流量监控界面,支持更丰富的数据展示
- **数据缓存优化**:改进配置和节点数据缓存,提升响应速度
### ✨ 新增功能
- **Mihomo(Meta) 内核升级至 v1.19.12**
- 新增版本信息复制按钮
- 增强型流量监控,支持更详细的数据分析
- 新增流量图表多种显示模式
- 新增强制刷新配置和节点缓存功能
- 首页流量统计支持查看刻度线详情
### 🚀 性能优化
- 全面提升数据传输和处理效率
- 优化内存使用,减少系统资源消耗
- 改进流量图表渲染性能
- 优化配置和节点刷新策略,从5秒延长到60秒
- 改进数据缓存机制,减少重复请求
- 优化异步程序性能
### 🐞 修复问题
- 修复系统代理状态检测和显示不一致问题
- 修复系统主题窗口颜色不一致问题
- 修复特殊字符 URL 处理问题
- 修复配置修改后缓存不同步问题
- 修复 Windows 安装器自启设置问题
- 修复 macOS 下 Dock 图标恢复窗口问题
- 修复 linux 下 KDE/Plasma 异常标题栏按钮
- 修复架构升级后节点测速功能异常
- 修复架构升级后流量统计功能异常
- 修复架构升级后日志功能异常
- 修复外部控制器跨域配置保存问题
- 修复首页端口显示不一致问题
- 修复首页流量统计刻度线显示问题
- 修复日志页面按钮功能混淆问题
- 修复日志等级设置保存问题
- 修复日志等级异常过滤
- 修复清理日志天数功能异常
- 修复偶发性启动卡死问题
- 修复首页虚拟网卡开关在管理模式下的状态问题
### 🔧 技术改进
- 统一使用新的内核通信方式
- 新增外部控制器配置界面
- 改进跨平台兼容性支持
+1 -2
View File
@@ -104,8 +104,7 @@ pub fn bench_draft(c: &mut Criterion) {
let draft = black_box(make_draft());
let _: Result<(), anyhow::Error> = draft
.with_data_modify::<_, _, _>(|mut box_data| async move {
box_data.enable_auto_launch =
Some(!box_data.enable_auto_launch.unwrap_or(false));
box_data.enable_auto_launch = Some(!box_data.enable_auto_launch.unwrap_or(false));
Ok((box_data, ()))
})
.await;
+21 -31
View File
@@ -1,8 +1,8 @@
use parking_lot::RwLock;
use std::sync::Arc;
pub type SharedBox<T> = Arc<Box<T>>;
type DraftInner<T> = (SharedBox<T>, Option<SharedBox<T>>);
pub type SharedDraft<T> = Arc<T>;
type DraftInner<T> = (SharedDraft<T>, Option<SharedDraft<T>>);
/// Draft 管理:committed 与 optional draft 都以 Arc<Box<T>> 存储,
// (committed_snapshot, optional_draft_snapshot)
@@ -15,12 +15,12 @@ impl<T: Clone> Draft<T> {
#[inline]
pub fn new(data: T) -> Self {
Self {
inner: Arc::new(RwLock::new((Arc::new(Box::new(data)), None))),
inner: Arc::new(RwLock::new((Arc::new(data), None))),
}
}
/// 以 Arc<Box<T>> 的形式获取当前“已提交(正式)”数据的快照(零拷贝,仅 clone Arc)
#[inline]
pub fn data_arc(&self) -> SharedBox<T> {
pub fn data_arc(&self) -> SharedDraft<T> {
let guard = self.inner.read();
Arc::clone(&guard.0)
}
@@ -28,7 +28,7 @@ impl<T: Clone> Draft<T> {
/// 获取当前(草稿若存在则返回草稿,否则返回已提交)的快照
/// 这也是零拷贝:只 clone Arc,不 clone T
#[inline]
pub fn latest_arc(&self) -> SharedBox<T> {
pub fn latest_arc(&self) -> SharedDraft<T> {
let guard = self.inner.read();
guard.1.clone().unwrap_or_else(|| Arc::clone(&guard.0))
}
@@ -41,21 +41,11 @@ impl<T: Clone> Draft<T> {
where
F: FnOnce(&mut T) -> R,
{
// 先获得写锁以创建或取出草稿 Arc 的可变引用位置
let mut guard = self.inner.write();
let mut draft_arc = if guard.1.is_none() {
Arc::clone(&guard.0)
} else {
#[allow(clippy::unwrap_used)]
guard.1.take().unwrap()
};
drop(guard);
// Arc::make_mut: 如果只有一个引用则返回可变引用;否则会克隆底层 Box<T>(要求 T: Clone)
let boxed = Arc::make_mut(&mut draft_arc); // &mut Box<T>
// 对 Box<T> 解引用得到 &mut T
let result = f(&mut **boxed);
// 恢复修改后的草稿 Arc
self.inner.write().1 = Some(draft_arc);
let mut draft_arc = guard.1.take().unwrap_or_else(|| Arc::clone(&guard.0));
let data_mut = Arc::make_mut(&mut draft_arc);
let result = f(data_mut);
guard.1 = Some(draft_arc);
result
}
@@ -81,22 +71,22 @@ impl<T: Clone> Draft<T> {
pub async fn with_data_modify<F, Fut, R>(&self, f: F) -> Result<R, anyhow::Error>
where
T: Send + Sync + 'static,
F: FnOnce(Box<T>) -> Fut + Send,
Fut: std::future::Future<Output = Result<(Box<T>, R), anyhow::Error>> + Send,
F: FnOnce(T) -> Fut + Send,
Fut: std::future::Future<Output = Result<(T, R), anyhow::Error>> + Send,
{
// 读取已提交快照(cheap Arc clone, 然后得到 Box<T> 所有权 via clone)
// 注意:为了让闭包接收 Box<T> 所有权,我们需要 clone 底层 T(不可避免)
let local: Box<T> = {
let (local, original_arc) = {
let guard = self.inner.read();
// 将 Arc<Box<T>> 的 Box<T> clone 出来(会调用 T: Clone)
(*guard.0).clone()
let arc = Arc::clone(&guard.0);
((*arc).clone(), arc)
};
let (new_local, res) = f(local).await?;
// 将新的 Box<T> 放到已提交位置(包进 Arc)
self.inner.write().0 = Arc::new(new_local);
let mut guard = self.inner.write();
if !Arc::ptr_eq(&guard.0, &original_arc) {
return Err(anyhow::anyhow!(
"Optimistic lock failed: Committed data has changed during async operation"
));
}
guard.0 = Arc::from(new_local);
Ok(res)
}
}
+5 -11
View File
@@ -133,10 +133,7 @@ mod tests {
let prev_draft_ptr = std::sync::Arc::as_ptr(&draft_after_first_edit);
draft.apply();
let committed_after_apply = draft.data_arc();
assert_eq!(
std::sync::Arc::as_ptr(&committed_after_apply),
prev_draft_ptr
);
assert_eq!(std::sync::Arc::as_ptr(&committed_after_apply), prev_draft_ptr);
// 第二次编辑:此时草稿唯一持有(无其它引用),不应再克隆
// 获取草稿 Arc 的指针并立即丢弃本地引用,避免增加 strong_count
@@ -198,7 +195,7 @@ mod tests {
// 使用 with_data_modify 异步(立即就绪)地更新 committed
let res = block_on_ready(draft.with_data_modify(|mut v| async move {
v.enable_auto_launch = Some(true);
Ok((Box::new(*v), "done")) // Dereference v to get Box<T>
Ok((v, "done"))
}));
assert_eq!(
{
@@ -218,11 +215,8 @@ mod tests {
let draft = Draft::new(IVerge::default());
#[allow(clippy::unwrap_used)]
let err = block_on_ready(draft.with_data_modify(|v| async move {
drop(v);
Err::<(Box<IVerge>, ()), _>(anyhow!("boom"))
}))
.unwrap_err();
let err = block_on_ready(draft.with_data_modify(|_v| async move { Err::<(IVerge, ()), _>(anyhow!("boom")) }))
.unwrap_err();
assert_eq!(format!("{err}"), "boom");
}
@@ -246,7 +240,7 @@ mod tests {
#[allow(clippy::unwrap_used)]
block_on_ready(draft.with_data_modify(|mut v| async move {
v.enable_auto_launch = Some(false); // 与草稿不同
Ok((Box::new(*v), ())) // Dereference v to get Box<T>
Ok((v, ()))
}))
.unwrap();
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "clash-verge-i18n"
version = "0.1.0"
edition = "2024"
[dependencies]
rust-i18n = "3.1.5"
sys-locale = "0.3.2"
[lints]
workspace = true
@@ -25,7 +25,8 @@ notifications:
title: Application Hidden
body: Clash Verge is running in the background.
service:
adminPrompt: Installing the service requires administrator privileges.
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
@@ -25,7 +25,8 @@ notifications:
title: Application Hidden
body: Clash Verge is running in the background.
service:
adminPrompt: Installing the service requires administrator privileges.
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
@@ -25,9 +25,8 @@ notifications:
title: Application Hidden
body: Clash Verge is running in the background.
service:
adminInstallPrompt: Installing the service requires administrator privileges.
adminUninstallPrompt: Uninstalling the service requires administrator privileges.
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
@@ -25,7 +25,8 @@ notifications:
title: Application Hidden
body: Clash Verge is running in the background.
service:
adminPrompt: Installing the service requires administrator privileges.
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
@@ -25,7 +25,8 @@ notifications:
title: Application Hidden
body: Clash Verge is running in the background.
service:
adminPrompt: Installing the service requires administrator privileges.
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
@@ -25,7 +25,8 @@ notifications:
title: Application Hidden
body: Clash Verge is running in the background.
service:
adminPrompt: Installing the service requires administrator privileges.
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
@@ -25,7 +25,8 @@ notifications:
title: Application Hidden
body: Clash Verge is running in the background.
service:
adminPrompt: Installing the service requires administrator privileges.
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
@@ -25,7 +25,8 @@ notifications:
title: 앱이 숨겨짐
body: Clash Verge가 백그라운드에서 실행 중입니다.
service:
adminPrompt: 서비스를 설치하려면 관리자 권한이 필요합니다.
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
tray:
dashboard: 대시보드
ruleMode: 규칙 모드
@@ -25,7 +25,8 @@ notifications:
title: Application Hidden
body: Clash Verge is running in the background.
service:
adminPrompt: Installing the service requires administrator privileges.
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
@@ -25,7 +25,8 @@ notifications:
title: Application Hidden
body: Clash Verge is running in the background.
service:
adminPrompt: Installing the service requires administrator privileges.
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
@@ -25,7 +25,8 @@ notifications:
title: Application Hidden
body: Clash Verge is running in the background.
service:
adminPrompt: Installing the service requires administrator privileges.
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
tray:
dashboard: Dashboard
ruleMode: Rule Mode
@@ -25,8 +25,8 @@ notifications:
title: 應用已隱藏
body: Clash Verge 正在背景執行。
service:
adminInstallPrompt: 安裝服務需要管理員權限
adminUninstallPrompt: 卸载服務需要管理員權限
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
adminUninstallPrompt: 卸载 Clash Verge 服務需要管理員權限
tray:
dashboard: 儀表板
ruleMode: 規則模式
+103
View File
@@ -0,0 +1,103 @@
use rust_i18n::i18n;
const DEFAULT_LANGUAGE: &str = "zh";
i18n!("locales", fallback = "zh");
#[inline]
fn locale_alias(locale: &str) -> Option<&'static str> {
match locale {
"ja" | "ja-jp" | "jp" => Some("jp"),
"zh" | "zh-cn" | "zh-hans" | "zh-sg" | "zh-my" | "zh-chs" => Some("zh"),
"zh-tw" | "zh-hk" | "zh-hant" | "zh-mo" | "zh-cht" => Some("zhtw"),
_ => None,
}
}
#[inline]
fn resolve_supported_language(language: &str) -> Option<&'static str> {
if language.is_empty() {
return None;
}
let normalized = language.to_lowercase().replace('_', "-");
let segments: Vec<&str> = normalized.split('-').collect();
let supported = rust_i18n::available_locales!();
for i in (1..=segments.len()).rev() {
let prefix = segments[..i].join("-");
if let Some(alias) = locale_alias(&prefix)
&& let Some(&found) = supported.iter().find(|&&l| l.eq_ignore_ascii_case(alias))
{
return Some(found);
}
if let Some(&found) = supported.iter().find(|&&l| l.eq_ignore_ascii_case(&prefix)) {
return Some(found);
}
}
None
}
#[inline]
fn current_language(language: Option<&str>) -> &str {
language
.as_ref()
.filter(|lang| !lang.is_empty())
.and_then(|lang| resolve_supported_language(lang))
.unwrap_or_else(system_language)
}
#[inline]
pub fn system_language() -> &'static str {
sys_locale::get_locale()
.as_deref()
.and_then(resolve_supported_language)
.unwrap_or(DEFAULT_LANGUAGE)
}
#[inline]
pub fn sync_locale(language: Option<&str>) {
let language = current_language(language);
set_locale(language);
}
#[inline]
pub fn set_locale(language: &str) {
let lang = resolve_supported_language(language).unwrap_or(DEFAULT_LANGUAGE);
rust_i18n::set_locale(lang);
}
#[inline]
pub fn translate(key: &str) -> Cow<'_, str> {
rust_i18n::t!(key)
}
#[macro_export]
macro_rules! t {
($key:expr) => {
$crate::translate(&$key)
};
($key:expr, $($arg_name:ident = $arg_value:expr),*) => {
{
let mut _text = $crate::translate(&$key);
$(
_text = _text.replace(&format!("{{{}}}", stringify!($arg_name)), &$arg_value);
)*
_text
}
};
}
#[cfg(test)]
mod test {
use super::resolve_supported_language;
#[test]
fn test_resolve_supported_language() {
assert_eq!(resolve_supported_language("en"), Some("en"));
assert_eq!(resolve_supported_language("en-US"), Some("en"));
assert_eq!(resolve_supported_language("zh"), Some("zh"));
assert_eq!(resolve_supported_language("zh-CN"), Some("zh"));
assert_eq!(resolve_supported_language("zh-Hant"), Some("zhtw"));
assert_eq!(resolve_supported_language("jp"), Some("jp"));
assert_eq!(resolve_supported_language("ja-JP"), Some("jp"));
assert_eq!(resolve_supported_language("fr"), None);
}
}
-1
View File
@@ -11,4 +11,3 @@ flexi_logger = { workspace = true }
[features]
default = []
tauri-dev = []
+4 -14
View File
@@ -1,6 +1,5 @@
use compact_str::CompactString;
use flexi_logger::DeferredNow;
#[cfg(not(feature = "tauri-dev"))]
use flexi_logger::filter::LogLineFilter;
use flexi_logger::writers::FileLogWriter;
use flexi_logger::writers::LogWriter as _;
@@ -93,27 +92,19 @@ pub fn write_sidecar_log(
) {
let args = format_args!("{}", message);
let record = Record::builder()
.args(args)
.level(level)
.target("sidecar")
.build();
let record = Record::builder().args(args).level(level).target("sidecar").build();
let _ = writer.write(now, &record);
}
#[cfg(not(feature = "tauri-dev"))]
pub struct NoModuleFilter<'a>(pub &'a [&'a str]);
pub struct NoModuleFilter<'a>(pub Vec<&'a str>);
#[cfg(not(feature = "tauri-dev"))]
impl<'a> NoModuleFilter<'a> {
#[inline]
pub fn filter(&self, record: &Record) -> bool {
if let Some(module) = record.module_path() {
for blocked in self.0 {
if module.len() >= blocked.len()
&& module.as_bytes()[..blocked.len()] == blocked.as_bytes()[..]
{
for blocked in self.0.iter() {
if module.len() >= blocked.len() && module.as_bytes()[..blocked.len()] == blocked.as_bytes()[..] {
return false;
}
}
@@ -122,7 +113,6 @@ impl<'a> NoModuleFilter<'a> {
}
}
#[cfg(not(feature = "tauri-dev"))]
impl<'a> LogLineFilter for NoModuleFilter<'a> {
#[inline]
fn write(
+3 -18
View File
@@ -17,36 +17,21 @@ where
let mut sigterm = match signal(SignalKind::terminate()) {
Ok(s) => s,
Err(e) => {
logging!(
error,
Type::SystemSignal,
"Failed to register SIGTERM: {}",
e
);
logging!(error, Type::SystemSignal, "Failed to register SIGTERM: {}", e);
return;
}
};
let mut sigint = match signal(SignalKind::interrupt()) {
Ok(s) => s,
Err(e) => {
logging!(
error,
Type::SystemSignal,
"Failed to register SIGINT: {}",
e
);
logging!(error, Type::SystemSignal, "Failed to register SIGINT: {}", e);
return;
}
};
let mut sighup = match signal(SignalKind::hangup()) {
Ok(s) => s,
Err(e) => {
logging!(
error,
Type::SystemSignal,
"Failed to register SIGHUP: {}",
e
);
logging!(error, Type::SystemSignal, "Failed to register SIGHUP: {}", e);
return;
}
};
+5 -30
View File
@@ -17,12 +17,7 @@ where
let mut ctrl_c = match windows::ctrl_c() {
Ok(s) => s,
Err(e) => {
logging!(
error,
Type::SystemSignal,
"Failed to register Ctrl+C: {}",
e
);
logging!(error, Type::SystemSignal, "Failed to register Ctrl+C: {}", e);
return;
}
};
@@ -30,12 +25,7 @@ where
let mut ctrl_close = match windows::ctrl_close() {
Ok(s) => s,
Err(e) => {
logging!(
error,
Type::SystemSignal,
"Failed to register Ctrl+Close: {}",
e
);
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Close: {}", e);
return;
}
};
@@ -43,12 +33,7 @@ where
let mut ctrl_shutdown = match windows::ctrl_shutdown() {
Ok(s) => s,
Err(e) => {
logging!(
error,
Type::SystemSignal,
"Failed to register Ctrl+Shutdown: {}",
e
);
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Shutdown: {}", e);
return;
}
};
@@ -56,12 +41,7 @@ where
let mut ctrl_logoff = match windows::ctrl_logoff() {
Ok(s) => s,
Err(e) => {
logging!(
error,
Type::SystemSignal,
"Failed to register Ctrl+Logoff: {}",
e
);
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Logoff: {}", e);
return;
}
};
@@ -94,12 +74,7 @@ where
}
IS_CLEANING_UP.store(true, Ordering::SeqCst);
logging!(
info,
Type::SystemSignal,
"Caught Windows signal: {}",
signal_name
);
logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name);
f().await;
}
+7 -11
View File
@@ -125,9 +125,8 @@ impl IRuntime {
&& let Some(Value::Sequence(proxies)) = config.get_mut("proxies")
{
for (i, dialer_proxy) in dialer_proxies.iter().enumerate() {
if let Some(Value::Mapping(proxy)) = proxies
.iter_mut()
.find(|proxy| proxy.get("name") == Some(dialer_proxy))
if let Some(Value::Mapping(proxy)) =
proxies.iter_mut().find(|proxy| proxy.get("name") == Some(dialer_proxy))
&& i != 0
&& let Some(dialer_proxy) = dialer_proxies.get(i - 1)
{
@@ -141,12 +140,9 @@ impl IRuntime {
// TODO 完整迁移 enhance 行为后移除
#[inline]
fn use_keys<'a>(config: &'a Mapping) -> impl Iterator<Item = String> + 'a {
config
.iter()
.filter_map(|(key, _)| key.as_str())
.map(|s: &str| {
let mut s: String = s.into();
s.make_ascii_lowercase();
s
})
config.iter().filter_map(|(key, _)| key.as_str()).map(|s: &str| {
let mut s: String = s.into();
s.make_ascii_lowercase();
s
})
}
@@ -11,7 +11,7 @@ parking_lot = { workspace = true }
sysinfo = { version = "0.37.2", features = ["network", "system"] }
[target.'cfg(not(windows))'.dependencies]
libc = "0.2.178"
libc = "0.2.180"
[target.'cfg(windows)'.dependencies]
deelevate = { workspace = true }
@@ -13,13 +13,7 @@ pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<String, Err
/// 获取应用的运行时间(毫秒)
#[command]
pub fn get_app_uptime(state: State<'_, RwLock<Platform>>) -> Result<u128, Error> {
Ok(state
.inner()
.read()
.appinfo
.app_startup_time
.elapsed()
.as_millis())
Ok(state.inner().read().appinfo.app_startup_time.elapsed().as_millis())
}
/// 检查应用是否以管理员身份运行
@@ -7,6 +7,8 @@ pub mod commands;
#[cfg(windows)]
use deelevate::{PrivilegeLevel, Token};
#[cfg(unix)]
pub use libc;
use parking_lot::RwLock;
use sysinfo::{Networks, System};
use tauri::{
@@ -132,6 +134,13 @@ pub fn set_app_core_mode<R: Runtime>(app: &tauri::AppHandle<R>, mode: impl Into<
spec.appinfo.app_core_mode = mode.into();
}
#[inline]
pub fn get_app_uptime<R: Runtime>(app: &tauri::AppHandle<R>) -> Instant {
let platform_spec = app.state::<RwLock<Platform>>();
let spec = platform_spec.read();
spec.appinfo.app_startup_time
}
#[inline]
pub fn is_current_app_handle_admin<R: Runtime>(app: &tauri::AppHandle<R>) -> bool {
let platform_spec = app.state::<RwLock<Platform>>();
View File
+10 -10
View File
@@ -5,8 +5,8 @@ Thanks for helping localize Clash Verge Rev. This guide reflects the current arc
## Quick workflow
- Update the language folder under `src/locales/<lang>/`; use `src/locales/en/` as the canonical reference for keys and intent.
- Run `pnpm format:i18n` to align structure and `pnpm i18n:types` to refresh generated typings.
- If you touch backend copy, edit the matching YAML file in `src-tauri/locales/<lang>.yml`.
- Run `pnpm i18n:format` to align structure (frontend JSON + backend YAML) and `pnpm i18n:types` to refresh generated typings.
- If you touch backend copy, edit the matching YAML file in `crates/clash-verge-i18n/locales/<lang>.yml`.
- Preview UI changes with `pnpm dev` (desktop shell) or `pnpm web:dev` (web only).
- Keep PRs focused and add screenshots whenever layout could be affected by text length.
@@ -33,29 +33,29 @@ src/locales/
Because backend translations now live in their own directory, you no longer need to run `pnpm prebuild` just to sync locales—the frontend folder is the sole source of truth for web bundles.
## Tooling for frontend contributors
## Tooling for i18n contributors
- `pnpm format:i18n` → `node scripts/cleanup-unused-i18n.mjs --align --apply`. It aligns key ordering, removes unused entries, and keeps all locales in lock-step with English.
- `pnpm node scripts/cleanup-unused-i18n.mjs` (without flags) performs a dry-run audit. Use it to inspect missing or extra keys before committing.
- `pnpm i18n:format` → `node scripts/cleanup-unused-i18n.mjs --align --apply`. It aligns key ordering, removes unused entries, and keeps all locales in lock-step with English across both JSON and YAML bundles.
- `pnpm i18n:check` performs a dry-run audit of frontend and backend keys. It scans TS/TSX usage plus Rust `t!(...)` calls in `src-tauri/` and `crates/` to spot missing or extra entries.
- `pnpm i18n:types` regenerates `src/types/generated/i18n-keys.ts` and `src/types/generated/i18n-resources.ts`, ensuring TypeScript catches invalid key usage.
- For dynamic keys that the analyzer cannot statically detect, add explicit references in code or update the script whitelist to avoid false positives.
## Backend (Tauri) locale bundles
Native UI strings (tray menu, notifications, dialogs) use `rust-i18n` with YAML bundles stored in `src-tauri/locales/<lang>.yml`. These files are completely independent from the frontend JSON modules.
Native UI strings (tray menu, notifications, dialogs) use `rust-i18n` with YAML bundles stored in `crates/clash-verge-i18n/locales/<lang>.yml`. These files are completely independent from the frontend JSON modules.
- Keep `en.yml` semantically aligned with the Simplified Chinese baseline (`zh.yml`). Other locales may temporarily copy English if no translation is available yet.
- When a backend feature introduces new strings, update every YAML file to keep the key set consistent. Missing keys fall back to the default language (`zh`), so catching gaps early avoids mixed-language output.
- Rust code resolves the active language through `src-tauri/src/utils/i18n.rs`. No additional build step is required after editing YAML files; `tauri dev` and `tauri build` pick them up automatically.
- The same `pnpm i18n:check` / `pnpm i18n:format` tooling now validates backend YAML keys against Rust usage, so run it after backend i18n edits.
- Rust code resolves the active language through the `clash-verge-i18n` crate (`crates/clash-verge-i18n/src/lib.rs`). No additional build step is required after editing YAML files; `tauri dev` and `tauri build` pick them up automatically.
## Adding a new language
1. Duplicate `src/locales/en/` into `src/locales/<new-lang>/` and translate the JSON files while preserving key structure.
2. Update the locale’s `index.ts` to import every namespace. Matching the English file is the easiest way to avoid missing exports.
3. Append the language code to `supportedLanguages` in `src/services/i18n.ts`.
4. If the backend should expose the language, create `src-tauri/locales/<new-lang>.yml` and translate the keys used in existing YAML files.
5. Adjust `crowdin.yml` if the locale requires a special mapping for Crowdin.
6. Run `pnpm format:i18n`, `pnpm i18n:types`, and (optionally) `pnpm node scripts/cleanup-unused-i18n.mjs` in dry-run mode to confirm structure.
4. If the backend should expose the language, create `crates/clash-verge-i18n/<new-lang>.yml` and translate the keys used in existing YAML files.
5. Run `pnpm i18n:format`, `pnpm i18n:types`, and (optionally) `pnpm i18n:check` in dry-run mode to confirm structure.
## Authoring guidelines
+297
View File
@@ -1,3 +1,300 @@
## v2.4.4
- **Mihomo(Meta) 内核升级至 v1.19.17**
### 🐞 修复问题
- Linux 无法切换 TUN 堆栈
- macOS service 启动项显示名称(试验性修改)
- macOS 非预期 Tproxy 端口设置
- 流量图缩放异常
- PAC 自动代理脚本内容无法动态调整
- 兼容从旧版服务模式升级
- Monaco 编辑器的行数上限
- 已删除节点在手动分组中导致配置无法加载
- 仪表盘与托盘状态不同步
- 彻底修复 macOS 连接页面显示异常
- windows 端监听关机信号失败
- 修复代理按钮和高亮状态不同步
- 修复侧边栏可能的未能正确跳转
- 修复解锁测试部分地区图标编码不正确
- 修复 IP 检测切页后强制刷新,改为仅在必要时更新
- 修复在搜索框输入不完整正则直接崩溃
- 修复创建窗口时在非简体中文环境或深色主题下的短暂闪烁
- 修复更新时加载进度条异常
- 升级内核失败导致内核不可用问题
- 修复 macOS 在安装和卸载服务时提示与操作不匹配
- 修复菜单排序模式拖拽异常
- 修复托盘菜单代理组前的异常勾选状态
- 修复 Windows 下自定义标题栏按钮在最小化 / 关闭后 hover 状态残留
- 修复直接覆盖 `config.yaml` 使用时无法展开代理组
- 修复 macOS 下应用启动时系统托盘图标颜色闪烁
- 修复应用静默启动模式下非全局热键一直抢占其他应用按键问题
- 修复首页当前节点卡片按延迟排序时,打开节点列表后,`timeout` 节点被排在正常节点前的问题
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 支持连接页面各个项目的排序
- 实现可选的自动备份
- 连接页面支持查看已关闭的连接(最近最多 500 个已关闭连接)
- 日志页面支持按时间倒序
- 增加「重新激活订阅」的全局快捷键
- WebView2 Runtime 修复构建升级到 133.0.3065.92
- 侧边栏右键新增「恢复默认排序」
- Linux 下新增对 TUN 「自动重定向」(`auto-redirect` 字段)的配置支持,默认关闭
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 网络请求改为使用 rustls,提升 TLS 兼容性
- rustls 避免因服务器证书链配置问题或较新 TLS 要求导致订阅无法导入
- 替换前端信息编辑组件,提供更好性能
- 优化后端内存和性能表现
- 防止退出时可能的禁用 TUN 失败
- 全新 i18n 支持方式
- 优化备份设置布局
- 优化流量图性能表现,实现动态 FPS 和窗口失焦自动暂停
- 性能优化系统状态获取
- 优化托盘菜单当前订阅检测逻辑
- 优化连接页面表格渲染
- 优化链式代理 UI 反馈
- 优化重启应用的资源清理逻辑
- 优化前端数据刷新
- 优化流量采样和数据处理
- 优化应用重启/退出时的资源清理性能, 大幅缩短执行时间
- 优化前端 WebSocket 连接机制
- 改进旧版 Service 需要重新安装检测流程
- 优化 macOS, Linux 和 Windows 系统信号处理
- 链式代理仅显示 Selector 类型规则组
- 优化 Windows 系统代理设置,不再依赖 `sysproxy.exe` 来设置代理
</details>
## v2.4.3
**发行代号:澜**
代号释义:澜象征平稳与融合,本次版本聚焦稳定性、兼容性、性能与体验优化,全面提升整体可靠性。
特别感谢 @Slinetrac, @oomeow, @Lythrilla, @Dragon1573 的出色贡献
### 🐞 修复问题
- 优化服务模式重装逻辑,避免不必要的重复检查
- 修复轻量模式退出无响应的问题
- 修复托盘轻量模式支持退出/进入
- 修复静默启动和自动进入轻量模式时,托盘状态刷新不再依赖窗口创建流程
- macOS Tun/系统代理 模式下图标大小不统一
- 托盘节点切换不再显示隐藏组
- 修复前端 IP 检测无法使用 ipapi, ipsb 提供商
- 修复MacOS 下 Tun开启后 系统代理无法打开的问题
- 修复服务模式启动时,修改、生成配置文件或重启内核可能导致页面卡死的问题
- 修复 Webdav 恢复备份不重启
- 修复 Linux 开机后无法正常代理需要手动设置
- 修复增加订阅或导入订阅文件时订阅页面无更新
- 修复系统代理守卫功能不工作
- 修复 KDE + Wayland 下多屏显示 UI 异常
- 修复 Windows 深色模式下首次启动客户端标题栏颜色异常
- 修复静默启动不加载完整 WebView 的问题
- 修复 Linux WebKit 网络进程的崩溃
- 修复无法导入订阅
- 修复实际导入成功但显示导入失败的问题
- 修复服务不可用时,自动关闭 Tun 模式导致应用卡死问题
- 修复删除订阅时未能实际删除相关文件
- 修复 macOS 连接界面显示异常
- 修复规则配置项在不同配置文件间全局共享导致切换被重置的问题
- 修复 Linux Wayland 下部分 GPU 可能出现的 UI 渲染问题
- 修复自动更新使版本回退的问题
- 修复首页自定义卡片在切换轻量模式时失效
- 修复悬浮跳转导航失效
- 修复小键盘热键映射错误
- 修复前端无法及时刷新操作状态
- 修复 macOS 从 Dock 栏退出轻量模式状态不同步
- 修复 Linux 系统主题切换不生效
- 修复 `允许自动更新` 字段使手动订阅刷新失效
- 修复轻量模式托盘状态不同步
- 修复一键导入订阅导致应用卡死崩溃的问题
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- **Mihomo(Meta) 内核升级至 v1.19.15**
- 支持前端修改日志(最大文件大小、最大保留数量)
- 新增链式代理图形化设置功能
- 新增系统标题栏与程序标题栏切换 (设置-页面设置-倾向系统标题栏)
- 监听关机事件,自动关闭系统代理
- 主界面“当前节点”卡片新增“延迟测试”按钮
- 新增批量选择配置文件功能
- Windows / Linux / MacOS 监听关机信号,优雅恢复网络设置
- 新增本地备份功能
- 主界面“当前节点”卡片新增自动延迟检测开关(默认关闭)
- 允许独立控制订阅自动更新
- 托盘 `更多` 中新增 `关闭所有连接` 按钮
- 新增左侧菜单栏的排序功能(右键点击左侧菜单栏)
- 托盘 `打开目录` 中新增 `应用日志` 和 `内核日志`
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 重构并简化服务模式启动检测流程,消除重复检测
- 重构并简化窗口创建流程
- 重构日志系统,单个日志默认最大 10 MB
- 优化前端资源占用
- 改进 macos 下系统代理设置的方法
- 优化 TUN 模式可用性的判断
- 移除流媒体检测的系统级提示(使用软件内通知)
- 优化后端 i18n 资源占用
- 改进 Linux 托盘支持并添加 `--no-tray` 选项
- Linux 现在在新生成的配置中默认将 TUN 栈恢复为 mixed 模式
- 为代理延迟测试的 URL 设置增加了保护以及添加了安全的备用 URL
- 更新了 Wayland 合成器检测逻辑,从而在 Hyprland 会话中保留原生 Wayland 后端
- 改进 Windows 和 Unix 的 服务连接方式以及权限,避免无法连接服务或内核
- 修改内核默认日志级别为 Info
- 支持通过桌面快捷方式重新打开应用
- 支持订阅界面输入链接后回车导入
- 选择按延迟排序时每次延迟测试自动刷新节点顺序
- 配置重载失败时自动重启核心
- 启用 TUN 前等待服务就绪
- 卸载 TUN 时会先关闭
- 优化应用启动页
- 优化首页当前节点对MATCH规则的支持
- 允许在 `界面设置` 修改 `悬浮跳转导航延迟`
- 添加热键绑定错误的提示信息
- 在 macOS 10.15 及更高版本默认包含 Mihomo-go122,以解决 Intel 架构 Mac 无法运行内核的问题
- Tun 模式不可用时,禁用系统托盘的 Tun 模式菜单
- 改进订阅更新方式,仍失败需打开订阅设置 `允许危险证书`
- 允许设置 Mihomo 端口范围 1000(含) - 65536(含)
</details>
## v2.4.2
### ✨ 新增功能
- 增加托盘节点选择
### 🚀 性能优化
- 优化前端首页加载速度
- 优化前端未使用 i18n 文件缓存
- 优化后端内存占用
- 优化后端启动速度
### 🐞 修复问题
- 修复首页节点切换失效的问题
- 修复和优化服务检查流程
- 修复2.4.1引入的订阅地址重定向报错问题
- 修复 rpm/deb 包名称问题
- 修复托盘轻量模式状态检测异常
- 修复通过 scheme 导入订阅崩溃
- 修复单例检测实效
- 修复启动阶段可能导致的无法连接内核
- 修复导入订阅无法 Auth Basic
### 👙 界面样式
- 简化和改进代理设置样式
## v2.4.1
### 🏆 重大改进
- **应用响应速度提升**:采用全新异步处理架构,大幅提升应用响应速度和稳定性
### ✨ 新增功能
- **Mihomo(Meta) 内核升级至 v1.19.13**
### 🚀 性能优化
- 优化热键响应速度,提升快捷键操作体验
- 改进服务管理响应性,减少系统服务操作等待时间
- 提升文件和配置处理性能
- 优化任务管理和日志记录效率
- 优化异步内存管理,减少内存占用并提升多任务处理效率
- 优化启动阶段初始化性能
### 🐞 修复问题
- 修复应用在某些操作中可能出现的响应延迟问题
- 修复任务管理中的潜在并发问题
- 修复通过托盘重启应用无法恢复
- 修复订阅在某些情况下无法导入
- 修复无法新建订阅时使用远程链接
- 修复卸载服务后的 tun 开关状态问题
- 修复页面快速切换订阅时导致崩溃
- 修复丢失工作目录时无法恢复环境
- 修复从轻量模式恢复导致崩溃
### 👙 界面样式
- 统一代理设置样式
### 🗑️ 移除内容
- 移除启动阶段自动清理过期订阅
## v2.4.0
**发行代号:融**
代号释义: 「融」象征融合与贯通,寓意新版本通过全新 IPC 通信机制 将系统各部分紧密衔接,打破壁垒,实现更高效的 数据流通与全面性能优化。
### 🏆 重大改进
- **核心通信架构升级**:采用全新通信机制,提升应用性能和稳定性
- **流量监控系统重构**:全新的流量监控界面,支持更丰富的数据展示
- **数据缓存优化**:改进配置和节点数据缓存,提升响应速度
### ✨ 新增功能
- **Mihomo(Meta) 内核升级至 v1.19.12**
- 新增版本信息复制按钮
- 增强型流量监控,支持更详细的数据分析
- 新增流量图表多种显示模式
- 新增强制刷新配置和节点缓存功能
- 首页流量统计支持查看刻度线详情
### 🚀 性能优化
- 全面提升数据传输和处理效率
- 优化内存使用,减少系统资源消耗
- 改进流量图表渲染性能
- 优化配置和节点刷新策略,从5秒延长到60秒
- 改进数据缓存机制,减少重复请求
- 优化异步程序性能
### 🐞 修复问题
- 修复系统代理状态检测和显示不一致问题
- 修复系统主题窗口颜色不一致问题
- 修复特殊字符 URL 处理问题
- 修复配置修改后缓存不同步问题
- 修复 Windows 安装器自启设置问题
- 修复 macOS 下 Dock 图标恢复窗口问题
- 修复 linux 下 KDE/Plasma 异常标题栏按钮
- 修复架构升级后节点测速功能异常
- 修复架构升级后流量统计功能异常
- 修复架构升级后日志功能异常
- 修复外部控制器跨域配置保存问题
- 修复首页端口显示不一致问题
- 修复首页流量统计刻度线显示问题
- 修复日志页面按钮功能混淆问题
- 修复日志等级设置保存问题
- 修复日志等级异常过滤
- 修复清理日志天数功能异常
- 修复偶发性启动卡死问题
- 修复首页虚拟网卡开关在管理模式下的状态问题
### 🔧 技术改进
- 统一使用新的内核通信方式
- 新增外部控制器配置界面
- 改进跨平台兼容性支持
## v2.3.2
### 🐞 修复问题
+3
View File
@@ -5,6 +5,7 @@ import configPrettier from "eslint-config-prettier";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import pluginImportX from "eslint-plugin-import-x";
import pluginPrettier from "eslint-plugin-prettier";
import pluginReactCompiler from "eslint-plugin-react-compiler";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginReactRefresh from "eslint-plugin-react-refresh";
import pluginUnusedImports from "eslint-plugin-unused-imports";
@@ -19,6 +20,7 @@ export default defineConfig([
js: eslintJS,
// @ts-expect-error -- https://github.com/typescript-eslint/typescript-eslint/issues/11543
"react-hooks": pluginReactHooks,
"react-compiler": pluginReactCompiler,
// @ts-expect-error -- https://github.com/un-ts/eslint-plugin-import-x/issues/421
"import-x": pluginImportX,
"react-refresh": pluginReactRefresh,
@@ -52,6 +54,7 @@ export default defineConfig([
// React
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"react-compiler/react-compiler": "error",
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
+28 -25
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.4.4",
"version": "2.4.5-rc.1",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
@@ -28,7 +28,8 @@
"lint:fix": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache --fix src",
"format": "prettier --write .",
"format:check": "prettier --check .",
"format:i18n": "node scripts/cleanup-unused-i18n.mjs --align --apply",
"i18n:check": "node scripts/cleanup-unused-i18n.mjs",
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
"i18n:types": "node scripts/generate-i18n-keys.mjs",
"typecheck": "tsc --noEmit"
},
@@ -40,24 +41,24 @@
"@emotion/styled": "^11.14.1",
"@juggle/resize-observer": "^3.4.0",
"@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^7.3.6",
"@mui/icons-material": "^7.3.7",
"@mui/lab": "7.0.0-beta.17",
"@mui/material": "^7.3.6",
"@mui/material": "^7.3.7",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.13",
"@tanstack/react-virtual": "^3.13.18",
"@tauri-apps/api": "2.9.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-http": "~2.5.4",
"@tauri-apps/plugin-dialog": "^2.5.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-http": "~2.5.5",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "2.3.3",
"@tauri-apps/plugin-shell": "2.3.4",
"@tauri-apps/plugin-updater": "2.9.0",
"ahooks": "^3.9.6",
"axios": "^1.13.2",
"dayjs": "1.11.19",
"foxact": "^0.2.49",
"i18next": "^25.7.3",
"foxact": "^0.2.52",
"i18next": "^25.7.4",
"js-yaml": "^4.1.1",
"lodash-es": "^4.17.22",
"monaco-editor": "^0.55.1",
@@ -65,25 +66,26 @@
"nanoid": "^5.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-error-boundary": "6.0.0",
"react-hook-form": "^7.68.0",
"react-i18next": "16.5.0",
"react-error-boundary": "6.0.3",
"react-hook-form": "^7.71.0",
"react-i18next": "16.5.2",
"react-markdown": "10.1.0",
"react-router": "^7.11.0",
"react-virtuoso": "^4.17.0",
"react-router": "^7.12.0",
"react-virtuoso": "^4.18.1",
"rehype-raw": "^7.0.0",
"swr": "^2.3.8",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
"types-pac": "^1.0.3"
},
"devDependencies": {
"@actions/github": "^6.0.1",
"@eslint-react/eslint-plugin": "^2.3.13",
"@actions/github": "^7.0.0",
"@eslint-react/eslint-plugin": "^2.5.5",
"@eslint/js": "^9.39.2",
"@tauri-apps/cli": "2.9.6",
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.4",
"@types/react": "19.2.7",
"@types/node": "^24.10.7",
"@types/react": "19.2.8",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-legacy": "^7.2.1",
"@vitejs/plugin-react-swc": "^4.2.2",
@@ -96,23 +98,24 @@
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-unused-imports": "^4.3.0",
"glob": "^13.0.0",
"globals": "^16.5.0",
"globals": "^17.0.0",
"https-proxy-agent": "^7.0.6",
"husky": "^9.1.7",
"jiti": "^2.6.1",
"lint-staged": "^16.2.7",
"node-fetch": "^3.3.2",
"prettier": "^3.7.4",
"sass": "^1.97.0",
"sass": "^1.97.2",
"tar": "^7.5.2",
"terser": "^5.44.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.0",
"vite": "^7.3.0",
"typescript-eslint": "^8.52.0",
"vite": "^7.3.1",
"vite-plugin-svgr": "^4.5.0"
},
"lint-staged": {
@@ -125,7 +128,7 @@
]
},
"type": "module",
"packageManager": "pnpm@10.26.1",
"packageManager": "pnpm@10.28.0",
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
+826 -1750
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
{
"extends": ["config:recommended", ":disableDependencyDashboard"],
"baseBranches": ["dev"],
"enabledManagers": ["cargo", "npm", "github-actions"],
"labels": ["dependencies"],
"ignorePaths": [
"**/node_modules/**",
"**/bower_components/**",
"**/vendor/**",
"**/__tests__/**",
"**/test/**",
"**/tests/**",
"**/__fixtures__/**",
"shared/**"
],
"rangeStrategy": "replace",
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"automerge": true
},
{
"matchPackageNames": ["*"],
"semanticCommitType": "chore"
},
{
"description": "Disable node/pnpm version updates",
"matchPackageNames": ["node", "pnpm"],
"matchDepTypes": ["engines", "packageManager"],
"enabled": false
},
{
"description": "Group all cargo dependencies into a single PR",
"matchManagers": ["cargo"],
"groupName": "cargo dependencies"
},
{
"description": "Group all npm dependencies into a single PR",
"matchManagers": ["npm"],
"groupName": "npm dependencies"
},
{
"description": "Group all GitHub Actions updates into a single PR",
"matchManagers": ["github-actions"],
"groupName": "github actions"
}
],
"postUpdateOptions": ["pnpmDedupe"],
"ignoreDeps": ["criterion"],
"lockFileMaintenance": {
"enabled": true,
"description": "Force update lockfile to track latest commits of git dependencies",
"schedule": ["before 5am on monday"]
}
}
-50
View File
@@ -1,50 +0,0 @@
{
extends: ["config:recommended", ":disableDependencyDashboard"],
baseBranches: ["dev"],
enabledManagers: ["cargo", "npm", "github-actions"],
labels: ["dependencies"],
ignorePaths: [
"**/node_modules/**",
"**/bower_components/**",
"**/vendor/**",
"**/__tests__/**",
"**/test/**",
"**/tests/**",
"**/__fixtures__/**",
"shared/**",
],
rangeStrategy: "replace",
packageRules: [
{
matchUpdateTypes: ["patch"],
automerge: true,
},
{
semanticCommitType: "chore",
matchPackageNames: ["*"],
},
{
description: "Disable node/pnpm version updates",
matchPackageNames: ["node", "pnpm"],
matchDepTypes: ["engines", "packageManager"],
enabled: false,
},
{
description: "Group all cargo dependencies into a single PR",
matchManagers: ["cargo"],
groupName: "cargo dependencies",
},
{
description: "Group all npm dependencies into a single PR",
matchManagers: ["npm"],
groupName: "npm dependencies",
},
{
description: "Group all GitHub Actions updates into a single PR",
matchManagers: ["github-actions"],
groupName: "github actions",
},
],
postUpdateOptions: ["pnpmDedupe"],
ignoreDeps: ["criterion"],
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
# bump_changelog.sh
# - prepend ./Changelog.md to ./docs/Changelog.history.md
# - overwrite ./Changelog.md with ./template/Changelog.md
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"
CHANGELOG="Changelog.md"
HISTORY="docs/Changelog.history.md"
TEMPLATE="template/Changelog.md"
timestamp() { date +"%Y%m%d%H%M%S"; }
echo "Repo root: $ROOT_DIR"
if [ ! -f "$CHANGELOG" ]; then
echo "Error: $CHANGELOG not found" >&2
exit 2
fi
if [ ! -f "$TEMPLATE" ]; then
echo "Error: $TEMPLATE not found" >&2
exit 3
fi
BACKUP_DIR=".changelog_backups"
mkdir -p "$BACKUP_DIR"
bak_ts=$(timestamp)
cp "$CHANGELOG" "$BACKUP_DIR/Changelog.md.bak.$bak_ts"
echo "Backed up $CHANGELOG -> $BACKUP_DIR/Changelog.md.bak.$bak_ts"
if [ -f "$HISTORY" ]; then
cp "$HISTORY" "$BACKUP_DIR/Changelog.history.md.bak.$bak_ts"
echo "Backed up $HISTORY -> $BACKUP_DIR/Changelog.history.md.bak.$bak_ts"
fi
# Prepend current Changelog.md content to top of docs/Changelog.history.md
tmpfile=$(mktemp)
{
cat "$CHANGELOG"
echo
echo ""
if [ -f "$HISTORY" ]; then
cat "$HISTORY"
fi
} > "$tmpfile"
mv "$tmpfile" "$HISTORY"
echo "Prepended $CHANGELOG -> $HISTORY"
# Overwrite Changelog.md with template
cp "$TEMPLATE" "$CHANGELOG"
echo "Overwrote $CHANGELOG with $TEMPLATE"
echo "Done. Backups saved under $BACKUP_DIR"
exit 0
+248 -61
View File
@@ -4,18 +4,23 @@ import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import yaml from "js-yaml";
import ts from "typescript";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const LOCALES_DIR = path.resolve(__dirname, "../src/locales");
const TAURI_LOCALES_DIR = path.resolve(__dirname, "../src-tauri/locales");
const DEFAULT_SOURCE_DIRS = [
path.resolve(__dirname, "../src"),
const FRONTEND_LOCALES_DIR = path.resolve(__dirname, "../src/locales");
const BACKEND_LOCALES_DIR = path.resolve(
__dirname,
"../crates/clash-verge-i18n/locales",
);
const DEFAULT_FRONTEND_SOURCE_DIRS = [path.resolve(__dirname, "../src")];
const DEFAULT_BACKEND_SOURCE_DIRS = [
path.resolve(__dirname, "../src-tauri"),
path.resolve(__dirname, "../crates"),
];
const EXCLUDE_USAGE_DIRS = [LOCALES_DIR, TAURI_LOCALES_DIR];
const EXCLUDE_USAGE_DIRS = [FRONTEND_LOCALES_DIR, BACKEND_LOCALES_DIR];
const DEFAULT_BASELINE_LANG = "en";
const IGNORE_DIR_NAMES = new Set([
".git",
@@ -36,7 +41,7 @@ const IGNORE_DIR_NAMES = new Set([
"logs",
"__pycache__",
]);
const SUPPORTED_EXTENSIONS = new Set([
const FRONTEND_EXTENSIONS = new Set([
".ts",
".tsx",
".js",
@@ -46,6 +51,7 @@ const SUPPORTED_EXTENSIONS = new Set([
".vue",
".json",
]);
const BACKEND_EXTENSIONS = new Set([".rs"]);
const TS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
@@ -86,20 +92,25 @@ const WHITELIST_KEYS = new Set([
"theme.light",
"theme.dark",
"theme.system",
"Already Using Latest Core Version",
"_version",
]);
const MAX_PREVIEW_ENTRIES = 40;
const dynamicKeyCache = new Map();
const fileUsageCache = new Map();
function resetUsageCaches() {
dynamicKeyCache.clear();
fileUsageCache.clear();
}
function printUsage() {
console.log(`Usage: pnpm node scripts/cleanup-unused-i18n.mjs [options]
Options:
--apply Write locale files with unused keys removed (default: report only)
--align Align locale structure/order using the baseline locale
--baseline <lang> Baseline locale file name (default: ${DEFAULT_BASELINE_LANG})
--baseline <lang> Baseline locale file name for frontend/backend (default: ${DEFAULT_BASELINE_LANG})
--keep-extra Preserve keys that exist only in non-baseline locales when aligning
--no-backup Skip creating \`.bak\` backups when applying changes
--report <path> Write a JSON report to the given path
@@ -148,7 +159,7 @@ function parseArgs(argv) {
if (!next) {
throw new Error("--baseline requires a locale name (e.g. en)");
}
options.baseline = next.replace(/\.json$/, "");
options.baseline = next.replace(/\.(json|ya?ml)$/i, "");
i += 1;
break;
}
@@ -211,14 +222,16 @@ function getAllFiles(start, predicate) {
return files;
}
function collectSourceFiles(sourceDirs) {
function collectSourceFiles(sourceDirs, options = {}) {
const supportedExtensions =
options.supportedExtensions ?? FRONTEND_EXTENSIONS;
const seen = new Set();
const files = [];
for (const dir of sourceDirs) {
const resolved = getAllFiles(dir, (filePath) => {
if (seen.has(filePath)) return false;
if (!SUPPORTED_EXTENSIONS.has(path.extname(filePath))) return false;
if (!supportedExtensions.has(path.extname(filePath))) return false;
if (
EXCLUDE_USAGE_DIRS.some((excluded) =>
filePath.startsWith(`${excluded}${path.sep}`),
@@ -673,6 +686,45 @@ function collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys) {
}
}
function readRustStringLiteral(source, startIndex) {
const slice = source.slice(startIndex);
if (slice.startsWith('"')) {
const match = slice.match(/^"(?:\\.|[^"\\])*"/);
if (!match) return null;
return match[0].slice(1, -1);
}
if (slice.startsWith("r")) {
const match = slice.match(/^r(#+)?"([\s\S]*?)"\1/);
if (!match) return null;
return match[2];
}
return null;
}
function collectUsedKeysFromRustFile(
file,
baselineNamespaces,
usedKeys,
_dynamicPrefixes,
) {
const pattern = /\b(?:[A-Za-z_][\w:]*::)?t!\s*\(/g;
let match;
while ((match = pattern.exec(file.content))) {
let index = match.index + match[0].length;
while (index < file.content.length && /\s/.test(file.content[index])) {
index += 1;
}
const key = readRustStringLiteral(file.content, index);
if (key) {
addKeyIfValid(key, usedKeys, baselineNamespaces, {
forceNamespace: true,
});
}
}
collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys);
}
function collectUsedI18nKeys(sourceFiles, baselineNamespaces) {
const usedKeys = new Set();
const dynamicPrefixes = new Set();
@@ -685,6 +737,13 @@ function collectUsedI18nKeys(sourceFiles, baselineNamespaces) {
usedKeys,
dynamicPrefixes,
);
} else if (file.extension === ".rs") {
collectUsedKeysFromRustFile(
file,
baselineNamespaces,
usedKeys,
dynamicPrefixes,
);
} else {
collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys);
}
@@ -864,12 +923,16 @@ function writeReport(reportPath, data) {
fs.writeFileSync(reportPath, `${payload}\n`, "utf8");
}
function loadLocales() {
if (!fs.existsSync(LOCALES_DIR)) {
throw new Error(`Locales directory not found: ${LOCALES_DIR}`);
function isPlainObject(value) {
return value && typeof value === "object" && !Array.isArray(value);
}
function loadFrontendLocales() {
if (!fs.existsSync(FRONTEND_LOCALES_DIR)) {
throw new Error(`Locales directory not found: ${FRONTEND_LOCALES_DIR}`);
}
const entries = fs.readdirSync(LOCALES_DIR, { withFileTypes: true });
const entries = fs.readdirSync(FRONTEND_LOCALES_DIR, { withFileTypes: true });
const locales = [];
for (const entry of entries) {
@@ -879,12 +942,12 @@ function loadLocales() {
!entry.name.endsWith(".bak") &&
!entry.name.endsWith(".old")
) {
const localePath = path.join(LOCALES_DIR, entry.name);
const localePath = path.join(FRONTEND_LOCALES_DIR, entry.name);
const name = path.basename(entry.name, ".json");
const raw = fs.readFileSync(localePath, "utf8");
locales.push({
name,
dir: LOCALES_DIR,
dir: FRONTEND_LOCALES_DIR,
format: "single-file",
files: [
{
@@ -901,7 +964,7 @@ function loadLocales() {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
const localeDir = path.join(LOCALES_DIR, entry.name);
const localeDir = path.join(FRONTEND_LOCALES_DIR, entry.name);
const namespaceEntries = fs
.readdirSync(localeDir, { withFileTypes: true })
.filter(
@@ -942,6 +1005,51 @@ function loadLocales() {
return locales;
}
function loadBackendLocales() {
if (!fs.existsSync(BACKEND_LOCALES_DIR)) {
return [];
}
const entries = fs.readdirSync(BACKEND_LOCALES_DIR, { withFileTypes: true });
const locales = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name.endsWith(".bak") || entry.name.endsWith(".old")) {
continue;
}
if (!/\.(ya?ml)$/i.test(entry.name)) continue;
const localePath = path.join(BACKEND_LOCALES_DIR, entry.name);
const name = entry.name.replace(/\.(ya?ml)$/i, "");
const raw = fs.readFileSync(localePath, "utf8");
let data = {};
try {
const parsed = yaml.load(raw);
data = isPlainObject(parsed) ? parsed : {};
} catch (error) {
console.warn(`Warning: failed to parse ${localePath}: ${error.message}`);
data = {};
}
locales.push({
name,
dir: BACKEND_LOCALES_DIR,
format: "yaml-file",
files: [
{
namespace: "translation",
path: localePath,
},
],
data,
});
}
locales.sort((a, b) => a.name.localeCompare(b.name));
return locales;
}
function ensureBackup(localePath) {
const backupPath = `${localePath}.bak`;
if (fs.existsSync(backupPath)) {
@@ -1042,6 +1150,15 @@ function writeLocale(locale, data, options) {
let success = false;
try {
if (locale.format === "yaml-file") {
const target = locale.files[0].path;
backupIfNeeded(target, backups, options);
const serialized = yaml.dump(data ?? {}, { lineWidth: -1, noRefs: true });
fs.writeFileSync(target, `${serialized.trimEnd()}\n`, "utf8");
success = true;
return;
}
if (locale.format === "single-file") {
const target = locale.files[0].path;
backupIfNeeded(target, backups, options);
@@ -1097,6 +1214,8 @@ function processLocale(
sourceFiles,
missingFromSource,
options,
groupName,
baselineName,
) {
const data = JSON.parse(JSON.stringify(locale.data));
const flattened = flattenLocale(data);
@@ -1112,7 +1231,7 @@ function processLocale(
}
const sourceMissing =
locale.name === options.baseline
locale.name === baselineName
? missingFromSource.filter((key) => !flattened.has(key))
: [];
@@ -1165,8 +1284,9 @@ function processLocale(
}
return {
group: groupName,
locale: locale.name,
file: locale.format === "single-file" ? locale.files[0].path : locale.dir,
file: locale.format === "multi-file" ? locale.dir : locale.files[0].path,
totalKeys: flattened.size,
expectedKeys: expectedTotal,
unusedKeys: unused,
@@ -1178,34 +1298,42 @@ function processLocale(
};
}
function main() {
const argv = process.argv.slice(2);
let options;
try {
options = parseArgs(argv);
} catch (error) {
console.error(`Error: ${error.message}`);
console.log();
printUsage();
process.exit(1);
}
function summarizeResults(results) {
return results.reduce(
(totals, result) => {
totals.totalUnused += result.unusedKeys.length;
totals.totalMissing += result.missingKeys.length;
totals.totalExtra += result.extraKeys.length;
totals.totalSourceMissing += result.missingSourceKeys.length;
return totals;
},
{
totalUnused: 0,
totalMissing: 0,
totalExtra: 0,
totalSourceMissing: 0,
},
);
}
function processLocaleGroup(group, options) {
const sourceDirs = [
...new Set([...DEFAULT_SOURCE_DIRS, ...options.extraSources]),
...new Set([...group.sourceDirs, ...options.extraSources]),
];
console.log("Scanning source directories:");
console.log(`\n[${group.label}] Scanning source directories:`);
for (const dir of sourceDirs) {
console.log(` - ${dir}`);
}
const sourceFiles = collectSourceFiles(sourceDirs);
const locales = loadLocales();
const sourceFiles = collectSourceFiles(sourceDirs, {
supportedExtensions: group.supportedExtensions,
});
const locales = group.locales;
if (locales.length === 0) {
console.log("No locale files found.");
return;
console.log(`[${group.label}] No locale files found.`);
return null;
}
const baselineLocale = locales.find(
@@ -1215,7 +1343,7 @@ function main() {
if (!baselineLocale) {
const available = locales.map((item) => item.name).join(", ");
throw new Error(
`Baseline locale "${options.baseline}" not found. Available locales: ${available}`,
`[${group.label}] Baseline locale "${options.baseline}" not found. Available locales: ${available}`,
);
}
@@ -1235,8 +1363,11 @@ function main() {
return a.name.localeCompare(b.name);
});
console.log(`\nChecking ${locales.length} locale files...\n`);
console.log(
`\n[${group.label}] Checking ${locales.length} locale files...\n`,
);
resetUsageCaches();
const results = locales.map((locale) =>
processLocale(
locale,
@@ -1246,35 +1377,85 @@ function main() {
sourceFiles,
missingFromSource,
options,
group.label,
baselineLocale.name,
),
);
const totalUnused = results.reduce(
(count, result) => count + result.unusedKeys.length,
0,
);
const totalMissing = results.reduce(
(count, result) => count + result.missingKeys.length,
0,
);
const totalExtra = results.reduce(
(count, result) => count + result.extraKeys.length,
0,
);
const totalSourceMissing = results.reduce(
(count, result) => count + result.missingSourceKeys.length,
0,
);
const totals = summarizeResults(results);
console.log("\nSummary:");
console.log(`\n[${group.label}] Summary:`);
for (const result of results) {
console.log(
` • ${result.locale}: unused=${result.unusedKeys.length}, missing=${result.missingKeys.length}, extra=${result.extraKeys.length}, missingSource=${result.missingSourceKeys.length}, total=${result.totalKeys}, expected=${result.expectedKeys}`,
);
}
console.log(
`\nTotals → unused: ${totalUnused}, missing: ${totalMissing}, extra: ${totalExtra}, missingSource: ${totalSourceMissing}`,
`\n[${group.label}] Totals → unused: ${totals.totalUnused}, missing: ${totals.totalMissing}, extra: ${totals.totalExtra}, missingSource: ${totals.totalSourceMissing}`,
);
return {
group: group.label,
baseline: baselineLocale.name,
sourceDirs,
totals,
results,
};
}
function main() {
const argv = process.argv.slice(2);
let options;
try {
options = parseArgs(argv);
} catch (error) {
console.error(`Error: ${error.message}`);
console.log();
printUsage();
process.exit(1);
}
const localeGroups = [
{
label: "frontend",
locales: loadFrontendLocales(),
sourceDirs: DEFAULT_FRONTEND_SOURCE_DIRS,
supportedExtensions: FRONTEND_EXTENSIONS,
},
{
label: "backend",
locales: loadBackendLocales(),
sourceDirs: DEFAULT_BACKEND_SOURCE_DIRS,
supportedExtensions: BACKEND_EXTENSIONS,
},
].filter((group) => group.locales.length > 0);
if (localeGroups.length === 0) {
console.log("No locale files found.");
return;
}
const groupReports = [];
const allResults = [];
for (const group of localeGroups) {
const report = processLocaleGroup(group, options);
if (!report) continue;
groupReports.push(report);
allResults.push(...report.results);
}
if (groupReports.length > 1) {
const overallTotals = summarizeResults(allResults);
console.log(
`\nOverall totals → unused: ${overallTotals.totalUnused}, missing: ${overallTotals.totalMissing}, extra: ${overallTotals.totalExtra}, missingSource: ${overallTotals.totalSourceMissing}`,
);
}
if (allResults.length === 0) {
return;
}
if (options.apply) {
console.log(
"Files were updated in-place; review diffs before committing changes.",
@@ -1301,11 +1482,17 @@ function main() {
apply: options.apply,
backup: options.backup,
align: options.align,
baseline: baselineLocale.name,
baseline: options.baseline,
keepExtra: options.keepExtra,
sourceDirs,
},
results,
groups: groupReports.map((report) => ({
group: report.group,
baseline: report.baseline,
sourceDirs: report.sourceDirs,
totals: report.totals,
locales: report.results.map((result) => result.locale),
})),
results: allResults,
};
writeReport(options.reportPath, payload);
console.log(`Report written to ${options.reportPath}`);
+4 -3
View File
@@ -39,9 +39,10 @@ function is_valid_ip() {
# 获取网络接口和硬件端口
nic=$(route -n get default | grep "interface" | awk '{print $2}')
hardware_port=$(networksetup -listallhardwareports | awk -v dev="$nic" '
/Hardware Port:/{port=$0; gsub("Hardware Port: ", "", port)}
/Device: /{if ($2 == dev) {print port; exit}}
# 从网络服务列表中获取硬件端口
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
')
# 获取当前DNS设置
+3 -10
View File
@@ -1,16 +1,9 @@
#!/bin/bash
nic=$(route -n get default | grep "interface" | awk '{print $2}')
hardware_port=$(networksetup -listallhardwareports | awk -v dev="$nic" '
/Hardware Port:/{
port=$0; gsub("Hardware Port: ", "", port)
}
/Device: /{
if ($2 == dev) {
print port;
exit
}
}
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
')
if [ -f .original_dns.txt ]; then
+25 -17
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.4.4"
version = "2.4.5-rc.1"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
verge-dev = ["clash_verge_logger/color"]
tauri-dev = ["clash-verge-logging/tauri-dev"]
tauri-dev = []
tokio-trace = ["console-subscriber"]
clippy = ["tauri/test"]
tracing = []
@@ -35,6 +35,7 @@ clash-verge-draft = { workspace = true }
clash-verge-logging = { workspace = true }
clash-verge-signal = { workspace = true }
clash-verge-types = { workspace = true }
clash-verge-i18n = { workspace = true }
tauri-plugin-clash-verge-sysinfo = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true }
tauri = { workspace = true, features = [
@@ -54,6 +55,7 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_yaml_ng = { workspace = true }
smartstring = { workspace = true, features = ["serde"] }
bitflags = { workspace = true }
warp = { version = "0.4.2", features = ["server"] }
open = "5.3.3"
dunce = "1.0.5"
@@ -61,46 +63,50 @@ nanoid = "0.4"
chrono = "0.4.42"
boa_engine = "0.21.0"
once_cell = { version = "1.21.3", features = ["parking_lot"] }
port_scanner = "0.1.5"
delay_timer = "0.11.6"
percent-encoding = "2.3.2"
reqwest = { version = "0.12.24", features = ["json", "cookies", "rustls-tls"] }
reqwest = { version = "0.13.1", features = [
"json",
"cookies",
"rustls",
"form",
] }
regex = "1.12.2"
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", features = [
"guard",
] }
network-interface = { version = "2.0.3", features = ["serde"] }
tauri-plugin-shell = "2.3.3"
tauri-plugin-dialog = "2.4.2"
tauri-plugin-fs = "2.4.4"
network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.3.4"
tauri-plugin-dialog = "2.5.0"
tauri-plugin-fs = "2.4.5"
tauri-plugin-process = "2.3.1"
tauri-plugin-deep-link = "2.4.5"
tauri-plugin-deep-link = "2.4.6"
tauri-plugin-window-state = "2.4.1"
zip = "6.0.0"
zip = "7.0.0"
reqwest_dav = "0.2.2"
aes-gcm = { version = "0.10.3", features = ["std"] }
base64 = "0.22.1"
getrandom = "0.3.4"
futures = "0.3.31"
sys-locale = "0.3.2"
gethostname = "1.1.0"
scopeguard = "1.2.0"
tauri-plugin-notification = "2.3.3"
tokio-stream = "0.1.17"
tokio-stream = "0.1.18"
backoff = { version = "0.4.0", features = ["tokio"] }
tauri-plugin-http = "2.5.4"
tauri-plugin-http = "2.5.5"
console-subscriber = { version = "0.5.0", optional = true }
tauri-plugin-devtools = { version = "2.0.1" }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo" }
clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" }
async-trait = "0.1.89"
clash_verge_service_ipc = { version = "2.0.26", features = [
clash_verge_service_ipc = { version = "2.0.29", features = [
"client",
], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" }
arc-swap = "1.7.1"
rust-i18n = "3.1.5"
arc-swap = "1.8.0"
rust_iso3166 = "0.1.14"
dark-light = "2.0.0"
# Use the git repo until the next release after v2.0.0.
dark-light = { git = "https://github.com/rust-dark-light/dark-light" }
governor = "0.10.4"
[target.'cfg(windows)'.dependencies]
deelevate = { workspace = true }
@@ -114,10 +120,12 @@ winapi = { version = "0.3.9", features = [
"errhandlingapi",
"minwindef",
"winerror",
"stringapiset",
"tlhelp32",
"processthreadsapi",
"winhttp",
"winreg",
"winnls",
] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
+6
View File
@@ -27,6 +27,12 @@ pub async fn restore_local_backup(filename: String) -> CmdResult<()> {
feat::restore_local_backup(filename).await.stringify_err()
}
/// Import local backup into the app's backup directory
#[tauri::command]
pub async fn import_local_backup(source: String) -> CmdResult<String> {
feat::import_local_backup(source).await.stringify_err()
}
/// Export local backup to a user selected destination
#[tauri::command]
pub async fn export_local_backup(filename: String, destination: String) -> CmdResult<()> {
+6
View File
@@ -4,6 +4,7 @@ use clash_verge_logging::{Type, logging};
use gethostname::gethostname;
use network_interface::NetworkInterface;
use serde_yaml_ng::Mapping;
use std::net::TcpListener;
use sysproxy::{Autoproxy, Sysproxy};
use tauri_plugin_clash_verge_sysinfo;
@@ -95,3 +96,8 @@ pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
Ok(result)
}
#[tauri::command]
pub fn is_port_in_use(port: u16) -> bool {
TcpListener::bind(("127.0.0.1", port)).is_err()
}
+4 -5
View File
@@ -13,10 +13,9 @@ use crate::{
feat,
module::auto_backup::{AutoBackupManager, AutoBackupTrigger},
process::AsyncHandler,
ret_err,
utils::{dirs, help},
};
use clash_verge_draft::SharedBox;
use clash_verge_draft::SharedDraft;
use clash_verge_logging::{Type, logging};
use scopeguard::defer;
use smartstring::alias::String;
@@ -26,7 +25,7 @@ use std::time::Duration;
static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false);
#[tauri::command]
pub async fn get_profiles() -> CmdResult<SharedBox<IProfiles>> {
pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
logging!(debug, Type::Cmd, "获取配置文件列表");
let draft = Config::profiles().await;
let data = draft.data_arc();
@@ -118,7 +117,7 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
match profiles_reorder_safe(&active_id, &over_id).await {
Ok(_) => {
logging!(info, Type::Cmd, "重新排序配置文件");
logging!(debug, Type::Cmd, "重新排序配置文件");
Config::profiles().await.apply();
Ok(())
}
@@ -455,7 +454,7 @@ pub async fn view_profile(index: String) -> CmdResult {
let path = dirs::app_profiles_dir().stringify_err()?.join(file.as_str());
if !path.exists() {
ret_err!("the file not found");
return CmdResult::Err(format!("file not found \"{}\"", path.display()).into());
}
help::open_file(path).stringify_err()
+1 -1
View File
@@ -99,7 +99,7 @@ pub async fn update_proxy_chain_config_in_runtime(proxy_chain_config: Option<ser
runtime.edit_draft(|d| d.update_proxy_chain_config(proxy_chain_config));
// 我们需要在 CoreManager 中验证并应用配置,这里不应该直接调用 runtime.apply()
}
logging_error!(Type::Core, CoreManager::global().apply_generate_confihg().await);
logging_error!(Type::Core, CoreManager::global().apply_generate_config().await);
Ok(())
}
+2 -2
View File
@@ -1,10 +1,10 @@
use super::CmdResult;
use crate::{cmd::StringifyErr as _, config::IVerge, feat};
use clash_verge_draft::SharedBox;
use clash_verge_draft::SharedDraft;
/// 获取Verge配置
#[tauri::command]
pub async fn get_verge_config() -> CmdResult<SharedBox<IVerge>> {
pub async fn get_verge_config() -> CmdResult<SharedDraft<IVerge>> {
feat::fetch_verge_config().await.stringify_err()
}
+15 -3
View File
@@ -65,6 +65,9 @@ impl Config {
pub async fn init_config() -> Result<()> {
Self::ensure_default_profile_items().await?;
let verge = Self::verge().await.latest_arc();
clash_verge_i18n::sync_locale(verge.language.as_deref());
// init Tun mode
let handle = Handle::app_handle();
let is_admin = is_current_app_handle_admin(handle);
@@ -89,6 +92,12 @@ impl Config {
handle::Handle::notice_message(msg_type, msg_content);
}
{
let profiles = Self::profiles().await.data_arc();
// Logging error internally
let _ = profiles.cleanup_orphaned_files().await;
}
Ok(())
}
@@ -165,11 +174,14 @@ impl Config {
};
let runtime = Self::runtime().await;
let runtime_arc = runtime.latest_arc();
let config = runtime_arc
let runtime_lastest = runtime.latest_arc();
// Fall back to committed config if runtime config is missing
let runtime_data = runtime.data_arc();
let config = runtime_lastest
.config
.as_ref()
.ok_or_else(|| anyhow!("failed to get runtime config"))?;
.or_else(|| runtime_data.config.as_ref())
.ok_or_else(|| anyhow!("failed to generate runtime config, might need to restart application"))?;
help::save_yaml(&path, config, Some("# Generated by Clash Verge")).await?;
Ok(path)
+18 -21
View File
@@ -60,13 +60,13 @@ pub struct PrfItem {
pub file_data: Option<String>,
}
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
#[derive(Default, Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct PrfSelected {
pub name: Option<String>,
pub now: Option<String>,
}
#[derive(Default, Debug, Clone, Copy, Deserialize, Serialize)]
#[derive(Default, Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
pub struct PrfExtra {
pub upload: u64,
pub download: u64,
@@ -124,25 +124,22 @@ pub struct PrfOption {
impl PrfOption {
pub fn merge(one: Option<&Self>, other: Option<&Self>) -> Option<Self> {
match (one, other) {
(Some(a_ref), Some(b_ref)) => {
let mut result = a_ref.clone();
result.user_agent = b_ref.user_agent.clone().or(result.user_agent);
result.with_proxy = b_ref.with_proxy.or(result.with_proxy);
result.self_proxy = b_ref.self_proxy.or(result.self_proxy);
result.danger_accept_invalid_certs =
b_ref.danger_accept_invalid_certs.or(result.danger_accept_invalid_certs);
result.allow_auto_update = b_ref.allow_auto_update.or(result.allow_auto_update);
result.update_interval = b_ref.update_interval.or(result.update_interval);
result.merge = b_ref.merge.clone().or(result.merge);
result.script = b_ref.script.clone().or(result.script);
result.rules = b_ref.rules.clone().or(result.rules);
result.proxies = b_ref.proxies.clone().or(result.proxies);
result.groups = b_ref.groups.clone().or(result.groups);
result.timeout_seconds = b_ref.timeout_seconds.or(result.timeout_seconds);
Some(result)
}
(Some(a_ref), None) => Some(a_ref.clone()),
(None, Some(b_ref)) => Some(b_ref.clone()),
(Some(a), Some(b)) => Some(Self {
user_agent: b.user_agent.as_ref().or(a.user_agent.as_ref()).cloned(),
with_proxy: b.with_proxy.or(a.with_proxy),
self_proxy: b.self_proxy.or(a.self_proxy),
danger_accept_invalid_certs: b.danger_accept_invalid_certs.or(a.danger_accept_invalid_certs),
allow_auto_update: b.allow_auto_update.or(a.allow_auto_update),
update_interval: b.update_interval.or(a.update_interval),
merge: b.merge.as_ref().or(a.merge.as_ref()).cloned(),
script: b.script.as_ref().or(a.script.as_ref()).cloned(),
rules: b.rules.as_ref().or(a.rules.as_ref()).cloned(),
proxies: b.proxies.as_ref().or(a.proxies.as_ref()).cloned(),
groups: b.groups.as_ref().or(a.groups.as_ref()).cloned(),
timeout_seconds: b.timeout_seconds.or(a.timeout_seconds),
}),
(Some(a), None) => Some(a.clone()),
(None, Some(b)) => Some(b.clone()),
(None, None) => None,
}
}
+227 -258
View File
@@ -5,12 +5,16 @@ use crate::utils::{
};
use anyhow::{Context as _, Result, bail};
use clash_verge_logging::{Type, logging};
use once_cell::sync::OnceCell;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_yaml_ng::Mapping;
use smartstring::alias::String;
use std::{collections::HashSet, sync::Arc};
use std::collections::{HashMap, HashSet};
use tokio::fs;
static PROFILE_FILE_RE: OnceCell<Regex> = OnceCell::new();
/// Define the `profiles.yaml` schema
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct IProfiles {
@@ -37,23 +41,15 @@ pub struct CleanupResult {
macro_rules! patch {
($lv: expr, $rv: expr, $key: tt) => {
if ($rv.$key).is_some() {
$lv.$key = $rv.$key.to_owned();
if let Some(ref val) = $rv.$key {
if Some(val) != $lv.$key.as_ref() {
$lv.$key = Some(val.to_owned());
}
}
};
}
impl IProfiles {
// Helper to find and remove an item by uid from the items vec, returning its file name (if any).
fn take_item_file_by_uid(items: &mut Vec<PrfItem>, target_uid: Option<String>) -> Option<String> {
for (i, _) in items.iter().enumerate() {
if items[i].uid == target_uid {
return items.remove(i).file;
}
}
None
}
pub async fn new() -> Self {
let path = match dirs::profiles_path() {
Ok(p) => p,
@@ -63,21 +59,22 @@ impl IProfiles {
}
};
match help::read_yaml::<Self>(&path).await {
Ok(mut profiles) => {
let items = profiles.items.get_or_insert_with(Vec::new);
for item in items.iter_mut() {
if item.uid.is_none() {
item.uid = Some(help::get_uid("d").into());
}
}
profiles
}
let mut profiles = match help::read_yaml::<Self>(&path).await {
Ok(profiles) => profiles,
Err(err) => {
logging!(error, Type::Config, "{err}");
Self::default()
return Self::default();
}
};
let items = profiles.items.get_or_insert_with(Vec::new);
for item in items.iter_mut() {
if item.uid.is_none() {
item.uid = Some(help::get_uid("d").into());
}
}
profiles
}
pub async fn save_file(&self) -> Result<()> {
@@ -113,47 +110,28 @@ impl IProfiles {
pub fn get_item(&self, uid: impl AsRef<str>) -> Result<&PrfItem> {
let uid_str = uid.as_ref();
if let Some(items) = self.items.as_ref() {
for each in items.iter() {
if let Some(uid_val) = &each.uid
&& uid_val.as_str() == uid_str
{
return Ok(each);
}
}
}
bail!("failed to get the profile item \"uid:{}\"", uid_str);
}
pub fn get_item_arc(&self, uid: &str) -> Option<Arc<PrfItem>> {
self.items.as_ref().and_then(|items| {
items
.iter()
.find(|it| it.uid.as_deref() == Some(uid))
.map(|it| Arc::new(it.clone()))
})
self.items
.as_ref()
.ok_or_else(|| anyhow::anyhow!("no profile items found"))?
.iter()
.find(|each| each.uid.as_ref().is_some_and(|uid_val| uid_val.as_str() == uid_str))
.ok_or_else(|| anyhow::anyhow!("failed to get the profile item \"uid:{}\"", uid_str))
}
/// append new item
/// if the file_data is some
/// then should save the data to file
pub async fn append_item(&mut self, item: &mut PrfItem) -> Result<()> {
let uid = &item.uid;
if uid.is_none() {
bail!("the uid should not be null");
}
anyhow::ensure!(item.uid.is_some(), "the uid should not be null");
// save the file data
// move the field value after save
if let Some(file_data) = item.file_data.take() {
if item.file.is_none() {
bail!("the file should not be null");
}
anyhow::ensure!(item.file.is_some(), "the file should not be null");
let file = item
.file
.clone()
.as_ref()
.ok_or_else(|| anyhow::anyhow!("file field is required when file_data is provided"))?;
let path = dirs::app_profiles_dir()?.join(file.as_str());
@@ -162,111 +140,116 @@ impl IProfiles {
.with_context(|| format!("failed to write to file \"{file}\""))?;
}
if self.current.is_none() && (item.itype == Some("remote".into()) || item.itype == Some("local".into())) {
self.current = uid.to_owned();
if self.current.is_none()
&& let Some(t) = item.itype.as_deref()
&& (t == "remote" || t == "local")
{
self.current = item.uid.to_owned();
}
if self.items.is_none() {
self.items = Some(vec![]);
}
if let Some(items) = self.items.as_mut() {
items.push(item.to_owned());
}
self.items.get_or_insert_default().push(std::mem::take(item));
Ok(())
}
/// reorder items
pub async fn reorder(&mut self, active_id: &String, over_id: &String) -> Result<()> {
let mut items = self.items.take().unwrap_or_default();
let mut old_index = None;
let mut new_index = None;
pub async fn reorder(&mut self, active_id: &str, over_id: &str) -> Result<()> {
if active_id == over_id {
return Ok(());
}
for (i, _) in items.iter().enumerate() {
if items[i].uid.as_ref() == Some(active_id) {
old_index = Some(i);
let Some(items) = self.items.as_mut() else {
return Ok(());
};
let mut old_idx = None;
let mut new_idx = None;
for (i, item) in items.iter().enumerate() {
if let Some(uid) = item.uid.as_ref() {
if uid == active_id {
old_idx = Some(i);
}
if uid == over_id {
new_idx = Some(i);
}
}
if items[i].uid.as_ref() == Some(over_id) {
new_index = Some(i);
if old_idx.is_some() && new_idx.is_some() {
break;
}
}
let (old_idx, new_idx) = match (old_index, new_index) {
(Some(old), Some(new)) => (old, new),
_ => return Ok(()),
};
let item = items.remove(old_idx);
items.insert(new_idx, item);
self.items = Some(items);
self.save_file().await
if let (Some(old), Some(new)) = (old_idx, new_idx) {
if old < new {
items[old..=new].rotate_left(1);
} else {
items[new..=old].rotate_right(1);
}
return self.save_file().await;
}
Ok(())
}
/// update the item value
pub async fn patch_item(&mut self, uid: &String, item: &PrfItem) -> Result<()> {
let mut items = self.items.take().unwrap_or_default();
let items = self
.items
.as_mut()
.ok_or_else(|| anyhow::anyhow!("no profile items found"))?;
for each in items.iter_mut() {
if each.uid.as_ref() == Some(uid) {
patch!(each, item, itype);
patch!(each, item, name);
patch!(each, item, desc);
patch!(each, item, file);
patch!(each, item, url);
patch!(each, item, selected);
patch!(each, item, extra);
patch!(each, item, updated);
patch!(each, item, option);
let target = items.iter_mut().find(|each| each.uid.as_ref() == Some(uid));
self.items = Some(items);
return self.save_file().await;
}
if let Some(each) = target {
patch!(each, item, itype);
patch!(each, item, name);
patch!(each, item, desc);
patch!(each, item, file);
patch!(each, item, url);
patch!(each, item, selected);
patch!(each, item, extra);
patch!(each, item, updated);
patch!(each, item, option);
return self.save_file().await;
}
self.items = Some(items);
bail!("failed to find the profile item \"uid:{uid}\"")
}
/// be used to update the remote item
/// only patch `updated` `extra` `file_data`
pub async fn update_item(&mut self, uid: &String, item: &mut PrfItem) -> Result<()> {
if self.items.is_none() {
self.items = Some(vec![]);
}
let target = self
.items
.get_or_insert_default()
.iter_mut()
.find(|each| each.uid.as_ref() == Some(uid))
.ok_or_else(|| anyhow::anyhow!("Item not found"))?;
// find the item
let _ = self.get_item(uid)?;
target.extra = item.extra;
target.updated = item.updated;
target.home = std::mem::take(&mut item.home);
target.option = PrfOption::merge(target.option.as_ref(), item.option.as_ref());
if let Some(items) = self.items.as_mut() {
let some_uid = Some(uid.clone());
let Some(file_data) = item.file_data.take() else {
return self.save_file().await;
};
for each in items.iter_mut() {
if each.uid == some_uid {
each.extra = item.extra;
each.updated = item.updated;
each.home = item.home.to_owned();
each.option = PrfOption::merge(each.option.as_ref(), item.option.as_ref());
// save the file data
// move the field value after save
if let Some(file_data) = item.file_data.take() {
let file = each.file.take();
let file =
file.unwrap_or_else(|| item.file.take().unwrap_or_else(|| format!("{}.yaml", &uid).into()));
let file = target
.file
.take()
.or_else(|| item.file.take())
.unwrap_or_else(|| format!("{}.yaml", uid).into());
// the file must exists
each.file = Some(file.clone());
let path = dirs::app_profiles_dir()?.join(file.as_str());
let path = dirs::app_profiles_dir()?.join(file.as_str());
fs::write(&path, file_data.as_bytes())
.await
.with_context(|| format!("failed to write to file \"{file}\""))?;
fs::write(&path, file_data.as_bytes())
.await
.with_context(|| format!("failed to write to file \"{file}\""))?;
}
break;
}
}
}
target.file = Some(file);
self.save_file().await
}
@@ -274,68 +257,82 @@ impl IProfiles {
/// delete item
/// if delete the current then return true
pub async fn delete_item(&mut self, uid: &String) -> Result<bool> {
let current = self.current.as_ref().unwrap_or(uid);
let current = current.clone();
let item = self.get_item(uid)?;
let merge_uid = item.option.as_ref().and_then(|e| e.merge.clone());
let script_uid = item.option.as_ref().and_then(|e| e.script.clone());
let rules_uid = item.option.as_ref().and_then(|e| e.rules.clone());
let proxies_uid = item.option.as_ref().and_then(|e| e.proxies.clone());
let groups_uid = item.option.as_ref().and_then(|e| e.groups.clone());
let mut items = self.items.take().unwrap_or_default();
let uids_to_remove: HashSet<String> = {
let item = self.get_item(uid)?;
let mut set = HashSet::new();
set.insert(uid.clone());
// remove the main item (if exists) and delete its file
if let Some(file) = Self::take_item_file_by_uid(&mut items, Some(uid.clone())) {
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await;
}
// remove related extension items (merge, script, rules, proxies, groups)
if let Some(file) = Self::take_item_file_by_uid(&mut items, merge_uid.clone()) {
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await;
}
if let Some(file) = Self::take_item_file_by_uid(&mut items, script_uid.clone()) {
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await;
}
if let Some(file) = Self::take_item_file_by_uid(&mut items, rules_uid.clone()) {
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await;
}
if let Some(file) = Self::take_item_file_by_uid(&mut items, proxies_uid.clone()) {
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await;
}
if let Some(file) = Self::take_item_file_by_uid(&mut items, groups_uid.clone()) {
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await;
}
// delete the original uid
if current == *uid {
self.current = None;
for item in items.iter() {
if item.itype == Some("remote".into()) || item.itype == Some("local".into()) {
self.current = item.uid.clone();
break;
if let Some(opt) = &item.option {
if let Some(u) = &opt.merge {
set.insert(u.clone());
}
if let Some(u) = &opt.script {
set.insert(u.clone());
}
if let Some(u) = &opt.rules {
set.insert(u.clone());
}
if let Some(u) = &opt.proxies {
set.insert(u.clone());
}
if let Some(u) = &opt.groups {
set.insert(u.clone());
}
}
set
};
let mut items = self.items.take().unwrap_or_default();
let mut deleted_files = Vec::new();
items.retain_mut(|item| {
if let Some(item_uid) = item.uid.as_ref()
&& uids_to_remove.contains(item_uid)
{
if let Some(file) = item.file.take() {
deleted_files.push(file);
}
return false;
}
true
});
let is_deleting_current = self.current.as_ref() == Some(uid);
if is_deleting_current {
self.current = items
.iter()
.find(|i| i.itype.as_deref() == Some("remote") || i.itype.as_deref() == Some("local"))
.and_then(|i| i.uid.clone());
}
self.items = Some(items);
if let Ok(profile_dir) = dirs::app_profiles_dir() {
for file in deleted_files {
let _ = profile_dir.join(file.as_str()).remove_if_exists().await;
}
}
self.save_file().await?;
Ok(current == *uid)
Ok(is_deleting_current)
}
/// 获取current指向的订阅内容
pub async fn current_mapping(&self) -> Result<Mapping> {
match (self.current.as_ref(), self.items.as_ref()) {
(Some(current), Some(items)) => {
if let Some(item) = items.iter().find(|e| e.uid.as_ref() == Some(current)) {
let file_path = match item.file.as_ref() {
Some(file) => dirs::app_profiles_dir()?.join(file.as_str()),
None => bail!("failed to get the file field"),
};
return help::read_mapping(&file_path).await;
}
bail!("failed to find the current profile \"uid:{current}\"");
}
_ => Ok(Mapping::new()),
}
let (Some(current), Some(items)) = (self.current.as_ref(), self.items.as_ref()) else {
return Ok(Mapping::new());
};
let Some(target) = items.iter().find(|e| e.uid.as_ref() == Some(current)) else {
bail!("failed to find the current profile \"uid:{current}\"");
};
let file = target
.file
.as_ref()
.ok_or_else(|| anyhow::anyhow!("failed to get the file field"))?;
let file_path = dirs::app_profiles_dir()?.join(file.as_str());
help::read_mapping(&file_path).await
}
/// 判断profile是否是current指向的
@@ -345,32 +342,32 @@ impl IProfiles {
/// 获取所有的profiles(uid,名称, 是否为 current)
pub fn profiles_preview(&self) -> Option<Vec<IProfilePreview<'_>>> {
self.items.as_ref().map(|items| {
items
.iter()
.filter_map(|e| {
if let (Some(uid), Some(name)) = (e.uid.as_ref(), e.name.as_ref()) {
let is_current = self.is_current_profile_index(uid);
let preview = IProfilePreview { uid, name, is_current };
Some(preview)
} else {
None
}
let items = self.items.as_ref()?;
let current_uid = self.current.as_ref();
let previews = items
.iter()
.filter_map(|e| {
let uid = e.uid.as_ref()?;
let name = e.name.as_ref()?;
Some(IProfilePreview {
uid,
name,
is_current: current_uid == Some(uid),
})
.collect()
})
})
.collect();
Some(previews)
}
/// 通过 uid 获取名称
pub fn get_name_by_uid(&self, uid: &String) -> Option<&String> {
if let Some(items) = &self.items {
for item in items {
if item.uid.as_ref() == Some(uid) {
return item.name.as_ref();
}
}
}
None
pub fn get_name_by_uid(&self, uid: &str) -> Option<&String> {
self.items
.as_ref()?
.iter()
.find(|item| item.uid.as_deref() == Some(uid))
.and_then(|item| item.name.as_ref())
}
/// 以 app 中的 profile 列表为准,删除不再需要的文件
@@ -420,7 +417,7 @@ impl IProfiles {
match path.to_path_buf().remove_if_exists().await {
Ok(_) => {
deleted_files.push(file_name.into());
logging!(info, Type::Config, "已清理冗余文件: {file_name}");
logging!(debug, Type::Config, "已清理冗余文件: {file_name}");
}
Err(e) => {
failed_deletions.push(format!("{file_name}: {e}").into());
@@ -461,59 +458,39 @@ impl IProfiles {
/// 获取所有 active profile 关联的文件名
fn get_all_active_files(&self) -> HashSet<&str> {
let mut active_files: HashSet<&str> = HashSet::new();
let mut active_files = HashSet::new();
let items = match &self.items {
Some(i) => i,
None => return active_files,
};
if let Some(items) = &self.items {
for item in items {
// 收集所有类型 profile 的文件
if let Some(file) = &item.file {
active_files.insert(file);
}
let item_map: HashMap<Option<&str>, &PrfItem> = items.iter().map(|i| (i.uid.as_deref(), i)).collect();
// 对于主 profile 类型(remote/local),还需要收集其关联的扩展文件
if let Some(itype) = &item.itype
&& (itype == "remote" || itype == "local")
&& let Some(option) = &item.option
for item in items {
if let Some(f) = &item.file {
active_files.insert(f.as_str());
}
let Some(opt) = &item.option else {
continue;
};
let related = [
opt.merge.as_deref(),
opt.script.as_deref(),
opt.rules.as_deref(),
opt.proxies.as_deref(),
opt.groups.as_deref(),
];
for r_uid in related.into_iter().flatten() {
if let Some(r_item) = item_map.get(&Some(r_uid))
&& let Some(f) = &r_item.file
{
// 收集关联的扩展文件
if let Some(merge_uid) = &option.merge
&& let Ok(merge_item) = self.get_item(merge_uid)
&& let Some(file) = &merge_item.file
{
active_files.insert(file);
}
if let Some(script_uid) = &option.script
&& let Ok(script_item) = self.get_item(script_uid)
&& let Some(file) = &script_item.file
{
active_files.insert(file);
}
if let Some(rules_uid) = &option.rules
&& let Ok(rules_item) = self.get_item(rules_uid)
&& let Some(file) = &rules_item.file
{
active_files.insert(file);
}
if let Some(proxies_uid) = &option.proxies
&& let Ok(proxies_item) = self.get_item(proxies_uid)
&& let Some(file) = &proxies_item.file
{
active_files.insert(file);
}
if let Some(groups_uid) = &option.groups
&& let Ok(groups_item) = self.get_item(groups_uid)
&& let Some(file) = &groups_item.file
{
active_files.insert(file);
}
active_files.insert(f.as_str());
}
}
}
active_files
}
@@ -528,18 +505,10 @@ impl IProfiles {
// p12345678.yaml (proxies)
// g12345678.yaml (groups)
let patterns = [
r"^[RL][a-zA-Z0-9]+\.yaml$", // Remote/Local profiles
r"^m[a-zA-Z0-9]+\.yaml$", // Merge files
r"^s[a-zA-Z0-9]+\.js$", // Script files
r"^[rpg][a-zA-Z0-9]+\.yaml$", // Rules/Proxies/Groups files
];
patterns.iter().any(|pattern| {
regex::Regex::new(pattern)
.map(|re| re.is_match(filename))
.unwrap_or(false)
})
#[allow(clippy::unwrap_used)]
let re = PROFILE_FILE_RE
.get_or_init(|| Regex::new(r"^(?:[RLmprg][a-zA-Z0-9_-]+\.yaml|s[a-zA-Z0-9_-]+\.js)$").unwrap());
re.is_match(filename)
}
}
+26 -18
View File
@@ -1,7 +1,7 @@
use crate::config::Config;
use crate::{
config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted},
utils::{dirs, help, i18n},
utils::{dirs, help},
};
use anyhow::Result;
use clash_verge_logging::{Type, logging};
@@ -66,6 +66,13 @@ pub struct IVerge {
#[serde(skip_serializing_if = "Option::is_none")]
pub menu_order: Option<Vec<String>>,
/// toast / notice position on screen
#[serde(skip_serializing_if = "Option::is_none")]
pub notice_position: Option<String>,
/// collapse navigation bar
pub collapse_navbar: Option<bool>,
/// sysproxy tray icon
pub sysproxy_tray_icon: Option<bool>,
@@ -87,6 +94,9 @@ pub struct IVerge {
/// enable proxy guard
pub enable_proxy_guard: Option<bool>,
/// enable bypass format check
pub enable_bypass_check: Option<bool>,
/// enable dns settings - this controls whether dns_config.yaml is applied
pub enable_dns_settings: Option<bool>,
@@ -222,7 +232,10 @@ pub struct IVerge {
// pub enable_tray_icon: Option<bool>,
/// show proxy groups directly on tray root menu
pub tray_inline_proxy_groups: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tray_proxy_groups_display_mode: Option<String>,
/// show outbound modes directly on tray root menu
pub tray_inline_outbound_modes: Option<bool>,
/// 自动进入轻量模式
pub enable_auto_light_weight_mode: Option<bool>,
@@ -336,19 +349,6 @@ impl IVerge {
self.clash_core.clone().unwrap_or_else(|| "verge-mihomo".into())
}
fn get_system_language() -> String {
let sys_lang = sys_locale::get_locale().unwrap_or_else(|| "en".into()).to_lowercase();
let lang_code = sys_lang.split(['_', '-']).next().unwrap_or("en");
let supported_languages = i18n::get_supported_languages();
if supported_languages.contains(&lang_code.into()) {
lang_code.into()
} else {
String::from("en")
}
}
pub async fn new() -> Self {
match dirs::verge_path() {
Ok(path) => match help::read_yaml::<Self>(&path).await {
@@ -378,7 +378,7 @@ impl IVerge {
app_log_max_size: Some(128),
app_log_max_count: Some(8),
clash_core: Some("verge-mihomo".into()),
language: Some(Self::get_system_language()),
language: Some(clash_verge_i18n::system_language().into()),
theme_mode: Some("system".into()),
#[cfg(not(target_os = "windows"))]
env_type: Some("bash".into()),
@@ -391,6 +391,8 @@ impl IVerge {
#[cfg(target_os = "macos")]
tray_icon: Some("monochrome".into()),
menu_icon: Some("monochrome".into()),
notice_position: Some("top-right".into()),
collapse_navbar: Some(false),
common_tray_icon: Some(false),
sysproxy_tray_icon: Some(false),
tun_tray_icon: Some(false),
@@ -416,6 +418,7 @@ impl IVerge {
verge_port: Some(7899),
verge_http_enabled: Some(false),
enable_proxy_guard: Some(false),
enable_bypass_check: Some(true),
use_default_bypass: Some(true),
proxy_guard_duration: Some(30),
auto_close_connection: Some(true),
@@ -430,7 +433,8 @@ impl IVerge {
webdav_password: None,
enable_tray_speed: Some(false),
// enable_tray_icon: Some(true),
tray_inline_proxy_groups: Some(true),
tray_proxy_groups_display_mode: Some("default".into()),
tray_inline_outbound_modes: Some(false),
enable_global_hotkey: Some(true),
enable_auto_light_weight_mode: Some(false),
auto_light_weight_minutes: Some(10),
@@ -475,6 +479,8 @@ impl IVerge {
patch!(tray_icon);
patch!(menu_icon);
patch!(menu_order);
patch!(notice_position);
patch!(collapse_navbar);
patch!(common_tray_icon);
patch!(sysproxy_tray_icon);
patch!(tun_tray_icon);
@@ -499,6 +505,7 @@ impl IVerge {
patch!(verge_http_enabled);
patch!(enable_system_proxy);
patch!(enable_proxy_guard);
patch!(enable_bypass_check);
patch!(use_default_bypass);
patch!(system_proxy_bypass);
patch!(proxy_guard_duration);
@@ -529,7 +536,8 @@ impl IVerge {
patch!(webdav_password);
patch!(enable_tray_speed);
// patch!(enable_tray_icon);
patch!(tray_inline_proxy_groups);
patch!(tray_proxy_groups_display_mode);
patch!(tray_inline_outbound_modes);
patch!(enable_auto_light_weight_mode);
patch!(auto_light_weight_minutes);
patch!(enable_dns_settings);
-5
View File
@@ -25,7 +25,6 @@ pub mod timing {
pub const CONFIG_UPDATE_DEBOUNCE: Duration = Duration::from_millis(300);
pub const EVENT_EMIT_DELAY: Duration = Duration::from_millis(20);
pub const STARTUP_ERROR_DELAY: Duration = Duration::from_secs(2);
pub const ERROR_BATCH_DELAY: Duration = Duration::from_millis(300);
#[cfg(target_os = "windows")]
pub const SERVICE_WAIT_MAX: Duration = Duration::from_millis(3000);
@@ -33,10 +32,6 @@ pub mod timing {
pub const SERVICE_WAIT_INTERVAL: Duration = Duration::from_millis(200);
}
pub mod retry {
pub const EVENT_EMIT_THRESHOLD: u64 = 10;
}
pub mod files {
pub const RUNTIME_CONFIG: &str = "clash-verge.yaml";
pub const CHECK_CONFIG: &str = "clash-verge-check.yaml";
+12 -10
View File
@@ -14,6 +14,7 @@ use std::{
sync::Arc,
time::Duration,
};
use tauri_plugin_http::reqwest as tauri_reqwest;
use tokio::{fs, time::timeout};
use zip::write::SimpleFileOptions;
@@ -52,16 +53,16 @@ impl Operation {
}
pub struct WebDavClient {
config: Arc<ArcSwapOption<WebDavConfig>>,
clients: Arc<ArcSwap<HashMap<Operation, reqwest_dav::Client>>>,
config: ArcSwapOption<WebDavConfig>,
clients: ArcSwap<HashMap<Operation, reqwest_dav::Client>>,
}
impl WebDavClient {
pub fn global() -> &'static Self {
static WEBDAV_CLIENT: OnceCell<WebDavClient> = OnceCell::new();
WEBDAV_CLIENT.get_or_init(|| Self {
config: Arc::new(ArcSwapOption::new(None)),
clients: Arc::new(ArcSwap::new(Arc::new(HashMap::new()))),
config: ArcSwapOption::new(None),
clients: ArcSwap::new(Arc::new(HashMap::new())),
})
}
@@ -110,12 +111,12 @@ impl WebDavClient {
// 创建新的客户端
let client = reqwest_dav::ClientBuilder::new()
.set_agent(
reqwest::Client::builder()
tauri_reqwest::Client::builder()
.use_rustls_tls()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(op.timeout()))
.user_agent(format!("clash-verge/{APP_VERSION} ({OS} WebDAV-Client)"))
.redirect(reqwest::redirect::Policy::custom(|attempt| {
.redirect(tauri_reqwest::redirect::Policy::custom(|attempt| {
// 允许所有请求类型的重定向,包括PUT
if attempt.previous().len() >= 5 {
attempt.error("重定向次数过多")
@@ -146,11 +147,12 @@ impl WebDavClient {
}
}
// 缓存客户端(替换 Arc<Mutex<HashMap<...>>> 的写法)
{
let mut map = (**self.clients.load()).clone();
map.insert(op, client.clone());
self.clients.store(map.into());
self.clients.rcu(|clients_map| {
let mut new_map = (**clients_map).clone();
new_map.insert(op, client.clone());
Arc::new(new_map)
});
}
Ok(client)
+14 -66
View File
@@ -1,24 +1,19 @@
use crate::{APP_HANDLE, constants::timing, singleton};
use crate::{APP_HANDLE, singleton, utils::window_manager::WindowManager};
use parking_lot::RwLock;
use smartstring::alias::String;
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use tauri::{AppHandle, Manager as _, WebviewWindow};
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
use tokio::sync::RwLockReadGuard;
use super::notification::{ErrorMessage, FrontendEvent, NotificationSystem};
use super::notification::{FrontendEvent, NotificationSystem};
#[derive(Debug)]
pub struct Handle {
is_exiting: AtomicBool,
startup_errors: Arc<RwLock<Vec<ErrorMessage>>>,
startup_completed: AtomicBool,
pub(crate) notification_system: Arc<RwLock<Option<NotificationSystem>>>,
}
@@ -26,8 +21,6 @@ impl Default for Handle {
fn default() -> Self {
Self {
is_exiting: AtomicBool::new(false),
startup_errors: Arc::new(RwLock::new(Vec::new())),
startup_completed: AtomicBool::new(false),
notification_system: Arc::new(RwLock::new(Some(NotificationSystem::new()))),
}
}
@@ -47,7 +40,7 @@ impl Handle {
let mut system_opt = self.notification_system.write();
if let Some(system) = system_opt.as_mut()
&& !system.is_running
&& !system.is_running()
{
system.start();
}
@@ -111,21 +104,19 @@ impl Handle {
// TODO 利用 &str 等缩短 Clone
pub fn notice_message<S: Into<String>, M: Into<String>>(status: S, msg: M) {
let handle = Self::global();
let status_str = status.into();
let msg_str = msg.into();
if !handle.startup_completed.load(Ordering::Acquire) {
handle.startup_errors.write().push(ErrorMessage {
status: status_str,
message: msg_str,
});
return;
}
if handle.is_exiting() {
return;
}
// We only send notice when main window exists
if WindowManager::get_main_window().is_none() {
return;
}
let status_str = status.into();
let msg_str = msg.into();
Self::send_event(FrontendEvent::NoticeMessage {
status: status_str,
message: msg_str,
@@ -144,49 +135,6 @@ impl Handle {
}
}
pub fn mark_startup_completed(&self) {
self.startup_completed.store(true, Ordering::Release);
self.send_startup_errors();
}
fn send_startup_errors(&self) {
let errors = {
let mut errors = self.startup_errors.write();
std::mem::take(&mut *errors)
};
if errors.is_empty() {
return;
}
let _ = thread::Builder::new()
.name("startup-errors-sender".into())
.spawn(move || {
thread::sleep(timing::STARTUP_ERROR_DELAY);
let handle = Self::global();
if handle.is_exiting() {
return;
}
let system_opt = handle.notification_system.read();
if let Some(system) = system_opt.as_ref() {
for error in errors {
if handle.is_exiting() {
break;
}
system.send_event(FrontendEvent::NoticeMessage {
status: error.status,
message: error.message,
});
thread::sleep(timing::ERROR_BATCH_DELAY);
}
}
});
}
pub fn set_is_exiting(&self) {
self.is_exiting.store(true, Ordering::Release);
+221 -1
View File
@@ -1 +1,221 @@
// TODO: global logger to record verge log message
use std::{
str::FromStr as _,
sync::{
Arc,
atomic::{AtomicU64, AtomicUsize, Ordering},
},
};
use anyhow::{Result, bail};
use clash_verge_logging::{Type, logging};
use clash_verge_service_ipc::WriterConfig;
use compact_str::CompactString;
use flexi_logger::{
Cleanup, Criterion, DeferredNow, FileSpec, LogSpecBuilder, LogSpecification, LoggerHandle,
writers::{FileLogWriter, FileLogWriterBuilder, LogWriter as _},
};
use log::{Level, LevelFilter, Record};
use parking_lot::{Mutex, RwLock};
use crate::{
core::service,
singleton,
utils::dirs::{self, service_log_dir, sidecar_log_dir},
};
pub struct Logger {
handle: Arc<Mutex<Option<LoggerHandle>>>,
sidecar_file_writer: Arc<RwLock<Option<FileLogWriter>>>,
log_level: Arc<RwLock<LevelFilter>>,
log_max_size: AtomicU64,
log_max_count: AtomicUsize,
}
impl Default for Logger {
fn default() -> Self {
Self {
handle: Arc::new(Mutex::new(None)),
sidecar_file_writer: Arc::new(RwLock::new(None)),
log_level: Arc::new(RwLock::new(LevelFilter::Info)),
log_max_size: AtomicU64::new(128),
log_max_count: AtomicUsize::new(8),
}
}
}
singleton!(Logger, LOGGER);
impl Logger {
fn new() -> Self {
Self::default()
}
pub async fn init(&self) -> Result<()> {
let (log_level, log_max_size, log_max_count) = {
let verge_guard = crate::config::Config::verge().await;
let verge = verge_guard.latest_arc();
(
verge.get_log_level(),
verge.app_log_max_size.unwrap_or(128),
verge.app_log_max_count.unwrap_or(8),
)
};
let log_level = std::env::var("RUST_LOG")
.ok()
.and_then(|v| log::LevelFilter::from_str(&v).ok())
.unwrap_or(log_level);
*self.log_level.write() = log_level;
self.log_max_size.store(log_max_size, Ordering::SeqCst);
self.log_max_count.store(log_max_count, Ordering::SeqCst);
#[cfg(not(feature = "tauri-dev"))]
{
let log_spec = Self::generate_log_spec(log_level);
let log_dir = dirs::app_logs_dir()?;
let logger = flexi_logger::Logger::with(log_spec)
.log_to_file(FileSpec::default().directory(log_dir).basename(""))
.duplicate_to_stdout(log_level.into())
.format(clash_verge_logger::console_format)
.format_for_files(clash_verge_logger::file_format_with_level)
.rotate(
Criterion::Size(log_max_size * 1024),
flexi_logger::Naming::TimestampsCustomFormat {
current_infix: Some("latest"),
format: "%Y-%m-%d_%H-%M-%S",
},
Cleanup::KeepLogFiles(log_max_count),
);
let mut filter_modules = vec!["wry", "tokio_tungstenite", "tungstenite"];
#[cfg(not(feature = "tracing"))]
filter_modules.push("tauri");
#[cfg(feature = "tracing")]
filter_modules.extend(["tauri_plugin_mihomo", "kode_bridge"]);
let logger = logger.filter(Box::new(clash_verge_logging::NoModuleFilter(filter_modules)));
let handle = logger.start()?;
*self.handle.lock() = Some(handle);
}
let sidecar_file_writer = self.generate_sidecar_writer()?;
*self.sidecar_file_writer.write() = Some(sidecar_file_writer);
Ok(())
}
fn generate_log_spec(log_level: LevelFilter) -> LogSpecification {
let mut spec = LogSpecBuilder::new();
let log_level = std::env::var("RUST_LOG")
.ok()
.and_then(|v| log::LevelFilter::from_str(&v).ok())
.unwrap_or(log_level);
spec.default(log_level);
#[cfg(feature = "tracing")]
spec.module("tauri", log::LevelFilter::Debug)
.module("wry", log::LevelFilter::Off)
.module("tauri_plugin_mihomo", log::LevelFilter::Off);
spec.build()
}
fn generate_file_log_writer(&self) -> Result<FileLogWriterBuilder> {
let log_dir = dirs::app_logs_dir()?;
let log_max_size = self.log_max_size.load(Ordering::SeqCst);
let log_max_count = self.log_max_count.load(Ordering::SeqCst);
let flwb = FileLogWriter::builder(FileSpec::default().directory(log_dir).basename("")).rotate(
Criterion::Size(log_max_size * 1024),
flexi_logger::Naming::TimestampsCustomFormat {
current_infix: Some("latest"),
format: "%Y-%m-%d_%H-%M-%S",
},
Cleanup::KeepLogFiles(log_max_count),
);
Ok(flwb)
}
/// only update app log level
pub fn update_log_level(&self, level: LevelFilter) -> Result<()> {
*self.log_level.write() = level;
let log_level = self.log_level.read().to_owned();
if let Some(handle) = self.handle.lock().as_mut() {
let log_spec = Self::generate_log_spec(log_level);
handle.set_new_spec(log_spec);
handle.adapt_duplication_to_stdout(log_level.into())?;
} else {
bail!("failed to get logger handle, make sure it init");
};
Ok(())
}
/// update app and mihomo core log config
pub async fn update_log_config(&self, log_max_size: u64, log_max_count: usize) -> Result<()> {
self.log_max_size.store(log_max_size, Ordering::SeqCst);
self.log_max_count.store(log_max_count, Ordering::SeqCst);
if let Some(handle) = self.handle.lock().as_ref() {
let log_file_writer = self.generate_file_log_writer()?;
handle.reset_flw(&log_file_writer)?;
} else {
bail!("failed to get logger handle, make sure it init");
};
let sidecar_writer = self.generate_sidecar_writer()?;
*self.sidecar_file_writer.write() = Some(sidecar_writer);
// update service writer config
if service::is_service_ipc_path_exists() && service::is_service_available().await.is_ok() {
let service_log_dir = dirs::path_to_str(&service_log_dir()?)?.into();
clash_verge_service_ipc::update_writer(&WriterConfig {
directory: service_log_dir,
max_log_size: log_max_size * 1024,
max_log_files: log_max_count,
})
.await?;
}
Ok(())
}
fn generate_sidecar_writer(&self) -> Result<FileLogWriter> {
let sidecar_log_dir = sidecar_log_dir()?;
let log_max_size = self.log_max_size.load(Ordering::SeqCst);
let log_max_count = self.log_max_count.load(Ordering::SeqCst);
Ok(FileLogWriter::builder(
FileSpec::default()
.directory(sidecar_log_dir)
.basename("sidecar")
.suppress_timestamp(),
)
.format(clash_verge_logger::file_format_without_level)
.rotate(
Criterion::Size(log_max_size * 1024),
flexi_logger::Naming::TimestampsCustomFormat {
current_infix: Some("latest"),
format: "%Y-%m-%d_%H-%M-%S",
},
Cleanup::KeepLogFiles(log_max_count),
)
.try_build()?)
}
pub fn writer_sidecar_log(&self, level: Level, message: &CompactString) {
if let Some(writer) = self.sidecar_file_writer.read().as_ref() {
let mut now = DeferredNow::default();
let args = format_args!("{}", message);
let record = Record::builder().args(args).level(level).target("sidecar").build();
let _ = writer.write(&mut now, &record);
} else {
logging!(error, Type::System, "failed to get sidecar file log writer");
}
}
pub fn service_writer_config(&self) -> Result<WriterConfig> {
let service_log_dir = dirs::path_to_str(&service_log_dir()?)?.into();
let log_max_size = self.log_max_size.load(Ordering::SeqCst);
let log_max_count = self.log_max_count.load(Ordering::SeqCst);
let writer_config = WriterConfig {
directory: service_log_dir,
max_log_size: log_max_size * 1024,
max_log_files: log_max_count,
};
Ok(writer_config)
}
}
+2 -2
View File
@@ -60,10 +60,10 @@ impl CoreManager {
async fn perform_config_update(&self) -> Result<(bool, String)> {
Config::generate().await?;
self.apply_generate_confihg().await
self.apply_generate_config().await
}
pub async fn apply_generate_confihg(&self) -> Result<(bool, String)> {
pub async fn apply_generate_config(&self) -> Result<(bool, String)> {
match CoreConfigValidator::global().validate_config().await {
Ok((true, _)) => {
let run_path = Config::generate_file(ConfigType::Run).await?;
+11 -10
View File
@@ -2,14 +2,13 @@ use super::{CoreManager, RunningMode};
use crate::{
AsyncHandler,
config::{Config, IClashTemp},
core::{handle, manager::CLASH_LOGGER, service},
core::{handle, logger::Logger, manager::CLASH_LOGGER, service},
logging,
utils::{dirs, init::sidecar_writer},
utils::dirs,
};
use anyhow::Result;
use clash_verge_logging::{SharedWriter, Type, write_sidecar_log};
use clash_verge_logging::Type;
use compact_str::CompactString;
use flexi_logger::DeferredNow;
use log::Level;
use scopeguard::defer;
use tauri_plugin_shell::ShellExt as _;
@@ -31,6 +30,8 @@ impl CoreManager {
let clash_core = Config::verge().await.latest_arc().get_valid_clash_core();
let config_dir = dirs::app_home_dir()?;
#[cfg(unix)]
let previous_mask = unsafe { tauri_plugin_clash_verge_sysinfo::libc::umask(0o007) };
let (mut rx, child) = app_handle
.shell()
.sidecar(clash_core.as_str())?
@@ -47,6 +48,10 @@ impl CoreManager {
&IClashTemp::guard_external_controller_ipc(),
])
.spawn()?;
#[cfg(unix)]
unsafe {
tauri_plugin_clash_verge_sysinfo::libc::umask(previous_mask)
};
let pid = child.pid();
logging!(trace, Type::Core, "Sidecar started with PID: {}", pid);
@@ -54,20 +59,16 @@ impl CoreManager {
self.set_running_child_sidecar(child);
self.set_running_mode(RunningMode::Sidecar);
let shared_writer: SharedWriter = std::sync::Arc::new(tokio::sync::Mutex::new(sidecar_writer().await?));
AsyncHandler::spawn(|| async move {
while let Some(event) = rx.recv().await {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line)
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
let mut now = DeferredNow::default();
let message = CompactString::from(String::from_utf8_lossy(&line).as_ref());
write_sidecar_log(shared_writer.lock().await, &mut now, Level::Error, &message);
Logger::global().writer_sidecar_log(Level::Error, &message);
CLASH_LOGGER.append_log(message).await;
}
tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
let mut now = DeferredNow::default();
let message = if let Some(code) = term.code {
CompactString::from(format!("Process terminated with code: {}", code))
} else if let Some(signal) = term.signal {
@@ -75,7 +76,7 @@ impl CoreManager {
} else {
CompactString::from("Process terminated")
};
write_sidecar_log(shared_writer.lock().await, &mut now, Level::Info, &message);
Logger::global().writer_sidecar_log(Level::Info, &message);
CLASH_LOGGER.clear_logs().await;
break;
}
+12 -67
View File
@@ -1,16 +1,8 @@
use super::handle::Handle;
use crate::constants::{retry, timing};
use crate::constants::timing;
use clash_verge_logging::{Type, logging};
use parking_lot::RwLock;
use smartstring::alias::String;
use std::{
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc,
},
thread,
time::Instant,
};
use std::{sync::mpsc, thread};
use tauri::{Emitter as _, WebviewWindow};
// TODO 重构或优化,避免 Clone 过多
@@ -25,27 +17,11 @@ pub enum FrontendEvent {
ProfileUpdateCompleted { uid: String },
}
#[derive(Debug, Default)]
struct EventStats {
total_sent: AtomicU64,
total_errors: AtomicU64,
last_error_time: RwLock<Option<Instant>>,
}
#[derive(Debug, Clone)]
pub struct ErrorMessage {
pub status: String,
pub message: String,
}
#[derive(Debug)]
pub struct NotificationSystem {
sender: Option<mpsc::Sender<FrontendEvent>>,
#[allow(clippy::type_complexity)]
worker_handle: Option<thread::JoinHandle<()>>,
pub(super) is_running: bool,
stats: EventStats,
emergency_mode: AtomicBool,
}
impl Default for NotificationSystem {
@@ -55,25 +31,26 @@ impl Default for NotificationSystem {
}
impl NotificationSystem {
pub fn new() -> Self {
pub const fn new() -> Self {
Self {
sender: None,
worker_handle: None,
is_running: false,
stats: EventStats::default(),
emergency_mode: AtomicBool::new(false),
}
}
pub const fn is_running(&self) -> bool {
self.sender.is_some() && self.worker_handle.is_some()
}
pub fn start(&mut self) {
if self.is_running {
if self.is_running() {
return;
}
let (tx, rx) = mpsc::channel();
self.sender = Some(tx);
self.is_running = true;
//? Do we have to create a new thread for this?
let result = thread::Builder::new()
.name("frontend-notifier".into())
.spawn(move || Self::worker_loop(rx));
@@ -107,10 +84,6 @@ impl NotificationSystem {
None => return,
};
if system.should_skip_event(&event) {
return;
}
if let Some(window) = super::handle::Handle::get_window() {
system.emit_to_window(&window, event);
drop(binding);
@@ -118,30 +91,15 @@ impl NotificationSystem {
}
}
fn should_skip_event(&self, event: &FrontendEvent) -> bool {
let is_emergency = self.emergency_mode.load(Ordering::Acquire);
matches!(
(is_emergency, event),
(true, FrontendEvent::NoticeMessage { status, .. }) if status == "info"
)
}
fn emit_to_window(&self, window: &WebviewWindow, event: FrontendEvent) {
let (event_name, payload) = self.serialize_event(event);
let Ok(payload) = payload else {
self.stats.total_errors.fetch_add(1, Ordering::Relaxed);
return;
};
match window.emit(event_name, payload) {
Ok(_) => {
self.stats.total_sent.fetch_add(1, Ordering::Relaxed);
}
Err(e) => {
logging!(warn, Type::Frontend, "Event emit failed: {}", e);
self.handle_emit_error();
}
if let Err(e) = window.emit(event_name, payload) {
logging!(warn, Type::Frontend, "Event emit failed: {}", e);
}
}
@@ -161,19 +119,8 @@ impl NotificationSystem {
}
}
fn handle_emit_error(&self) {
self.stats.total_errors.fetch_add(1, Ordering::Relaxed);
*self.stats.last_error_time.write() = Some(Instant::now());
let errors = self.stats.total_errors.load(Ordering::Relaxed);
if errors > retry::EVENT_EMIT_THRESHOLD && !self.emergency_mode.load(Ordering::Acquire) {
logging!(warn, Type::Frontend, "Entering emergency mode after {} errors", errors);
self.emergency_mode.store(true, Ordering::Release);
}
}
pub fn send_event(&self, event: FrontendEvent) -> bool {
if self.should_skip_event(&event) {
if !self.is_running() {
return false;
}
@@ -185,8 +132,6 @@ impl NotificationSystem {
}
pub fn shutdown(&mut self) {
self.is_running = false;
if let Some(sender) = self.sender.take() {
drop(sender);
}
+26 -125
View File
@@ -1,7 +1,7 @@
use crate::{
config::{Config, IClashTemp},
core::tray::Tray,
utils::{dirs, init::service_writer_config},
core::{logger::Logger, tray::Tray},
utils::dirs,
};
use anyhow::{Context as _, Result, bail};
use clash_verge_logging::{Type, logging, logging_error};
@@ -30,9 +30,8 @@ pub enum ServiceStatus {
#[derive(Clone)]
pub struct ServiceManager(ServiceStatus);
#[allow(clippy::unused_async)]
#[cfg(target_os = "windows")]
async fn uninstall_service() -> Result<()> {
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
use deelevate::{PrivilegeLevel, Token};
@@ -63,9 +62,8 @@ async fn uninstall_service() -> Result<()> {
Ok(())
}
#[allow(clippy::unused_async)]
#[cfg(target_os = "windows")]
async fn install_service() -> Result<()> {
fn install_service() -> Result<()> {
logging!(info, Type::Service, "install service");
use deelevate::{PrivilegeLevel, Token};
@@ -93,27 +91,8 @@ async fn install_service() -> Result<()> {
Ok(())
}
#[cfg(target_os = "windows")]
async fn reinstall_service() -> Result<()> {
logging!(info, Type::Service, "reinstall service");
// 先卸载服务
if let Err(err) = uninstall_service().await {
logging!(warn, Type::Service, "failed to uninstall service: {}", err);
}
// 再安装服务
match install_service().await {
Ok(_) => Ok(()),
Err(err) => {
bail!(format!("failed to install service: {err}"))
}
}
}
#[allow(clippy::unused_async)]
#[cfg(target_os = "linux")]
async fn uninstall_service() -> Result<()> {
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall");
@@ -169,8 +148,7 @@ async fn uninstall_service() -> Result<()> {
}
#[cfg(target_os = "linux")]
#[allow(clippy::unused_async)]
async fn install_service() -> Result<()> {
fn install_service() -> Result<()> {
logging!(info, Type::Service, "install service");
let install_path = tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install");
@@ -222,24 +200,6 @@ async fn install_service() -> Result<()> {
Ok(())
}
#[cfg(target_os = "linux")]
async fn reinstall_service() -> Result<()> {
logging!(info, Type::Service, "reinstall service");
// 先卸载服务
if let Err(err) = uninstall_service().await {
logging!(warn, Type::Service, "failed to uninstall service: {}", err);
}
// 再安装服务
match install_service().await {
Ok(_) => Ok(()),
Err(err) => {
bail!(format!("failed to install service: {err}"))
}
}
}
#[cfg(target_os = "linux")]
fn linux_running_as_root() -> bool {
use crate::core::handle;
@@ -249,7 +209,7 @@ fn linux_running_as_root() -> bool {
}
#[cfg(target_os = "macos")]
async fn uninstall_service() -> Result<()> {
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let binary_path = dirs::service_path()?;
@@ -261,9 +221,9 @@ async fn uninstall_service() -> Result<()> {
let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned();
crate::utils::i18n::sync_locale().await;
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
let prompt = rust_i18n::t!("service.adminUninstallPrompt").to_string();
let prompt = clash_verge_i18n::t!("service.adminUninstallPrompt");
let command =
format!(r#"do shell script "sudo '{uninstall_shell}'" with administrator privileges with prompt "{prompt}""#);
@@ -282,7 +242,7 @@ async fn uninstall_service() -> Result<()> {
}
#[cfg(target_os = "macos")]
async fn install_service() -> Result<()> {
fn install_service() -> Result<()> {
logging!(info, Type::Service, "install service");
let binary_path = dirs::service_path()?;
@@ -294,9 +254,9 @@ async fn install_service() -> Result<()> {
let install_shell: String = install_path.to_string_lossy().into_owned();
crate::utils::i18n::sync_locale().await;
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
let prompt = rust_i18n::t!("service.adminInstallPrompt").to_string();
let prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
let command =
format!(r#"do shell script "sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""#);
@@ -309,17 +269,16 @@ async fn install_service() -> Result<()> {
Ok(())
}
#[cfg(target_os = "macos")]
async fn reinstall_service() -> Result<()> {
fn reinstall_service() -> Result<()> {
logging!(info, Type::Service, "reinstall service");
// 先卸载服务
if let Err(err) = uninstall_service().await {
if let Err(err) = uninstall_service() {
logging!(warn, Type::Service, "failed to uninstall service: {}", err);
}
// 再安装服务
match install_service().await {
match install_service() {
Ok(_) => Ok(()),
Err(err) => {
bail!(format!("failed to install service: {err}"))
@@ -328,62 +287,14 @@ async fn reinstall_service() -> Result<()> {
}
/// 强制重装服务(UI修复按钮)
async fn force_reinstall_service() -> Result<()> {
fn force_reinstall_service() -> Result<()> {
logging!(info, Type::Service, "用户请求强制重装服务");
reinstall_service().await.map_err(|err| {
reinstall_service().map_err(|err| {
logging!(error, Type::Service, "强制重装服务失败: {}", err);
err
})
}
/// 检查服务版本 - 使用IPC通信
async fn check_service_version() -> Result<String> {
let version_arc: Result<String> = {
logging!(info, Type::Service, "开始检查服务版本 (IPC)");
let result = clash_verge_service_ipc::get_version().await;
logging!(debug, Type::Service, "检查服务版本 (IPC) 结果: {:?}", result);
// 检查错误信息是否是JSON序列化错误或预期值错误,以适配老版本服务
// 这可能是因为老版本服务的API不兼容,导致无法正确解析响应
// 如果是这种情况,直接返回空字符串,表示无法获取版本
if let Err(e) = result.as_ref()
&& (e.to_string().contains("JSON serialization error") || e.to_string().contains("expected value"))
{
logging!(
warn,
Type::Service,
"服务版本检查失败,可能是老版本服务 API 不兼容: {}",
e
);
return Ok("".to_string());
}
// 因为上面的错误处理 Error 可能会被忽略,所以这里需要再次检查
let response = result.context("无法连接到Clash Verge Service")?;
if response.code > 0 {
let err_msg = response.message;
logging!(error, Type::Service, "获取服务版本失败: {}", err_msg);
return Err(anyhow::anyhow!(err_msg));
}
let version = response.data.unwrap_or_else(|| "unknown".into());
Ok(version)
};
match version_arc.as_ref() {
Ok(v) => Ok(v.clone()),
Err(e) => Err(anyhow::Error::msg(e.to_string())),
}
}
/// 检查服务是否需要重装
pub async fn check_service_needs_reinstall() -> Result<bool> {
match check_service_version().await {
Ok(version) => Ok(version != clash_verge_service_ipc::VERSION),
Err(e) => Err(e),
}
}
/// 尝试使用服务启动core
pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result<()> {
logging!(info, Type::Service, "尝试使用现有服务启动核心");
@@ -402,7 +313,7 @@ pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result
core_ipc_path: IClashTemp::guard_external_controller_ipc(),
config_dir: dirs::path_to_str(&dirs::app_home_dir()?)?.into(),
},
log_config: service_writer_config().await?,
log_config: Logger::global().service_writer_config()?,
};
let response = clash_verge_service_ipc::start_clash(&payload)
@@ -524,20 +435,10 @@ impl ServiceManager {
/// 综合服务状态检查(一次性完成所有检查)
pub async fn check_service_comprehensive(&self) -> ServiceStatus {
match check_service_needs_reinstall().await {
Ok(need) => {
logging!(debug, Type::Service, "服务当前可用,检查是否需要重装");
if need {
logging!(debug, Type::Service, "服务需要重装且需要重装");
ServiceStatus::NeedsReinstall
} else {
ServiceStatus::Ready
}
}
Err(err) => {
logging!(warn, Type::Service, "服务不可用,检查安装状态");
ServiceStatus::Unavailable(err.to_string())
}
if clash_verge_service_ipc::is_reinstall_service_needed().await {
ServiceStatus::NeedsReinstall
} else {
ServiceStatus::Ready
}
}
@@ -550,22 +451,22 @@ impl ServiceManager {
}
ServiceStatus::NeedsReinstall | ServiceStatus::ReinstallRequired => {
logging!(info, Type::Service, "服务需要重装,执行重装流程");
reinstall_service().await?;
reinstall_service()?;
wait_and_check_service_available(self).await?;
}
ServiceStatus::ForceReinstallRequired => {
logging!(info, Type::Service, "服务需要强制重装,执行强制重装流程");
force_reinstall_service().await?;
force_reinstall_service()?;
wait_and_check_service_available(self).await?;
}
ServiceStatus::InstallRequired => {
logging!(info, Type::Service, "需要安装服务,执行安装流程");
install_service().await?;
install_service()?;
wait_and_check_service_available(self).await?;
}
ServiceStatus::UninstallRequired => {
logging!(info, Type::Service, "服务需要卸载,执行卸载流程");
uninstall_service().await?;
uninstall_service()?;
self.0 = ServiceStatus::Unavailable("Service Uninstalled".into());
}
ServiceStatus::Unavailable(reason) => {
+28 -41
View File
@@ -1,12 +1,14 @@
#[cfg(target_os = "windows")]
use crate::utils::autostart as startup_shortcut;
use crate::utils::schtasks as startup_task;
use crate::{
config::{Config, IVerge},
core::handle::Handle,
singleton,
};
use anyhow::Result;
use clash_verge_logging::{Type, logging, logging_error};
#[cfg(not(target_os = "windows"))]
use clash_verge_logging::logging_error;
use clash_verge_logging::{Type, logging};
use parking_lot::RwLock;
use scopeguard::defer;
use smartstring::alias::String;
@@ -18,7 +20,10 @@ use std::{
time::Duration,
};
use sysproxy::{Autoproxy, GuardMonitor, GuardType, Sysproxy};
#[cfg(not(target_os = "windows"))]
use tauri_plugin_autostart::ManagerExt as _;
#[cfg(target_os = "windows")]
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
pub struct Sysopt {
update_sysproxy: AtomicBool,
@@ -230,35 +235,21 @@ impl Sysopt {
let is_enable = enable_auto_launch.unwrap_or(false);
logging!(info, Type::System, "Setting auto-launch state to: {:?}", is_enable);
// 首先尝试使用快捷方式方法
#[cfg(target_os = "windows")]
{
if is_enable {
if let Err(e) = startup_shortcut::create_shortcut().await {
logging!(error, Type::Setup, "创建启动快捷方式失败: {e}");
// 如果快捷方式创建失败,回退到原来的方法
self.try_original_autostart_method(is_enable);
} else {
return Ok(());
}
} else if let Err(e) = startup_shortcut::remove_shortcut().await {
logging!(error, Type::Setup, "删除启动快捷方式失败: {e}");
self.try_original_autostart_method(is_enable);
} else {
return Ok(());
}
let is_admin = is_current_app_handle_admin(Handle::app_handle());
startup_task::set_auto_launch(is_enable, is_admin).await
}
#[cfg(not(target_os = "windows"))]
{
// 非Windows平台使用原来的方法
self.try_original_autostart_method(is_enable);
Ok(())
}
Ok(())
}
/// 尝试使用原来的自启动方法
#[cfg(not(target_os = "windows"))]
fn try_original_autostart_method(&self, is_enable: bool) {
let app_handle = Handle::app_handle();
let autostart_manager = app_handle.autolaunch();
@@ -272,32 +263,28 @@ impl Sysopt {
/// 获取当前自启动的实际状态
pub fn get_launch_status(&self) -> Result<bool> {
// 首先尝试检查快捷方式是否存在
#[cfg(target_os = "windows")]
{
match startup_shortcut::is_shortcut_enabled() {
Ok(enabled) => {
logging!(info, Type::System, "快捷方式自启动状态: {enabled}");
return Ok(enabled);
}
Err(e) => {
logging!(error, Type::System, "检查快捷方式失败,尝试原来的方法: {e}");
}
let enabled = startup_task::is_auto_launch_enabled();
if let Ok(status) = enabled {
logging!(info, Type::System, "Auto launch status (scheduled task): {status}");
}
enabled
}
// 回退到原来的方法
let app_handle = Handle::app_handle();
let autostart_manager = app_handle.autolaunch();
match autostart_manager.is_enabled() {
Ok(status) => {
logging!(info, Type::System, "Auto launch status: {status}");
Ok(status)
}
Err(e) => {
logging!(error, Type::System, "Failed to get auto launch status: {e}");
Err(anyhow::anyhow!("Failed to get auto launch status: {}", e))
#[cfg(not(target_os = "windows"))]
{
let app_handle = Handle::app_handle();
let autostart_manager = app_handle.autolaunch();
match autostart_manager.is_enabled() {
Ok(status) => {
logging!(info, Type::System, "Auto launch status: {status}");
Ok(status)
}
Err(e) => {
logging!(error, Type::System, "Failed to get auto launch status: {e}");
Err(anyhow::anyhow!("Failed to get auto launch status: {}", e))
}
}
}
}
+1
View File
@@ -25,6 +25,7 @@ pub struct TimerTask {
pub last_run: i64, // Timestamp of last execution
}
// TODO 一个 Timer 负责轻量, 一个 Timer 负责订阅更新。当前会生产 N(订阅数量) + 1 个定时任务
pub struct Timer {
/// cron manager
pub delay_timer: Arc<RwLock<DelayTimer>>,
+25 -11
View File
@@ -1,18 +1,11 @@
use rust_i18n::t;
use std::{borrow::Cow, sync::Arc};
fn to_arc_str(value: Cow<'static, str>) -> Arc<str> {
match value {
Cow::Borrowed(s) => Arc::from(s),
Cow::Owned(s) => Arc::from(s.into_boxed_str()),
}
}
use clash_verge_i18n::t;
use std::borrow::Cow;
macro_rules! define_menu {
($($field:ident => $const_name:ident, $id:expr, $text:expr),+ $(,)?) => {
#[derive(Debug)]
pub struct MenuTexts {
$(pub $field: Arc<str>,)+
$(pub $field: Cow<'static, str>,)+
}
pub struct MenuIds;
@@ -20,7 +13,7 @@ macro_rules! define_menu {
impl MenuTexts {
pub fn new() -> Self {
Self {
$($field: to_arc_str(t!($text)),)+
$($field: t!($text),)+
}
}
}
@@ -56,3 +49,24 @@ define_menu! {
more => MORE, "tray_more", "tray.more",
exit => EXIT, "tray_exit", "tray.exit",
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum TrayAction {
SystemProxy,
TunMode,
MainWindow,
TrayMenue,
Unknown,
}
impl From<&str> for TrayAction {
fn from(s: &str) -> Self {
match s {
"system_proxy" => Self::SystemProxy,
"tun_mode" => Self::TunMode,
"main_window" => Self::MainWindow,
"tray_menue" => Self::TrayMenue,
_ => Self::Unknown,
}
}
}
+126 -182
View File
@@ -1,34 +1,26 @@
use once_cell::sync::OnceCell;
use tauri::tray::TrayIconBuilder;
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
use tauri_plugin_mihomo::models::Proxies;
use tokio::fs;
#[cfg(target_os = "macos")]
pub mod speed_rate;
use crate::config::{IProfilePreview, IVerge};
use crate::core::service;
use crate::core::tray::menu_def::TrayAction;
use crate::module::lightweight;
use crate::process::AsyncHandler;
use crate::singleton;
use crate::utils::window_manager::WindowManager;
use crate::{
Type, cmd,
config::Config,
feat, logging,
module::lightweight::is_in_lightweight_mode,
utils::{dirs::find_target_icons, i18n},
Type, cmd, config::Config, feat, logging, module::lightweight::is_in_lightweight_mode,
utils::dirs::find_target_icons,
};
use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
use tauri::tray::TrayIconBuilder;
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
use tauri_plugin_mihomo::models::Proxies;
use tokio::fs;
use super::handle;
use anyhow::Result;
use parking_lot::Mutex;
use smartstring::alias::String;
use std::collections::HashMap;
use std::sync::Arc;
use std::{
sync::atomic::{AtomicBool, Ordering},
time::{Duration, Instant},
};
use std::num::NonZeroU32;
use std::time::Duration;
use tauri::{
AppHandle, Wry,
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
@@ -41,48 +33,27 @@ use menu_def::{MenuIds, MenuTexts};
type ProxyMenuItem = (Option<Submenu<Wry>>, Vec<Box<dyn IsMenuItem<Wry>>>);
const TRAY_CLICK_DEBOUNCE_MS: u64 = 1_275;
#[derive(Clone)]
struct TrayState {}
// 托盘点击防抖机制
static TRAY_CLICK_DEBOUNCE: OnceCell<Mutex<Instant>> = OnceCell::new();
const TRAY_CLICK_DEBOUNCE_MS: u64 = 300;
fn get_tray_click_debounce() -> &'static Mutex<Instant> {
TRAY_CLICK_DEBOUNCE.get_or_init(|| Mutex::new(Instant::now() - Duration::from_secs(1)))
}
fn should_handle_tray_click() -> bool {
let debounce_lock = get_tray_click_debounce();
let now = Instant::now();
if now.duration_since(*debounce_lock.lock()) >= Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS) {
*debounce_lock.lock() = now;
true
} else {
logging!(
debug,
Type::Tray,
"托盘点击被防抖机制忽略,距离上次点击 {}ms",
now.duration_since(*debounce_lock.lock()).as_millis()
);
false
}
}
#[cfg(target_os = "macos")]
pub struct Tray {
last_menu_update: Mutex<Option<Instant>>,
menu_updating: AtomicBool,
}
#[cfg(not(target_os = "macos"))]
pub struct Tray {
last_menu_update: Mutex<Option<Instant>>,
menu_updating: AtomicBool,
limiter: DefaultDirectRateLimiter,
}
impl TrayState {
async fn get_tray_icon(verge: &IVerge) -> (bool, Vec<u8>) {
let system_mode = verge.enable_system_proxy.as_ref().unwrap_or(&false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
match (*system_mode, *tun_mode) {
(true, true) => Self::get_tun_tray_icon(verge).await,
(true, false) => Self::get_sysproxy_tray_icon(verge).await,
(false, true) => Self::get_tun_tray_icon(verge).await,
(false, false) => Self::get_common_tray_icon(verge).await,
}
}
async fn get_common_tray_icon(verge: &IVerge) -> (bool, Vec<u8>) {
let is_common_tray_icon = verge.common_tray_icon.unwrap_or(false);
if is_common_tray_icon
@@ -162,10 +133,14 @@ impl TrayState {
}
impl Default for Tray {
#[allow(clippy::unwrap_used)]
fn default() -> Self {
Self {
last_menu_update: Mutex::new(None),
menu_updating: AtomicBool::new(false),
limiter: RateLimiter::direct(
Quota::with_period(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS))
.unwrap()
.allow_burst(NonZeroU32::new(1).unwrap()),
),
}
}
}
@@ -210,12 +185,12 @@ impl Tray {
let app_handle = handle::Handle::app_handle();
let tray_event = { Config::verge().await.latest_arc().tray_event.clone() };
let tray_event = tray_event.unwrap_or_else(|| "main_window".into());
let tray_event = TrayAction::from(tray_event.as_deref().unwrap_or("main_window"));
let tray = app_handle
.tray_by_id("main")
.ok_or_else(|| anyhow::anyhow!("Failed to get main tray"))?;
match tray_event.as_str() {
"tray_menu" => tray.set_show_menu_on_left_click(true)?,
match tray_event {
TrayAction::TrayMenue => tray.set_show_menu_on_left_click(true)?,
_ => tray.set_show_menu_on_left_click(false)?,
}
Ok(())
@@ -227,45 +202,8 @@ impl Tray {
logging!(debug, Type::Tray, "应用正在退出,跳过托盘菜单更新");
return Ok(());
}
// 调整最小更新间隔,确保状态及时刷新
const MIN_UPDATE_INTERVAL: Duration = Duration::from_millis(100);
// 检查是否正在更新
if self.menu_updating.load(Ordering::Acquire) {
return Ok(());
}
// 检查更新频率,但允许重要事件跳过频率限制
let should_force_update = match std::thread::current().name() {
Some("main") => true,
_ => {
let last_update = self.last_menu_update.lock();
if let Some(last_time) = *last_update {
last_time.elapsed() >= MIN_UPDATE_INTERVAL
} else {
true
}
}
};
if !should_force_update {
return Ok(());
}
let app_handle = handle::Handle::app_handle();
// 设置更新状态
self.menu_updating.store(true, Ordering::Release);
let result = self.update_menu_internal(app_handle).await;
{
let mut last_update = self.last_menu_update.lock();
*last_update = Some(Instant::now());
}
self.menu_updating.store(false, Ordering::Release);
result
self.update_menu_internal(app_handle).await
}
async fn update_menu_internal(&self, app_handle: &AppHandle) -> Result<()> {
@@ -331,15 +269,7 @@ impl Tray {
}
};
let system_mode = verge.enable_system_proxy.as_ref().unwrap_or(&false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
let (_is_custom_icon, icon_bytes) = match (*system_mode, *tun_mode) {
(true, true) => TrayState::get_tun_tray_icon(verge).await,
(true, false) => TrayState::get_sysproxy_tray_icon(verge).await,
(false, true) => TrayState::get_tun_tray_icon(verge).await,
(false, false) => TrayState::get_common_tray_icon(verge).await,
};
let (_is_custom_icon, icon_bytes) = TrayState::get_tray_icon(verge).await;
let colorful = verge.tray_icon.clone().unwrap_or_else(|| "monochrome".into());
let is_colorful = colorful == "colorful";
@@ -366,15 +296,7 @@ impl Tray {
}
};
let system_mode = verge.enable_system_proxy.as_ref().unwrap_or(&false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
let (_is_custom_icon, icon_bytes) = match (*system_mode, *tun_mode) {
(true, true) => TrayState::get_tun_tray_icon(verge).await,
(true, false) => TrayState::get_sysproxy_tray_icon(verge).await,
(false, true) => TrayState::get_tun_tray_icon(verge).await,
(false, false) => TrayState::get_common_tray_icon(verge).await,
};
let (_is_custom_icon, icon_bytes) = TrayState::get_tray_icon(verge).await;
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?));
Ok(())
@@ -389,8 +311,6 @@ impl Tray {
let app_handle = handle::Handle::app_handle();
i18n::sync_locale().await;
let verge = Config::verge().await.latest_arc();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
@@ -417,9 +337,9 @@ impl Tray {
}
// Get localized strings before using them
let sys_proxy_text = rust_i18n::t!("tray.tooltip.systemProxy");
let tun_text = rust_i18n::t!("tray.tooltip.tun");
let profile_text = rust_i18n::t!("tray.tooltip.profile");
let sys_proxy_text = clash_verge_i18n::t!("tray.tooltip.systemProxy");
let tun_text = clash_verge_i18n::t!("tray.tooltip.tun");
let profile_text = clash_verge_i18n::t!("tray.tooltip.profile");
let v = env!("CARGO_PKG_VERSION");
let reassembled_version = v.split_once('+').map_or_else(
@@ -469,25 +389,20 @@ impl Tray {
let verge = Config::verge().await.data_arc();
// 获取图标
let icon_bytes = TrayState::get_common_tray_icon(&verge).await.1;
let icon_bytes = TrayState::get_tray_icon(&verge).await.1;
let icon = tauri::image::Image::from_bytes(&icon_bytes)?;
#[cfg(target_os = "linux")]
let builder = TrayIconBuilder::with_id("main").icon(icon).icon_as_template(false);
#[cfg(any(target_os = "macos", target_os = "windows"))]
let show_menu_on_left_click = {
// TODO 优化这里 复用 verge
let tray_event = { Config::verge().await.latest_arc().tray_event.clone() };
tray_event.is_some_and(|v| v == "tray_menu")
};
let show_menu_on_left_click = verge.tray_event.as_ref().is_some_and(|v| v == "tray_menu");
#[cfg(not(target_os = "linux"))]
let mut builder = TrayIconBuilder::with_id("main").icon(icon).icon_as_template(false);
#[cfg(target_os = "macos")]
{
let is_monochrome = verge.tray_icon.clone().is_none_or(|v| v == "monochrome");
let is_monochrome = verge.tray_icon.as_ref().is_none_or(|v| v == "monochrome");
builder = builder.icon_as_template(is_monochrome);
}
@@ -499,8 +414,10 @@ impl Tray {
}
let tray = builder.build(app_handle)?;
let tray_event = verge.tray_event.clone().unwrap_or_else(|| "main_window".into());
let tray_action = TrayAction::from(tray_event.as_str());
tray.on_tray_icon_event(|_app_handle, event| {
tray.on_tray_icon_event(move |_app_handle, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Down,
@@ -508,33 +425,46 @@ impl Tray {
} = event
{
// 添加防抖检查,防止快速连击
if !should_handle_tray_click() {
logging!(info, Type::Tray, "click tray icon too fast, ignore");
#[allow(clippy::use_self)]
if !Tray::global().should_handle_tray_click() {
return;
}
AsyncHandler::spawn(|| async move {
let tray_event = { Config::verge().await.latest_arc().tray_event.clone() };
let tray_event: String = tray_event.unwrap_or_else(|| "main_window".into());
logging!(debug, Type::Tray, "tray event: {tray_event:?}");
match tray_event.as_str() {
"system_proxy" => feat::toggle_system_proxy().await,
"tun_mode" => feat::toggle_tun_mode(None).await,
"main_window" => {
logging!(debug, Type::Tray, "tray event: {tray_action:?}");
match tray_action {
TrayAction::SystemProxy => {
AsyncHandler::spawn(|| async move {
let _ = feat::toggle_system_proxy().await;
});
}
TrayAction::TunMode => {
AsyncHandler::spawn(|| async move {
let _ = feat::toggle_tun_mode(None).await;
});
}
TrayAction::MainWindow => {
AsyncHandler::spawn(|| async move {
if !lightweight::exit_lightweight_mode().await {
WindowManager::show_main_window().await;
};
}
_ => {
logging!(warn, Type::Tray, "invalid tray event: {}", tray_event);
}
};
});
});
}
_ => {
logging!(warn, Type::Tray, "invalid tray event: {}", tray_event);
}
};
}
});
tray.on_menu_event(on_menu_event);
Ok(())
}
fn should_handle_tray_click(&self) -> bool {
let res = self.limiter.check().is_ok();
if !res {
logging!(debug, Type::Tray, "tray click rate limited");
}
res
}
}
fn create_hotkeys(hotkeys: &Option<Vec<String>>) -> HashMap<String, String> {
@@ -680,7 +610,7 @@ fn create_proxy_menu_item(
app_handle: &AppHandle,
show_proxy_groups_inline: bool,
proxy_submenus: Vec<Submenu<Wry>>,
proxies_text: &Arc<str>,
proxies_text: &str,
) -> Result<ProxyMenuItem> {
// 创建代理主菜单
let (proxies_submenu, inline_proxy_items) = if show_proxy_groups_inline {
@@ -724,8 +654,6 @@ async fn create_tray_menu(
) -> Result<tauri::menu::Menu<Wry>> {
let current_proxy_mode = mode.unwrap_or("");
i18n::sync_locale().await;
// TODO: should update tray menu again when it was timeout error
let proxy_nodes_data = tokio::time::timeout(
Duration::from_millis(1000),
@@ -770,7 +698,11 @@ async fn create_tray_menu(
});
let verge_settings = Config::verge().await.latest_arc();
let show_proxy_groups_inline = verge_settings.tray_inline_proxy_groups.unwrap_or(true);
let tray_proxy_groups_display_mode = verge_settings
.tray_proxy_groups_display_mode
.as_deref()
.unwrap_or("default");
let show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false);
let version = env!("CARGO_PKG_VERSION");
@@ -794,13 +726,6 @@ async fn create_tray_menu(
hotkeys.get("open_or_close_dashboard").map(|s| s.as_str()),
)?;
let current_mode_text = match current_proxy_mode {
"global" => rust_i18n::t!("tray.global"),
"direct" => rust_i18n::t!("tray.direct"),
_ => rust_i18n::t!("tray.rule"),
};
let outbound_modes_label = format!("{} ({})", texts.outbound_modes, current_mode_text);
let rule_mode = &CheckMenuItem::with_id(
app_handle,
MenuIds::RULE_MODE,
@@ -828,17 +753,27 @@ async fn create_tray_menu(
hotkeys.get("clash_mode_direct").map(|s| s.as_str()),
)?;
let outbound_modes = &Submenu::with_id_and_items(
app_handle,
MenuIds::OUTBOUND_MODES,
outbound_modes_label.as_str(),
true,
&[
rule_mode as &dyn IsMenuItem<Wry>,
global_mode as &dyn IsMenuItem<Wry>,
direct_mode as &dyn IsMenuItem<Wry>,
],
)?;
let outbound_modes = if show_outbound_modes_inline {
None
} else {
let current_mode_text = match current_proxy_mode {
"global" => clash_verge_i18n::t!("tray.global"),
"direct" => clash_verge_i18n::t!("tray.direct"),
_ => clash_verge_i18n::t!("tray.rule"),
};
let outbound_modes_label = format!("{} ({})", texts.outbound_modes, current_mode_text);
Some(Submenu::with_id_and_items(
app_handle,
MenuIds::OUTBOUND_MODES,
outbound_modes_label.as_str(),
true,
&[
rule_mode as &dyn IsMenuItem<Wry>,
global_mode as &dyn IsMenuItem<Wry>,
direct_mode as &dyn IsMenuItem<Wry>,
],
)?)
};
let profiles = &Submenu::with_id_and_items(
app_handle,
@@ -851,8 +786,11 @@ async fn create_tray_menu(
let proxy_sub_menus =
create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data);
let (proxies_menu, inline_proxy_items) =
create_proxy_menu_item(app_handle, show_proxy_groups_inline, proxy_sub_menus, &texts.proxies)?;
let (proxies_menu, inline_proxy_items) = match tray_proxy_groups_display_mode {
"default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?,
"inline" => create_proxy_menu_item(app_handle, true, proxy_sub_menus, &texts.proxies)?,
_ => (None, Vec::new()),
};
let system_proxy = &CheckMenuItem::with_id(
app_handle,
@@ -946,15 +884,29 @@ async fn create_tray_menu(
let separator = &PredefinedMenuItem::separator(app_handle)?;
// 动态构建菜单项
let mut menu_items: Vec<&dyn IsMenuItem<Wry>> = vec![open_window, outbound_modes, separator, profiles];
let mut menu_items: Vec<&dyn IsMenuItem<Wry>> = vec![open_window, separator];
if show_outbound_modes_inline {
menu_items.extend_from_slice(&[
rule_mode as &dyn IsMenuItem<Wry>,
global_mode as &dyn IsMenuItem<Wry>,
direct_mode as &dyn IsMenuItem<Wry>,
]);
} else if let Some(ref outbound_modes) = outbound_modes {
menu_items.push(outbound_modes);
}
menu_items.extend_from_slice(&[separator, profiles]);
// 如果有代理节点,添加代理节点菜单
if show_proxy_groups_inline {
if !inline_proxy_items.is_empty() {
match tray_proxy_groups_display_mode {
"default" => {
menu_items.extend(proxies_menu.iter().map(|item| item as &dyn IsMenuItem<_>));
}
"inline" if !inline_proxy_items.is_empty() => {
menu_items.extend(inline_proxy_items.iter().map(|item| item.as_ref()));
}
} else if let Some(ref proxies_menu) = proxies_menu {
menu_items.push(proxies_menu);
_ => {}
}
menu_items.extend_from_slice(&[
@@ -984,10 +936,6 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
}
MenuIds::DASHBOARD => {
logging!(info, Type::Tray, "托盘菜单点击: 打开窗口");
if !should_handle_tray_click() {
return;
}
if !lightweight::exit_lightweight_mode().await {
WindowManager::show_main_window().await;
};
@@ -1005,7 +953,6 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
}
MenuIds::COPY_ENV => feat::copy_clash_env().await,
MenuIds::CONF_DIR => {
println!("Open directory submenu clicked");
let _ = cmd::open_app_dir().await;
}
MenuIds::CORE_DIR => {
@@ -1023,9 +970,6 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
MenuIds::RESTART_CLASH => feat::restart_clash_core().await,
MenuIds::RESTART_APP => feat::restart_app().await,
MenuIds::LIGHTWEIGHT_MODE => {
if !should_handle_tray_click() {
return;
}
if !is_in_lightweight_mode() {
lightweight::entry_lightweight_mode().await;
} else {
-1
View File
@@ -1 +0,0 @@
+3 -3
View File
@@ -3,14 +3,14 @@ use clash_verge_logging::{Type, logging};
use super::use_lowercase;
use serde_yaml_ng::{self, Mapping, Value};
fn deep_merge(a: &mut Value, b: &Value) {
fn deep_merge(a: &mut Value, b: Value) {
match (a, b) {
(&mut Value::Mapping(ref mut a), Value::Mapping(b)) => {
for (k, v) in b {
deep_merge(a.entry(k.clone()).or_insert(Value::Null), v);
}
}
(a, b) => *a = b.clone(),
(a, b) => *a = b,
}
}
@@ -18,7 +18,7 @@ pub fn use_merge(merge: &Mapping, config: Mapping) -> Mapping {
let mut config = Value::from(config);
let merge = use_lowercase(merge);
deep_merge(&mut config, &Value::from(merge));
deep_merge(&mut config, Value::from(merge));
config.as_mapping().cloned().unwrap_or_else(|| {
logging!(
+1 -1
View File
@@ -106,7 +106,7 @@ async fn get_config_values() -> ConfigValues {
ref verge_http_enabled,
ref enable_dns_settings,
..
} = **verge_arc;
} = *verge_arc;
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
Some(verge_arc.get_valid_clash_core()),
+134 -4
View File
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use serde_yaml_ng::{Mapping, Sequence, Value};
use std::collections::HashSet;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SeqMap {
@@ -8,6 +9,27 @@ pub struct SeqMap {
pub delete: Vec<String>,
}
fn collect_proxy_names(seq: &Sequence) -> Vec<String> {
seq.iter()
.filter_map(|item| match item {
Value::Mapping(map) => map.get("name").and_then(Value::as_str).map(str::to_owned),
Value::String(name) => Some(name.to_owned()),
_ => None,
})
.collect()
}
fn is_selector_group(group_map: &Mapping) -> bool {
group_map
.get("type")
.and_then(Value::as_str)
.map(|value| {
let value = value.to_ascii_lowercase();
value == "select" || value == "selector"
})
.unwrap_or(false)
}
pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
let SeqMap {
prepend,
@@ -15,6 +37,18 @@ pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
delete,
} = seq;
let added_proxy_names = if field == "proxies" {
let mut names = collect_proxy_names(&prepend);
names.extend(collect_proxy_names(&append));
let mut seen = HashSet::new();
names
.into_iter()
.filter(|name| seen.insert(name.clone()))
.collect::<Vec<String>>()
} else {
Vec::new()
};
let mut new_seq = Sequence::new();
new_seq.extend(prepend);
@@ -48,10 +82,11 @@ pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
&& let Some(Value::Sequence(groups)) = config.get_mut("proxy-groups")
{
let mut new_groups = Sequence::new();
let mut appended_to_selector = false;
for group in groups {
if let Value::Mapping(group_map) = group {
if let Some(Value::Sequence(proxies)) = group_map.get("proxies") {
let filtered_proxies: Sequence = proxies
let mut proxies_seq = group_map.get("proxies").and_then(Value::as_sequence).map(|proxies| {
proxies
.iter()
.filter(|p| {
if let Value::String(name) = p {
@@ -61,8 +96,33 @@ pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
}
})
.cloned()
.collect();
group_map.insert(Value::String("proxies".into()), Value::Sequence(filtered_proxies));
.collect::<Sequence>()
});
if !appended_to_selector && !added_proxy_names.is_empty() && is_selector_group(group_map) {
let base_seq = proxies_seq.unwrap_or_else(Sequence::new);
let mut seq = Sequence::new();
let mut existing = HashSet::new();
for name in &added_proxy_names {
if existing.insert(name.clone()) {
seq.push(Value::String(name.clone()));
}
}
for value in base_seq {
if let Value::String(name) = &value
&& !existing.insert(name.to_owned())
{
continue;
}
seq.push(value);
}
proxies_seq = Some(seq);
appended_to_selector = true;
}
if let Some(seq) = proxies_seq {
group_map.insert(Value::String("proxies".into()), Value::Sequence(seq));
}
new_groups.push(Value::Mapping(group_map.to_owned()));
} else {
@@ -158,4 +218,74 @@ proxy-groups:
);
assert_eq!(group2_proxies.len(), 0);
}
#[test]
#[allow(clippy::unwrap_used)]
#[allow(clippy::expect_used)]
fn test_add_new_proxies_to_first_selector_group() {
let config_str = r#"
proxies:
- name: "proxy1"
type: "ss"
proxy-groups:
- name: "group1"
type: "select"
proxies:
- "proxy1"
- name: "group2"
type: "select"
proxies:
- "proxy1"
"#;
let mut config: Mapping = serde_yaml_ng::from_str(config_str).expect("Failed to parse test config YAML");
let prepend: Sequence = serde_yaml_ng::from_str(
r#"
- name: "proxy3"
type: "ss"
"#,
)
.expect("Failed to parse prepend proxies");
let append: Sequence = serde_yaml_ng::from_str(
r#"
- name: "proxy4"
type: "vmess"
"#,
)
.expect("Failed to parse append proxies");
let seq = SeqMap {
prepend,
append,
delete: vec![],
};
config = use_seq(seq, config, "proxies");
let groups = config
.get("proxy-groups")
.expect("proxy-groups field should exist")
.as_sequence()
.expect("proxy-groups should be a sequence");
let group1_proxies = groups[0]
.as_mapping()
.expect("group should be a mapping")
.get("proxies")
.expect("group should have proxies")
.as_sequence()
.expect("group proxies should be a sequence");
let names: Vec<&str> = group1_proxies.iter().filter_map(Value::as_str).collect();
assert_eq!(names, vec!["proxy3", "proxy4", "proxy1"]);
let group2_proxies = groups[1]
.as_mapping()
.expect("group should be a mapping")
.get("proxies")
.expect("group should have proxies")
.as_sequence()
.expect("group proxies should be a sequence");
let names: Vec<&str> = group2_proxies.iter().filter_map(Value::as_str).collect();
assert_eq!(names, vec!["proxy1"]);
}
}
+63 -1
View File
@@ -1,5 +1,5 @@
use crate::{
config::{Config, IVerge},
config::{Config, IClashTemp, IProfiles, IVerge},
core::backup,
process::AsyncHandler,
utils::{
@@ -25,6 +25,7 @@ pub struct LocalBackupFile {
}
/// Load restored verge.yaml from disk, merge back WebDAV creds, save, and sync memory.
/// Also reload other restored configs so restarts won't overwrite them.
async fn finalize_restored_verge_config(
webdav_url: Option<String>,
webdav_username: Option<String>,
@@ -38,6 +39,20 @@ async fn finalize_restored_verge_config(
restored.webdav_password = webdav_password;
restored.save_file().await?;
let restored_clash = IClashTemp::new().await;
let clash_draft = Config::clash().await;
clash_draft.edit_draft(|d| {
*d = restored_clash.clone();
});
clash_draft.apply();
let restored_profiles = IProfiles::new().await;
let profiles_draft = Config::profiles().await;
profiles_draft.edit_draft(|d| {
*d = restored_profiles.clone();
});
profiles_draft.apply();
let verge_draft = Config::verge().await;
verge_draft.edit_draft(|d| {
*d = restored.clone();
@@ -158,6 +173,53 @@ where
Ok(final_name)
}
/// Import an existing backup file into the local backup directory
pub async fn import_local_backup(source: String) -> Result<String> {
let source_path = PathBuf::from(source.as_str());
if !source_path.exists() {
return Err(anyhow!("Backup file not found: {source}"));
}
if !source_path.is_file() {
return Err(anyhow!("Backup path is not a file: {source}"));
}
let ext = source_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.unwrap_or_default();
if ext != "zip" {
return Err(anyhow!("Only .zip backup files are supported"));
}
let file_name = source_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow!("Invalid backup file name"))?;
let backup_dir = local_backup_dir()?;
let target_path = backup_dir.join(file_name);
if target_path == source_path {
// Already located in the backup directory
return Ok(file_name.to_string().into());
}
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).await?;
}
if target_path.exists() {
return Err(anyhow!("Backup file already exists: {file_name}"));
}
fs::copy(&source_path, &target_path)
.await
.map_err(|err| anyhow!("Failed to import backup file: {err:#?}"))?;
Ok(file_name.to_string().into())
}
async fn move_file(from: PathBuf, to: PathBuf) -> Result<()> {
if let Some(parent) = to.parent() {
fs::create_dir_all(parent).await?;
+105 -79
View File
@@ -1,10 +1,11 @@
use crate::{
config::{Config, IVerge},
core::{CoreManager, handle, hotkey, sysopt, tray},
core::{CoreManager, handle, hotkey, logger::Logger, sysopt, tray},
module::{auto_backup::AutoBackupManager, lightweight},
};
use anyhow::Result;
use clash_verge_draft::SharedBox;
use bitflags::bitflags;
use clash_verge_draft::SharedDraft;
use clash_verge_logging::{Type, logging, logging_error};
use serde_yaml_ng::Mapping;
@@ -49,25 +50,31 @@ pub async fn patch_clash(patch: &Mapping) -> Result<()> {
}
// Define update flags as bitflags for better performance
#[derive(Clone, Copy)]
enum UpdateFlags {
None = 0,
RestartCore = 1 << 0,
ClashConfig = 1 << 1,
VergeConfig = 1 << 2,
Launch = 1 << 3,
SysProxy = 1 << 4,
SystrayIcon = 1 << 5,
Hotkey = 1 << 6,
SystrayMenu = 1 << 7,
SystrayTooltip = 1 << 8,
SystrayClickBehavior = 1 << 9,
LighteWeight = 1 << 10,
bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct UpdateFlags: u16 {
const RESTART_CORE = 1 << 0;
const CLASH_CONFIG = 1 << 1;
const VERGE_CONFIG = 1 << 2;
const LAUNCH = 1 << 3;
const SYS_PROXY = 1 << 4;
const SYSTRAY_ICON = 1 << 5;
const HOTKEY = 1 << 6;
const SYSTRAY_MENU = 1 << 7;
const SYSTRAY_TOOLTIP = 1 << 8;
const SYSTRAY_CLICK_BEHAVIOR = 1 << 9;
const LIGHT_WEIGHT = 1 << 10;
const LANGUAGE = 1 << 11;
const LOG_LEVEL = 1 << 12;
const LOG_FILE = 1 << 13;
const GROUP_SYS_TRAY = Self::SYSTRAY_MENU.bits()
| Self::SYSTRAY_TOOLTIP.bits()
| Self::SYSTRAY_ICON.bits();
}
}
fn determine_update_flags(patch: &IVerge) -> i32 {
let mut update_flags: i32 = UpdateFlags::None as i32;
fn determine_update_flags(patch: &IVerge) -> UpdateFlags {
let tun_mode = patch.enable_tun_mode;
let auto_launch = patch.enable_auto_launch;
let system_proxy = patch.enable_system_proxy;
@@ -99,145 +106,163 @@ fn determine_update_flags(patch: &IVerge) -> i32 {
// let enable_tray_icon = patch.enable_tray_icon;
let enable_global_hotkey = patch.enable_global_hotkey;
let tray_event = &patch.tray_event;
let home_cards = patch.home_cards.clone();
let home_cards = patch.home_cards.as_ref();
let enable_auto_light_weight = patch.enable_auto_light_weight_mode;
let enable_external_controller = patch.enable_external_controller;
let tray_inline_proxy_groups = patch.tray_inline_proxy_groups;
let tray_proxy_groups_display_mode = &patch.tray_proxy_groups_display_mode;
let tray_inline_outbound_modes = patch.tray_inline_outbound_modes;
let enable_proxy_guard = patch.enable_proxy_guard;
let proxy_guard_duration = patch.proxy_guard_duration;
let log_level = &patch.app_log_level;
let log_max_size = patch.app_log_max_size;
let log_max_count = patch.app_log_max_count;
if tun_mode.is_some() {
update_flags |= UpdateFlags::ClashConfig as i32;
update_flags |= UpdateFlags::SystrayMenu as i32;
update_flags |= UpdateFlags::SystrayTooltip as i32;
update_flags |= UpdateFlags::SystrayIcon as i32;
}
if enable_global_hotkey.is_some() || home_cards.is_some() {
update_flags |= UpdateFlags::VergeConfig as i32;
}
#[cfg(not(target_os = "windows"))]
if redir_enabled.is_some() || redir_port.is_some() {
update_flags |= UpdateFlags::RestartCore as i32;
}
#[cfg(target_os = "linux")]
if tproxy_enabled.is_some() || tproxy_port.is_some() {
update_flags |= UpdateFlags::RestartCore as i32;
}
if socks_enabled.is_some()
#[cfg(target_os = "windows")]
let restart_core_needed = socks_enabled.is_some()
|| http_enabled.is_some()
|| socks_port.is_some()
|| http_port.is_some()
|| mixed_port.is_some()
|| enable_external_controller.is_some();
#[cfg(not(target_os = "windows"))]
let mut restart_core_needed = socks_enabled.is_some()
|| http_enabled.is_some()
|| socks_port.is_some()
|| http_port.is_some()
|| mixed_port.is_some()
|| enable_external_controller.is_some();
#[cfg(not(target_os = "windows"))]
{
update_flags |= UpdateFlags::RestartCore as i32;
restart_core_needed |= redir_enabled.is_some() || redir_port.is_some();
}
#[cfg(target_os = "linux")]
{
restart_core_needed |= tproxy_enabled.is_some() || tproxy_port.is_some();
}
let mut update_flags = UpdateFlags::empty();
if restart_core_needed {
update_flags.insert(UpdateFlags::RESTART_CORE);
}
if tun_mode.is_some() {
update_flags.insert(UpdateFlags::CLASH_CONFIG | UpdateFlags::GROUP_SYS_TRAY);
}
if enable_global_hotkey.is_some() || home_cards.is_some() {
update_flags.insert(UpdateFlags::VERGE_CONFIG);
}
if auto_launch.is_some() {
update_flags |= UpdateFlags::Launch as i32;
update_flags.insert(UpdateFlags::LAUNCH);
}
if system_proxy.is_some() {
update_flags |= UpdateFlags::SysProxy as i32;
update_flags |= UpdateFlags::SystrayMenu as i32;
update_flags |= UpdateFlags::SystrayTooltip as i32;
update_flags |= UpdateFlags::SystrayIcon as i32;
update_flags.insert(UpdateFlags::SYS_PROXY | UpdateFlags::GROUP_SYS_TRAY);
}
if proxy_bypass.is_some()
|| pac_content.is_some()
|| pac.is_some()
|| enable_proxy_guard.is_some()
|| proxy_guard_duration.is_some()
{
update_flags |= UpdateFlags::SysProxy as i32;
update_flags.insert(UpdateFlags::SYS_PROXY);
}
if language.is_some() {
update_flags |= UpdateFlags::SystrayMenu as i32;
update_flags.insert(UpdateFlags::LANGUAGE | UpdateFlags::SYSTRAY_MENU | UpdateFlags::SYSTRAY_TOOLTIP);
}
if common_tray_icon.is_some()
|| sysproxy_tray_icon.is_some()
|| tun_tray_icon.is_some()
|| tray_icon.is_some()
|| enable_tray_speed.is_some()
// || enable_tray_icon.is_some()
{
update_flags |= UpdateFlags::SystrayIcon as i32;
update_flags.insert(UpdateFlags::SYSTRAY_ICON);
}
if patch.hotkeys.is_some() {
update_flags |= UpdateFlags::Hotkey as i32;
update_flags |= UpdateFlags::SystrayMenu as i32;
update_flags.insert(UpdateFlags::HOTKEY | UpdateFlags::SYSTRAY_MENU);
}
if tray_event.is_some() {
update_flags |= UpdateFlags::SystrayClickBehavior as i32;
update_flags.insert(UpdateFlags::SYSTRAY_CLICK_BEHAVIOR);
}
if enable_auto_light_weight.is_some() {
update_flags |= UpdateFlags::LighteWeight as i32;
update_flags.insert(UpdateFlags::LIGHT_WEIGHT);
}
if enable_external_controller.is_some() {
update_flags |= UpdateFlags::RestartCore as i32;
if tray_proxy_groups_display_mode.is_some() {
update_flags.insert(UpdateFlags::SYSTRAY_MENU);
}
if tray_inline_proxy_groups.is_some() {
update_flags |= UpdateFlags::SystrayMenu as i32;
if log_level.is_some() {
update_flags.insert(UpdateFlags::LOG_LEVEL);
}
if log_max_size.is_some() || log_max_count.is_some() {
update_flags.insert(UpdateFlags::LOG_FILE);
}
if tray_inline_outbound_modes.is_some() {
update_flags.insert(UpdateFlags::SYSTRAY_MENU);
}
update_flags
}
#[allow(clippy::cognitive_complexity)]
async fn process_terminated_flags(update_flags: i32, patch: &IVerge) -> Result<()> {
async fn process_terminated_flags(update_flags: UpdateFlags, patch: &IVerge) -> Result<()> {
// Process updates based on flags
if (update_flags & (UpdateFlags::RestartCore as i32)) != 0 {
if update_flags.contains(UpdateFlags::RESTART_CORE) {
Config::generate().await?;
CoreManager::global().restart_core().await?;
}
if (update_flags & (UpdateFlags::ClashConfig as i32)) != 0 {
if update_flags.contains(UpdateFlags::CLASH_CONFIG) {
CoreManager::global().update_config().await?;
handle::Handle::refresh_clash();
}
if (update_flags & (UpdateFlags::VergeConfig as i32)) != 0 {
if update_flags.contains(UpdateFlags::VERGE_CONFIG) {
Config::verge()
.await
.edit_draft(|d| d.enable_global_hotkey = patch.enable_global_hotkey);
handle::Handle::refresh_verge();
}
if (update_flags & (UpdateFlags::Launch as i32)) != 0 {
if update_flags.contains(UpdateFlags::LAUNCH) {
sysopt::Sysopt::global().update_launch().await?;
}
if (update_flags & (UpdateFlags::SysProxy as i32)) != 0 {
if update_flags.contains(UpdateFlags::LANGUAGE)
&& let Some(language) = &patch.language
{
clash_verge_i18n::set_locale(language.as_str());
}
if update_flags.contains(UpdateFlags::SYS_PROXY) {
sysopt::Sysopt::global().update_sysproxy().await?;
sysopt::Sysopt::global().refresh_guard().await;
}
if (update_flags & (UpdateFlags::Hotkey as i32)) != 0
if update_flags.contains(UpdateFlags::HOTKEY)
&& let Some(hotkeys) = &patch.hotkeys
{
hotkey::Hotkey::global().update(hotkeys.to_owned()).await?;
}
if (update_flags & (UpdateFlags::SystrayMenu as i32)) != 0 {
if update_flags.contains(UpdateFlags::SYSTRAY_MENU) {
tray::Tray::global().update_menu().await?;
}
if (update_flags & (UpdateFlags::SystrayIcon as i32)) != 0 {
if update_flags.contains(UpdateFlags::SYSTRAY_ICON) {
tray::Tray::global()
.update_icon(&Config::verge().await.latest_arc())
.await?;
}
if (update_flags & (UpdateFlags::SystrayTooltip as i32)) != 0 {
if update_flags.contains(UpdateFlags::SYSTRAY_TOOLTIP) {
tray::Tray::global().update_tooltip().await?;
}
if (update_flags & (UpdateFlags::SystrayClickBehavior as i32)) != 0 {
if update_flags.contains(UpdateFlags::SYSTRAY_CLICK_BEHAVIOR) {
tray::Tray::global().update_click_behavior().await?;
}
if (update_flags & (UpdateFlags::LighteWeight as i32)) != 0 {
if update_flags.contains(UpdateFlags::LIGHT_WEIGHT) {
if patch.enable_auto_light_weight_mode.unwrap_or(false) {
lightweight::enable_auto_light_weight_mode().await;
} else {
lightweight::disable_auto_light_weight_mode();
}
}
if update_flags.contains(UpdateFlags::LOG_LEVEL) {
Logger::global().update_log_level(patch.get_log_level())?;
}
if update_flags.contains(UpdateFlags::LOG_FILE) {
let log_max_size = patch.app_log_max_size.unwrap_or(128);
let log_max_count = patch.app_log_max_count.unwrap_or(8);
Logger::global().update_log_config(log_max_size, log_max_count).await?;
}
Ok(())
}
@@ -245,6 +270,7 @@ pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> {
Config::verge().await.edit_draft(|d| d.patch_config(patch));
let update_flags = determine_update_flags(patch);
logging!(debug, Type::Setup, "Determined update flags: {:?}", update_flags);
let process_flag_result: std::result::Result<(), anyhow::Error> = {
process_terminated_flags(update_flags, patch).await?;
Ok(())
@@ -259,13 +285,13 @@ pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> {
if !not_save_file {
// 分离数据获取和异步调用
let verge_data = Config::verge().await.data_arc();
logging!(info, Type::Setup, "Saving Verge configuration to file...");
logging!(debug, Type::Setup, "Saving Verge configuration to file...");
verge_data.save_file().await?;
}
Ok(())
}
pub async fn fetch_verge_config() -> Result<SharedBox<IVerge>> {
pub async fn fetch_verge_config() -> Result<SharedDraft<IVerge>> {
let draft = Config::verge().await;
let data = draft.data_arc();
Ok(data)
+6 -4
View File
@@ -19,7 +19,6 @@ use crate::{
use anyhow::Result;
use clash_verge_logging::{Type, logging};
use once_cell::sync::OnceCell;
use rust_i18n::i18n;
use std::time::Duration;
use tauri::{AppHandle, Manager as _};
#[cfg(target_os = "macos")]
@@ -27,8 +26,6 @@ use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_deep_link::DeepLinkExt as _;
use tauri_plugin_mihomo::RejectPolicy;
i18n!("locales", fallback = "zh");
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
/// Application initialization helper functions
mod app_init {
@@ -137,6 +134,7 @@ mod app_init {
tauri_plugin_clash_verge_sysinfo::commands::get_app_uptime,
tauri_plugin_clash_verge_sysinfo::commands::app_is_admin,
tauri_plugin_clash_verge_sysinfo::commands::export_diagnostic_info,
cmd::is_port_in_use,
cmd::get_sys_proxy,
cmd::get_auto_proxy,
cmd::open_app_dir,
@@ -210,6 +208,7 @@ mod app_init {
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,
@@ -227,6 +226,9 @@ pub fn run() {
return;
}
#[cfg(target_os = "linux")]
utils::linux::workarounds::apply_nvidia_dmabuf_renderer_workaround();
let _ = utils::dirs::init_portable_flag();
let builder = app_init::setup_plugins(tauri::Builder::default())
@@ -236,7 +238,7 @@ pub fn run() {
.set(app.app_handle().clone())
.expect("failed to set global app handle");
let _handle = resolve::init_work_dir_and_logger();
resolve::init_work_dir_and_logger()?;
logging!(info, Type::Setup, "开始应用初始化...");
if let Err(e) = app_init::setup_autostart(app) {
-7
View File
@@ -3,12 +3,5 @@ fn main() {
#[cfg(feature = "tokio-trace")]
console_subscriber::init();
// Check for --no-tray command line argument
#[cfg(target_os = "linux")]
if std::env::args().any(|x| x == "--no-tray") {
unsafe {
std::env::set_var("CLASH_VERGE_DISABLE_TRAY", "1");
}
}
app_lib::run();
}
-46
View File
@@ -1,17 +1,9 @@
#[cfg(feature = "tokio-trace")]
use std::any::type_name;
use std::future::Future;
#[cfg(feature = "tokio-trace")]
use std::panic::Location;
use tauri::{async_runtime, async_runtime::JoinHandle};
pub struct AsyncHandler;
impl AsyncHandler {
// pub fn handle() -> async_runtime::RuntimeHandle {
// async_runtime::handle()
// }
#[inline]
#[track_caller]
pub fn spawn<F, Fut>(f: F) -> JoinHandle<()>
@@ -19,8 +11,6 @@ impl AsyncHandler {
F: FnOnce() -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
#[cfg(feature = "tokio-trace")]
Self::log_task_info(&f);
async_runtime::spawn(f())
}
@@ -31,8 +21,6 @@ impl AsyncHandler {
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
#[cfg(feature = "tokio-trace")]
Self::log_task_info(&f);
async_runtime::spawn_blocking(f)
}
@@ -42,40 +30,6 @@ impl AsyncHandler {
where
Fut: Future + Send + 'static,
{
#[cfg(feature = "tokio-trace")]
Self::log_task_info(&fut);
async_runtime::block_on(fut)
}
#[cfg(feature = "tokio-trace")]
#[track_caller]
fn log_task_info<F>(f: &F)
where
F: ?Sized,
{
const TRACE_SPECIAL_SIZE: [usize; 3] = [0, 4, 24];
let size = std::mem::size_of_val(f);
if TRACE_SPECIAL_SIZE.contains(&size) {
return;
}
let location = Location::caller();
let type_str = type_name::<F>();
let size_str = format!("{} bytes", size);
let loc_str = format!("{}:{}:{}", location.file(), location.line(), location.column());
println!(
"┌────────────────────┬─────────────────────────────────────────────────────────────────────────────┐"
);
println!("│ {:<18} │ {:<80} │", "Field", "Value");
println!(
"├────────────────────┼─────────────────────────────────────────────────────────────────────────────┤"
);
println!("│ {:<18} │ {:<80} │", "Type of task", type_str);
println!("│ {:<18} │ {:<80} │", "Size of task", size_str);
println!("│ {:<18} │ {:<80} │", "Called from", loc_str);
println!(
"└────────────────────┴─────────────────────────────────────────────────────────────────────────────┘"
);
}
}
-132
View File
@@ -1,132 +0,0 @@
#[cfg(target_os = "windows")]
use anyhow::{Result, anyhow};
#[cfg(target_os = "windows")]
use clash_verge_logging::{Type, logging};
#[cfg(target_os = "windows")]
use std::{os::windows::process::CommandExt as _, path::Path, path::PathBuf};
/// Windows 下的开机启动文件夹路径
#[cfg(target_os = "windows")]
pub fn get_startup_dir() -> Result<PathBuf> {
let appdata = std::env::var("APPDATA").map_err(|_| anyhow!("无法获取 APPDATA 环境变量"))?;
let startup_dir = Path::new(&appdata)
.join("Microsoft")
.join("Windows")
.join("Start Menu")
.join("Programs")
.join("Startup");
if !startup_dir.exists() {
return Err(anyhow!("Startup 目录不存在: {:?}", startup_dir));
}
Ok(startup_dir)
}
/// 获取当前可执行文件路径
#[cfg(target_os = "windows")]
pub fn get_exe_path() -> Result<PathBuf> {
let exe_path = std::env::current_exe().map_err(|e| anyhow!("无法获取当前可执行文件路径: {}", e))?;
Ok(exe_path)
}
/// 创建快捷方式
#[cfg(target_os = "windows")]
pub async fn create_shortcut() -> Result<()> {
use crate::utils::dirs::PathBufExec as _;
let exe_path = get_exe_path()?;
let startup_dir = get_startup_dir()?;
let old_shortcut_path = startup_dir.join("Clash-Verge.lnk");
let new_shortcut_path = startup_dir.join("Clash Verge.lnk");
// 移除旧的快捷方式
let _ = old_shortcut_path
.remove_if_exists()
.await
.inspect(|_| {
logging!(info, Type::Setup, "成功移除旧启动快捷方式");
})
.inspect_err(|err| {
logging!(error, Type::Setup, "移除旧启动快捷方式失败: {err}");
});
// 如果新快捷方式已存在,直接返回成功
if new_shortcut_path.exists() {
logging!(info, Type::Setup, "启动快捷方式已存在");
return Ok(());
}
// 使用 PowerShell 创建快捷方式
let powershell_command = format!(
"$WshShell = New-Object -ComObject WScript.Shell; \
$Shortcut = $WshShell.CreateShortcut('{}'); \
$Shortcut.TargetPath = '{}'; \
$Shortcut.Save()",
new_shortcut_path.to_string_lossy().replace("\\", "\\\\"),
exe_path.to_string_lossy().replace("\\", "\\\\")
);
let output = std::process::Command::new("powershell")
.args(["-Command", &powershell_command])
// 隐藏 PowerShell 窗口
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.output()
.map_err(|e| anyhow!("执行 PowerShell 命令失败: {}", e))?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("创建快捷方式失败: {}", error_msg));
}
logging!(info, Type::Setup, "成功创建启动快捷方式");
Ok(())
}
/// 删除快捷方式
#[cfg(target_os = "windows")]
pub async fn remove_shortcut() -> Result<()> {
use crate::utils::dirs::PathBufExec as _;
let startup_dir = get_startup_dir()?;
let old_shortcut_path = startup_dir.join("Clash-Verge.lnk");
let new_shortcut_path = startup_dir.join("Clash Verge.lnk");
let mut removed_any = false;
let _ = old_shortcut_path
.remove_if_exists()
.await
.inspect(|_| {
logging!(info, Type::Setup, "成功删除旧启动快捷方式");
removed_any = true;
})
.inspect_err(|err| {
logging!(error, Type::Setup, "删除旧启动快捷方式失败: {err}");
});
let _ = new_shortcut_path
.remove_if_exists()
.await
.inspect(|_| {
logging!(info, Type::Setup, "成功删除启动快捷方式");
removed_any = true;
})
.inspect_err(|err| {
logging!(error, Type::Setup, "删除启动快捷方式失败: {err}");
});
Ok(())
}
/// 检查快捷方式是否存在
#[cfg(target_os = "windows")]
pub fn is_shortcut_enabled() -> Result<bool> {
let startup_dir = get_startup_dir()?;
let new_shortcut_path = startup_dir.join("Clash Verge.lnk");
Ok(new_shortcut_path.exists())
}
-26
View File
@@ -1,26 +0,0 @@
/// Format bytes into human readable string (B, KB, MB, GB)
#[allow(unused)]
pub fn fmt_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
let (mut val, mut unit) = (bytes as f64, 0);
while val >= 1024.0 && unit < 3 {
val /= 1024.0;
unit += 1;
}
format!("{:.1}{}", val, UNITS[unit])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fmt_bytes() {
assert_eq!(fmt_bytes(0), "0.0B");
assert_eq!(fmt_bytes(512), "512.0B");
assert_eq!(fmt_bytes(1024), "1.0KB");
assert_eq!(fmt_bytes(1536), "1.5KB");
assert_eq!(fmt_bytes(1024 * 1024), "1.0MB");
assert_eq!(fmt_bytes(1024 * 1024 * 1024), "1.0GB");
}
}
-15
View File
@@ -134,18 +134,3 @@ pub fn linux_elevator() -> String {
Err(_) => "sudo".to_string(),
}
}
/// return the string literal error
#[macro_export]
macro_rules! ret_err {
($str: expr) => {
return Err($str.into())
};
}
#[macro_export]
macro_rules! t {
($en:expr, $zh:expr, $use_zh:expr) => {
if $use_zh { $zh } else { $en }
};
}
-96
View File
@@ -1,96 +0,0 @@
use crate::config::Config;
use sys_locale;
const DEFAULT_LANGUAGE: &str = "zh";
fn supported_languages_internal() -> Vec<&'static str> {
rust_i18n::available_locales!()
}
const fn fallback_language() -> &'static str {
DEFAULT_LANGUAGE
}
fn locale_alias(locale: &str) -> Option<&'static str> {
match locale {
"ja" | "ja-jp" | "jp" => Some("jp"),
"zh" | "zh-cn" | "zh-hans" | "zh-sg" | "zh-my" | "zh-chs" => Some("zh"),
"zh-tw" | "zh-hk" | "zh-hant" | "zh-mo" | "zh-cht" => Some("zhtw"),
_ => None,
}
}
fn resolve_supported_language(language: &str) -> Option<String> {
if language.is_empty() {
return None;
}
let normalized = language.to_lowercase().replace('_', "-");
let mut candidates: Vec<String> = Vec::new();
let mut push_candidate = |candidate: String| {
if !candidate.is_empty()
&& !candidates
.iter()
.any(|existing| existing.eq_ignore_ascii_case(&candidate))
{
candidates.push(candidate);
}
};
let segments: Vec<&str> = normalized.split('-').collect();
for i in (1..=segments.len()).rev() {
let prefix = segments[..i].join("-");
if let Some(alias) = locale_alias(&prefix) {
push_candidate(alias.to_string());
}
push_candidate(prefix);
}
let supported = supported_languages_internal();
candidates
.into_iter()
.find(|candidate| supported.iter().any(|&lang| lang.eq_ignore_ascii_case(candidate)))
}
fn system_language() -> String {
sys_locale::get_locale()
.as_deref()
.and_then(resolve_supported_language)
.unwrap_or_else(|| fallback_language().to_string())
}
pub fn get_supported_languages() -> Vec<String> {
supported_languages_internal()
.into_iter()
.map(|lang| lang.to_string())
.collect()
}
pub fn set_locale(language: &str) {
let lang = resolve_supported_language(language).unwrap_or_else(|| fallback_language().to_string());
rust_i18n::set_locale(&lang);
}
pub async fn current_language() -> String {
Config::verge()
.await
.latest_arc()
.language
.as_ref()
.filter(|lang| !lang.is_empty())
.and_then(|lang| resolve_supported_language(lang))
.unwrap_or_else(system_language)
}
pub async fn sync_locale() -> String {
let language = current_language().await;
set_locale(&language);
language
}
pub const fn default_language() -> &'static str {
fallback_language()
}
+2 -121
View File
@@ -6,137 +6,18 @@ use crate::{
logging,
process::AsyncHandler,
utils::{
dirs::{self, PathBufExec as _, service_log_dir, sidecar_log_dir},
dirs::{self, PathBufExec as _},
help,
},
};
use anyhow::Result;
use chrono::{Local, TimeZone as _};
#[cfg(all(not(feature = "tauri-dev"), not(feature = "tracing-full")))]
use clash_verge_logging::NoModuleFilter;
use clash_verge_logging::Type;
use clash_verge_service_ipc::WriterConfig;
use flexi_logger::writers::FileLogWriter;
use flexi_logger::{Cleanup, Criterion, FileSpec};
#[cfg(not(feature = "tauri-dev"))]
use flexi_logger::{Duplicate, LogSpecBuilder, Logger, LoggerHandle};
use std::{path::PathBuf, str::FromStr as _};
use tauri_plugin_shell::ShellExt as _;
use tokio::fs;
use tokio::fs::DirEntry;
/// initialize this instance's log file
#[cfg(not(feature = "tauri-dev"))]
pub async fn init_logger() -> Result<LoggerHandle> {
// TODO 提供 runtime 级别实时修改
let (log_level, log_max_size, log_max_count) = {
let verge_guard = Config::verge().await;
let verge = verge_guard.data_arc();
(
verge.get_log_level(),
verge.app_log_max_size.unwrap_or(128),
verge.app_log_max_count.unwrap_or(8),
)
};
let log_dir = dirs::app_logs_dir()?;
let mut spec = LogSpecBuilder::new();
let level = std::env::var("RUST_LOG")
.ok()
.and_then(|v| log::LevelFilter::from_str(&v).ok())
.unwrap_or(log_level);
spec.default(level);
#[cfg(feature = "tracing")]
spec.module("tauri", log::LevelFilter::Debug)
.module("wry", log::LevelFilter::Off)
.module("tauri_plugin_mihomo", log::LevelFilter::Off);
let spec = spec.build();
let logger = Logger::with(spec)
.log_to_file(FileSpec::default().directory(log_dir).basename(""))
.duplicate_to_stdout(Duplicate::Debug)
.format(clash_verge_logger::console_format)
.format_for_files(clash_verge_logger::file_format_with_level)
.rotate(
Criterion::Size(log_max_size * 1024),
flexi_logger::Naming::TimestampsCustomFormat {
current_infix: Some("latest"),
format: "%Y-%m-%d_%H-%M-%S",
},
Cleanup::KeepLogFiles(log_max_count),
);
#[cfg(all(not(feature = "tracing"), not(feature = "tracing-full")))]
let logger = logger.filter(Box::new(NoModuleFilter(&[
"wry",
"tauri",
"tokio_tungstenite",
"tungstenite",
])));
#[cfg(feature = "tracing")]
let logger = logger.filter(Box::new(NoModuleFilter(&[
"wry",
"tauri_plugin_mihomo",
"tokio_tungstenite",
"tungstenite",
"kode_bridge",
])));
let handle = logger.start()?;
// TODO 全局 logger handle 控制
// GlobalLoggerProxy::global().set_inner(handle);
// TODO 提供前端设置等级,热更新等级
// logger.parse_new_spec(spec)
Ok(handle)
}
pub async fn sidecar_writer() -> Result<FileLogWriter> {
let (log_max_size, log_max_count) = {
let verge_guard = Config::verge().await;
let verge = verge_guard.data_arc();
(
verge.app_log_max_size.unwrap_or(128),
verge.app_log_max_count.unwrap_or(8),
)
};
let sidecar_log_dir = sidecar_log_dir()?;
Ok(FileLogWriter::builder(
FileSpec::default()
.directory(sidecar_log_dir)
.basename("sidecar")
.suppress_timestamp(),
)
.format(clash_verge_logger::file_format_without_level)
.rotate(
Criterion::Size(log_max_size * 1024),
flexi_logger::Naming::TimestampsCustomFormat {
current_infix: Some("latest"),
format: "%Y-%m-%d_%H-%M-%S",
},
Cleanup::KeepLogFiles(log_max_count),
)
.try_build()?)
}
pub async fn service_writer_config() -> Result<WriterConfig> {
let (log_max_size, log_max_count) = {
let verge_guard = Config::verge().await;
let verge = verge_guard.data_arc();
(
verge.app_log_max_size.unwrap_or(128),
verge.app_log_max_count.unwrap_or(8),
)
};
let service_log_dir = dirs::path_to_str(&service_log_dir()?)?.into();
Ok(WriterConfig {
directory: service_log_dir,
max_log_size: log_max_size * 1024,
max_log_files: log_max_count,
})
}
// TODO flexi_logger 提供了最大保留天数,或许我们应该用内置删除log文件
/// 删除log文件
pub async fn delete_log() -> Result<()> {
@@ -493,7 +374,7 @@ pub fn init_scheme() -> Result<()> {
}
}
crate::utils::linux::ensure_mimeapps_entries(DESKTOP_FILE, DEEP_LINK_SCHEMES)?;
crate::utils::linux::mime::ensure_mimeapps_entries(DESKTOP_FILE, DEEP_LINK_SCHEMES)?;
Ok(())
}
#[cfg(target_os = "macos")]
@@ -1,3 +1,11 @@
//! Utilities for working with freedesktop MIME / mimeapps.list.
//!
//! NOTE:
//! `mimeapps.list` is not a strict INI file.
//! We intentionally perform line-based, round-trip edits instead of using
//! an INI parser to preserve comments, ordering, duplicate keys and desktop
//! environment quirks.
use anyhow::Result;
use std::collections::HashMap;
use std::env;
+2
View File
@@ -0,0 +1,2 @@
pub mod mime;
pub mod workarounds;
+55
View File
@@ -0,0 +1,55 @@
//! Best-effort platform workarounds for known upstream issues.
//!
//! NOTE:
//! These helpers are not fixes and may stop working as environments change.
use clash_verge_logging::{Type, logging};
use std::{fs, path::Path};
pub fn apply_nvidia_dmabuf_renderer_workaround() {
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_some() {
return;
}
if has_nvidia_gpu() {
unsafe {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
logging!(
info,
Type::Setup,
"Detected NVIDIA GPU, set WEBKIT_DISABLE_DMABUF_RENDERER=1"
);
}
}
fn has_nvidia_gpu() -> bool {
if Path::new("/proc/driver/nvidia/version").exists()
|| Path::new("/sys/module/nvidia").exists()
|| Path::new("/sys/module/nvidia_drm").exists()
{
return true;
}
let Ok(entries) = fs::read_dir("/sys/class/drm") else {
return false;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if !name.starts_with("card") || name.contains('-') {
continue;
}
let vendor_path = entry.path().join("device/vendor");
let Ok(vendor) = fs::read_to_string(vendor_path) else {
continue;
};
if vendor.trim().eq_ignore_ascii_case("0x10de") {
return true;
}
}
false
}
+2 -3
View File
@@ -1,14 +1,13 @@
pub mod autostart;
pub mod dirs;
pub mod format;
pub mod help;
pub mod i18n;
pub mod init;
#[cfg(target_os = "linux")]
pub mod linux;
pub mod network;
pub mod notification;
pub mod resolve;
#[cfg(target_os = "windows")]
pub mod schtasks;
pub mod server;
pub mod singleton;
pub mod tmpl;
+9 -63
View File
@@ -6,10 +6,9 @@ use reqwest::{
header::{HeaderMap, HeaderValue, USER_AGENT},
};
use smartstring::alias::String;
use std::time::{Duration, Instant};
use std::time::Duration;
use sysproxy::Sysproxy;
use tauri::Url;
use tokio::sync::Mutex;
#[derive(Debug)]
pub struct HttpResponse {
@@ -43,13 +42,7 @@ pub enum ProxyType {
System,
}
pub struct NetworkManager {
self_proxy_client: Mutex<Option<Client>>,
system_proxy_client: Mutex<Option<Client>>,
no_proxy_client: Mutex<Option<Client>>,
last_connection_error: Mutex<Option<(Instant, String)>>,
connection_error_count: Mutex<usize>,
}
pub struct NetworkManager;
impl Default for NetworkManager {
fn default() -> Self {
@@ -58,44 +51,8 @@ impl Default for NetworkManager {
}
impl NetworkManager {
pub fn new() -> Self {
Self {
self_proxy_client: Mutex::new(None),
system_proxy_client: Mutex::new(None),
no_proxy_client: Mutex::new(None),
last_connection_error: Mutex::new(None),
connection_error_count: Mutex::new(0),
}
}
async fn record_connection_error(&self, error: &str) {
*self.last_connection_error.lock().await = Some((Instant::now(), error.into()));
let mut count = self.connection_error_count.lock().await;
*count += 1;
}
async fn should_reset_clients(&self) -> bool {
let count = *self.connection_error_count.lock().await;
if count > 5 {
return true;
}
if let Some((time, _)) = &*self.last_connection_error.lock().await
&& time.elapsed() < Duration::from_secs(30)
&& count > 2
{
return true;
}
false
}
pub async fn reset_clients(&self) {
*self.self_proxy_client.lock().await = None;
*self.system_proxy_client.lock().await = None;
*self.no_proxy_client.lock().await = None;
*self.connection_error_count.lock().await = 0;
pub const fn new() -> Self {
Self
}
fn build_client(
@@ -192,11 +149,7 @@ impl NetworkManager {
user_agent: Option<String>,
accept_invalid_certs: bool,
) -> Result<HttpResponse> {
if self.should_reset_clients().await {
self.reset_clients().await;
}
let parsed = Url::parse(url)?;
let mut parsed = Url::parse(url)?;
let mut extra_headers = HeaderMap::new();
if !parsed.username().is_empty()
@@ -207,19 +160,15 @@ impl NetworkManager {
extra_headers.insert("Authorization", HeaderValue::from_str(&format!("Basic {}", encoded))?);
}
let clean_url = {
let mut no_auth = parsed.clone();
no_auth.set_username("").ok();
no_auth.set_password(None).ok();
no_auth.to_string()
};
parsed.set_username("").ok();
parsed.set_password(None).ok();
// 创建请求
let client = self
.create_request(proxy_type, timeout_secs, user_agent, accept_invalid_certs)
.await?;
let mut request_builder = client.get(&clean_url);
let mut request_builder = client.get(parsed);
for (key, value) in extra_headers.iter() {
request_builder = request_builder.header(key, value);
@@ -228,18 +177,15 @@ impl NetworkManager {
let response = match request_builder.send().await {
Ok(resp) => resp,
Err(e) => {
self.record_connection_error(&format!("Request failed: {}", e)).await;
return Err(anyhow::anyhow!("Request failed: {}", e));
}
};
let status = response.status();
let headers = response.headers().clone();
let headers = response.headers().to_owned();
let body = match response.text().await {
Ok(text) => text.into(),
Err(e) => {
self.record_connection_error(&format!("Failed to read response body: {}", e))
.await;
return Err(anyhow::anyhow!("Failed to read response body: {}", e));
}
};
+31 -28
View File
@@ -1,4 +1,7 @@
use crate::{core::handle, utils::i18n};
use std::borrow::Cow;
use crate::core::handle;
use clash_verge_i18n;
use tauri_plugin_notification::NotificationExt as _;
pub enum NotificationEvent<'a> {
@@ -15,55 +18,55 @@ pub enum NotificationEvent<'a> {
AppHidden,
}
fn notify(title: &str, body: &str) {
fn notify(title: Cow<'_, str>, body: Cow<'_, str>) {
let app_handle = handle::Handle::app_handle();
app_handle.notification().builder().title(title).body(body).show().ok();
}
pub async fn notify_event<'a>(event: NotificationEvent<'a>) {
i18n::sync_locale().await;
match event {
NotificationEvent::DashboardToggled => {
let title = rust_i18n::t!("notifications.dashboardToggled.title").to_string();
let body = rust_i18n::t!("notifications.dashboardToggled.body").to_string();
notify(&title, &body);
let title = clash_verge_i18n::t!("notifications.dashboardToggled.title");
let body = clash_verge_i18n::t!("notifications.dashboardToggled.body");
notify(title, body);
}
NotificationEvent::ClashModeChanged { mode } => {
let title = rust_i18n::t!("notifications.clashModeChanged.title").to_string();
let body = rust_i18n::t!("notifications.clashModeChanged.body").replace("{mode}", mode);
notify(&title, &body);
let title = clash_verge_i18n::t!("notifications.clashModeChanged.title");
let body = clash_verge_i18n::t!("notifications.clashModeChanged.body")
.replace("{mode}", mode)
.into();
notify(title, body);
}
NotificationEvent::SystemProxyToggled => {
let title = rust_i18n::t!("notifications.systemProxyToggled.title").to_string();
let body = rust_i18n::t!("notifications.systemProxyToggled.body").to_string();
notify(&title, &body);
let title = clash_verge_i18n::t!("notifications.systemProxyToggled.title");
let body = clash_verge_i18n::t!("notifications.systemProxyToggled.body");
notify(title, body);
}
NotificationEvent::TunModeToggled => {
let title = rust_i18n::t!("notifications.tunModeToggled.title").to_string();
let body = rust_i18n::t!("notifications.tunModeToggled.body").to_string();
notify(&title, &body);
let title = clash_verge_i18n::t!("notifications.tunModeToggled.title");
let body = clash_verge_i18n::t!("notifications.tunModeToggled.body");
notify(title, body);
}
NotificationEvent::LightweightModeEntered => {
let title = rust_i18n::t!("notifications.lightweightModeEntered.title").to_string();
let body = rust_i18n::t!("notifications.lightweightModeEntered.body").to_string();
notify(&title, &body);
let title = clash_verge_i18n::t!("notifications.lightweightModeEntered.title");
let body = clash_verge_i18n::t!("notifications.lightweightModeEntered.body");
notify(title, body);
}
NotificationEvent::ProfilesReactivated => {
let title = rust_i18n::t!("notifications.profilesReactivated.title").to_string();
let body = rust_i18n::t!("notifications.profilesReactivated.body").to_string();
notify(&title, &body);
let title = clash_verge_i18n::t!("notifications.profilesReactivated.title");
let body = clash_verge_i18n::t!("notifications.profilesReactivated.body");
notify(title, body);
}
NotificationEvent::AppQuit => {
let title = rust_i18n::t!("notifications.appQuit.title").to_string();
let body = rust_i18n::t!("notifications.appQuit.body").to_string();
notify(&title, &body);
let title = clash_verge_i18n::t!("notifications.appQuit.title");
let body = clash_verge_i18n::t!("notifications.appQuit.body");
notify(title, body);
}
#[cfg(target_os = "macos")]
NotificationEvent::AppHidden => {
let title = rust_i18n::t!("notifications.appHidden.title").to_string();
let body = rust_i18n::t!("notifications.appHidden.body").to_string();
notify(&title, &body);
let title = clash_verge_i18n::t!("notifications.appHidden.title");
let body = clash_verge_i18n::t!("notifications.appHidden.body");
notify(title, body);
}
}
}
+5 -15
View File
@@ -1,13 +1,13 @@
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::Result;
use flexi_logger::LoggerHandle;
use crate::{
config::Config,
core::{
CoreManager, Timer, handle,
hotkey::Hotkey,
logger::Logger,
service::{SERVICE_MANAGER, ServiceManager, is_service_ipc_path_exists},
sysopt,
tray::Tray,
@@ -28,20 +28,13 @@ pub mod window_script;
static RESOLVE_DONE: AtomicBool = AtomicBool::new(false);
pub fn init_work_dir_and_logger() -> Option<LoggerHandle> {
pub fn init_work_dir_and_logger() -> anyhow::Result<()> {
AsyncHandler::block_on(async {
init_work_config().await;
init_resources().await;
#[cfg(not(feature = "tauri-dev"))]
{
logging!(info, Type::Setup, "Initializing logger");
init::init_logger().await.ok()
}
#[cfg(feature = "tauri-dev")]
{
None
}
logging!(info, Type::Setup, "Initializing logger");
Logger::global().init().await?;
Ok(())
})
}
@@ -154,9 +147,6 @@ pub async fn init_work_config() {
}
pub(super) async fn init_tray() {
if std::env::var("CLASH_VERGE_DISABLE_TRAY").unwrap_or_default() == "1" {
return;
}
logging_error!(Type::Setup, Tray::global().init().await);
}
+19 -2
View File
@@ -39,8 +39,25 @@ pub(super) async fn resolve_scheme(param: &str) -> Result<()> {
let url_param = if let Some(query) = link_parsed.query() {
let prefix = "url=";
if let Some(pos) = query.find(prefix) {
let raw_url = &query[pos + prefix.len()..];
Some(percent_decode_str(raw_url).decode_utf8_lossy().to_string())
let raw_url = query[pos + prefix.len()..].trim();
// Avoid double-decoding nested subscription URLs; decode only when needed.
let decoded = if Url::parse(raw_url).is_ok() {
raw_url.to_string()
} else {
let mut candidate = raw_url.to_string();
for _ in 0..2 {
let next = percent_decode_str(&candidate).decode_utf8_lossy().to_string();
if next == candidate {
break;
}
candidate = next;
if Url::parse(&candidate).is_ok() {
break;
}
}
candidate
};
Some(decoded)
} else {
None
}
+402
View File
@@ -0,0 +1,402 @@
use crate::utils::dirs::{self, PathBufExec as _};
use anyhow::{Result, anyhow};
use clash_verge_logging::{Type, logging};
use std::fs;
use std::os::windows::process::CommandExt as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use winapi::um::stringapiset::MultiByteToWideChar;
use winapi::um::winnls::{GetACP, GetOEMCP};
const CREATE_NO_WINDOW: u32 = 0x08000000;
const TASK_NAME_USER: &str = "Clash Verge";
const TASK_NAME_ADMIN: &str = "Clash Verge (Admin)";
const TASK_XML_DIR: &str = "tasks";
const TASK_XML_USER: &str = "clash-verge-task-user.xml";
const TASK_XML_ADMIN: &str = "clash-verge-task-admin.xml";
#[derive(Clone, Copy)]
pub enum TaskMode {
User,
Admin,
}
impl TaskMode {
const fn name(self) -> &'static str {
match self {
Self::User => TASK_NAME_USER,
Self::Admin => TASK_NAME_ADMIN,
}
}
const fn label(self) -> &'static str {
match self {
Self::User => "user",
Self::Admin => "admin",
}
}
const fn xml_run_level(self) -> &'static str {
match self {
Self::User => "LeastPrivilege",
Self::Admin => "HighestAvailable",
}
}
const fn xml_file_name(self) -> &'static str {
match self {
Self::User => TASK_XML_USER,
Self::Admin => TASK_XML_ADMIN,
}
}
}
fn get_exe_path() -> Result<PathBuf> {
let exe_path = std::env::current_exe().map_err(|e| anyhow!("failed to get exe path: {}", e))?;
Ok(exe_path)
}
fn get_task_user_id() -> Result<String> {
let username = std::env::var_os("USERNAME")
.or_else(|| std::env::var_os("USER"))
.ok_or_else(|| anyhow!("failed to get current user name"))?;
let username = username.to_string_lossy();
let username = username.trim();
if username.is_empty() {
return Err(anyhow!("current user name is empty"));
}
let domain = std::env::var_os("USERDOMAIN")
.or_else(|| std::env::var_os("COMPUTERNAME"))
.map(|value| value.to_string_lossy().to_string());
if let Some(domain) = domain {
let domain = domain.trim();
if !domain.is_empty() {
return Ok(format!("{domain}\\{username}"));
}
}
Ok(username.to_string())
}
fn get_startup_dir() -> Result<PathBuf> {
let appdata = std::env::var("APPDATA").map_err(|_| anyhow!("failed to read APPDATA env var"))?;
let startup_dir = Path::new(&appdata)
.join("Microsoft")
.join("Windows")
.join("Start Menu")
.join("Programs")
.join("Startup");
if !startup_dir.exists() {
return Err(anyhow!("startup folder does not exist: {:?}", startup_dir));
}
Ok(startup_dir)
}
async fn cleanup_legacy_shortcuts() -> Result<()> {
let startup_dir = get_startup_dir()?;
let old_shortcut = startup_dir.join("Clash-Verge.lnk");
let new_shortcut = startup_dir.join("Clash Verge.lnk");
old_shortcut.remove_if_exists().await?;
new_shortcut.remove_if_exists().await?;
Ok(())
}
fn task_xml_path(mode: TaskMode) -> Result<PathBuf> {
let dir = dirs::app_home_dir()?.join(TASK_XML_DIR);
fs::create_dir_all(&dir).map_err(|e| anyhow!("failed to create task xml dir: {}", e))?;
Ok(dir.join(mode.xml_file_name()))
}
fn xml_escape(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
_ => escaped.push(ch),
}
}
escaped
}
fn build_task_xml(mode: TaskMode) -> Result<String> {
let exe_path = get_exe_path()?.to_string_lossy().to_string();
let exe_path = xml_escape(&exe_path);
let user_id = xml_escape(&get_task_user_id()?);
Ok(format!(
r#"<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<UserId>{}</UserId>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>{}</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>{}</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>Parallel</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>false</AllowHardTerminate>
<StartWhenAvailable>false</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<IdleSettings>
<StopOnIdleEnd>false</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>3</Priority>
</Settings>
<Actions Context="Author">
<Exec>
<Command>{}</Command>
</Exec>
</Actions>
</Task>
"#,
user_id,
user_id,
mode.xml_run_level(),
exe_path
))
}
fn encode_utf16le_with_bom(content: &str) -> Vec<u8> {
let mut bytes = Vec::with_capacity(2 + content.len() * 2);
bytes.extend_from_slice(&[0xFF, 0xFE]);
for unit in content.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
bytes
}
fn write_task_xml(mode: TaskMode) -> Result<PathBuf> {
let task_xml = build_task_xml(mode)?;
let task_xml_path = task_xml_path(mode)?;
let encoded = encode_utf16le_with_bom(&task_xml);
fs::write(&task_xml_path, encoded).map_err(|e| anyhow!("failed to write task xml: {}", e))?;
Ok(task_xml_path)
}
fn decode_with_code_page(bytes: &[u8], code_page: u32) -> Option<String> {
if bytes.is_empty() {
return Some(String::new());
}
let len = bytes.len();
if len > i32::MAX as usize {
return None;
}
let required = unsafe {
MultiByteToWideChar(
code_page,
0,
bytes.as_ptr() as *const i8,
len as i32,
std::ptr::null_mut(),
0,
)
};
if required == 0 {
return None;
}
let mut wide = vec![0u16; required as usize];
let written = unsafe {
MultiByteToWideChar(
code_page,
0,
bytes.as_ptr() as *const i8,
len as i32,
wide.as_mut_ptr(),
required,
)
};
if written == 0 {
return None;
}
wide.truncate(written as usize);
Some(String::from_utf16_lossy(&wide))
}
fn decode_console_output(bytes: &[u8]) -> String {
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_string();
}
let oem = unsafe { GetOEMCP() };
if let Some(text) = decode_with_code_page(bytes, oem) {
return text;
}
let acp = unsafe { GetACP() };
if let Some(text) = decode_with_code_page(bytes, acp) {
return text;
}
String::from_utf8_lossy(bytes).to_string()
}
fn output_message(output: &Output) -> String {
let stdout = decode_console_output(&output.stdout);
let stderr = decode_console_output(&output.stderr);
let stdout = stdout.trim();
let stderr = stderr.trim();
match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => "unknown error".to_string(),
(false, true) => stdout.to_string(),
(true, false) => stderr.to_string(),
(false, false) => format!("{stdout} | {stderr}"),
}
}
fn schtasks_output(mut cmd: Command) -> Result<Output> {
cmd.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| anyhow!("failed to execute schtasks: {}", e))
}
pub fn is_task_enabled(mode: TaskMode) -> Result<bool> {
let output = schtasks_output({
let mut cmd = Command::new("schtasks");
cmd.args(["/Query", "/TN", mode.name()]);
cmd
})?;
Ok(output.status.success())
}
pub fn create_task(mode: TaskMode) -> Result<()> {
let task_xml_path = write_task_xml(mode)?;
let output = schtasks_output({
let mut cmd = Command::new("schtasks");
cmd.args(["/Create", "/TN", mode.name(), "/XML"]);
cmd.arg(&task_xml_path);
cmd.arg("/F");
cmd
})?;
if !output.status.success() {
return Err(anyhow!(
"failed to create {} task: {}",
mode.label(),
output_message(&output)
));
}
logging!(info, Type::Setup, "Created {} auto-launch task", mode.label());
Ok(())
}
pub fn remove_task(mode: TaskMode) -> Result<()> {
let output = schtasks_output({
let mut cmd = Command::new("schtasks");
cmd.args(["/Delete", "/TN", mode.name(), "/F"]);
cmd
})?;
if output.status.success() {
logging!(info, Type::Setup, "Removed {} auto-launch task", mode.label());
return Ok(());
}
if !is_task_enabled(mode)? {
logging!(
info,
Type::Setup,
"{} auto-launch task not found, skipping removal",
mode.label()
);
return Ok(());
}
Err(anyhow!(
"failed to remove {} task: {}",
mode.label(),
output_message(&output)
))
}
pub async fn set_auto_launch(is_enable: bool, is_admin: bool) -> Result<()> {
let target = if is_admin { TaskMode::Admin } else { TaskMode::User };
let other = if is_admin { TaskMode::User } else { TaskMode::Admin };
if let Err(err) = cleanup_legacy_shortcuts().await {
logging!(warn, Type::Setup, "Failed to cleanup legacy startup shortcuts: {}", err);
}
if is_enable {
if is_admin {
create_task(target)?;
if let Err(err) = remove_task(other) {
let _ = remove_task(target);
return Err(err);
}
} else {
if is_task_enabled(other)? {
return Err(anyhow!(
"admin auto-launch task exists; run the app as administrator to remove it before creating a user task"
));
}
create_task(target)?;
}
return Ok(());
}
if is_admin {
let mut errors = Vec::new();
if let Err(err) = remove_task(TaskMode::User) {
errors.push(err);
}
if let Err(err) = remove_task(TaskMode::Admin) {
errors.push(err);
}
if let Some(err) = errors.into_iter().next() {
return Err(err);
}
return Ok(());
}
remove_task(TaskMode::User)?;
if is_task_enabled(TaskMode::Admin)? {
return Err(anyhow!(
"admin auto-launch task exists; run the app as administrator to remove it"
));
}
Ok(())
}
pub fn is_auto_launch_enabled() -> Result<bool> {
if is_task_enabled(TaskMode::Admin)? {
return Ok(true);
}
is_task_enabled(TaskMode::User)
}
+2 -2
View File
@@ -1,5 +1,6 @@
use super::resolve;
use crate::{
cmd::is_port_in_use,
config::{Config, DEFAULT_PAC, IVerge},
module::lightweight,
process::AsyncHandler,
@@ -9,7 +10,6 @@ use anyhow::{Result, bail};
use clash_verge_logging::{Type, logging, logging_error};
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use port_scanner::local_port_available;
use reqwest::ClientBuilder;
use smartstring::alias::String;
use std::time::Duration;
@@ -27,7 +27,7 @@ static SHUTDOWN_SENDER: OnceCell<Mutex<Option<oneshot::Sender<()>>>> = OnceCell:
/// check whether there is already exists
pub async fn check_singleton() -> Result<()> {
let port = IVerge::get_singleton_port();
if !local_port_available(port) {
if is_port_in_use(port) {
let client = ClientBuilder::new().timeout(Duration::from_millis(500)).build()?;
// 需要确保 Send
#[allow(clippy::needless_collect)]
+19 -64
View File
@@ -1,17 +1,12 @@
use crate::{core::handle, utils::resolve::window::build_new_window};
use clash_verge_logging::{Type, logging};
use std::future::Future;
use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
use once_cell::sync::Lazy;
use std::num::NonZeroU32;
use std::pin::Pin;
use std::time::Duration;
use tauri::{Manager as _, WebviewWindow, Wry};
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use scopeguard;
use std::{
sync::atomic::{AtomicBool, Ordering},
time::{Duration, Instant},
};
/// 窗口操作结果
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum WindowOperationResult {
@@ -45,53 +40,22 @@ pub enum WindowState {
}
// 窗口操作防抖机制
static WINDOW_OPERATION_DEBOUNCE: OnceCell<Mutex<Instant>> = OnceCell::new();
static WINDOW_OPERATION_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
const WINDOW_OPERATION_DEBOUNCE_MS: u64 = 500;
fn get_window_operation_debounce() -> &'static Mutex<Instant> {
WINDOW_OPERATION_DEBOUNCE.get_or_init(|| Mutex::new(Instant::now() - Duration::from_secs(1)))
}
const WINDOW_OPERATION_DEBOUNCE_MS: u64 = 1_275;
static WINDOW_OPERATION_LIMITER: Lazy<DefaultDirectRateLimiter> = Lazy::new(|| {
#[allow(clippy::unwrap_used)]
RateLimiter::direct(
Quota::with_period(Duration::from_millis(WINDOW_OPERATION_DEBOUNCE_MS))
.unwrap()
.allow_burst(NonZeroU32::new(1).unwrap()),
)
});
fn should_handle_window_operation() -> bool {
if WINDOW_OPERATION_IN_PROGRESS.load(Ordering::Acquire) {
logging!(warn, Type::Window, "Warning: [防抖] 窗口操作已在进行中,跳过重复调用");
return false;
let res = WINDOW_OPERATION_LIMITER.check().is_ok();
if !res {
logging!(debug, Type::Window, "window operation rate limited");
}
let debounce_lock = get_window_operation_debounce();
let mut last_operation = debounce_lock.lock();
let now = Instant::now();
let elapsed = now.duration_since(*last_operation);
logging!(
debug,
Type::Window,
"[防抖] 检查窗口操作间隔: {}ms (需要>={}ms)",
elapsed.as_millis(),
WINDOW_OPERATION_DEBOUNCE_MS
);
if elapsed >= Duration::from_millis(WINDOW_OPERATION_DEBOUNCE_MS) {
*last_operation = now;
drop(last_operation);
WINDOW_OPERATION_IN_PROGRESS.store(true, Ordering::Release);
logging!(info, Type::Window, "[防抖] 窗口操作被允许执行");
true
} else {
logging!(
warn,
Type::Window,
"Warning: [防抖] 窗口操作被防抖机制忽略,距离上次操作 {}ms < {}ms",
elapsed.as_millis(),
WINDOW_OPERATION_DEBOUNCE_MS
);
false
}
}
fn finish_window_operation() {
WINDOW_OPERATION_IN_PROGRESS.store(false, Ordering::Release);
res
}
/// 统一的窗口管理器
@@ -135,9 +99,6 @@ impl WindowManager {
if !should_handle_window_operation() {
return WindowOperationResult::NoAction;
}
let _guard = scopeguard::guard((), |_| {
finish_window_operation();
});
logging!(info, Type::Window, "开始智能显示主窗口");
logging!(debug, Type::Window, "{}", Self::get_window_status_info());
@@ -149,7 +110,7 @@ impl WindowManager {
logging!(info, Type::Window, "窗口不存在,创建新窗口");
if Self::create_window(true).await {
logging!(info, Type::Window, "窗口创建成功");
std::thread::sleep(std::time::Duration::from_millis(100));
std::thread::sleep(std::time::Duration::from_millis(50));
WindowOperationResult::Created
} else {
logging!(warn, Type::Window, "窗口创建失败");
@@ -180,11 +141,7 @@ impl WindowManager {
// 防抖检查
if !should_handle_window_operation() {
return WindowOperationResult::NoAction;
}
let _guard = scopeguard::guard((), |_| {
finish_window_operation();
});
};
logging!(info, Type::Window, "开始切换主窗口显示状态");
let current_state = Self::get_main_window_state();
@@ -346,8 +303,6 @@ impl WindowManager {
return false;
}
handle::Handle::global().mark_startup_completed();
true
})
}

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