Compare commits

...
Author SHA1 Message Date
AetherWingandSlinetrac e1d914e61d Chore(i18n): Improve Chinese–English typesetting (#6351)
* chore(i18n): add spacing after "TUN"

* docs: Changelog

* chore(i18n): Improve Chinese–English typesetting

* Apply suggestions from code review

* chore: i18n

---------

Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-02-23 05:17:23 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Slinetrac
6dba62a3b4 chore(deps): lock file maintenance (#6348)
* chore(deps): lock file maintenance

* chore(deps): pnpm update

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-02-23 01:52:41 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Slinetrac
acab77a1b4 chore(deps): lock file maintenance (#6347)
* chore(deps): lock file maintenance

* chore(deps): update Cargo.toml

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-02-23 01:52:27 +00:00
Slinetrac 1bf445ddcc fix(tun): avoid service IPC startup race on Windows (#6340)
* fix(tun): avoid service IPC startup race on Windows

* docs: Changelog.md

* style: prettier
2026-02-22 13:09:47 +00:00
wonfen 700011688b feat: mask all URLs embedded in an error/log string for safe logging 2026-02-22 07:37:40 +08:00
oomeowandTunglies 0cc9bb2f30 refactor: mihomo ipc (#6312)
* refactor: mihomo ipc

* feat: enable tauri-plugin-mihomo log

* chore: filter tauri ipc modules log

* chore: update tauri-plugin-mihomo dep

* chore: bump tauri-plugin-mihomo version to 0.1.6

---------

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-02-21 18:36:49 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 7d29c0c6ee chore(deps): lock file maintenance (#6318)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-21 17:55:26 +00:00
wonfen 321017413d chore: replace node test with cp.cloudflare.com 2026-02-21 14:28:27 +08:00
wonfen 7f045943e2 chore: update changelog 2026-02-21 04:23:03 +08:00
wonfen fa07dfbc9a feat: mask sensitive parts of a subscription URL for safe logging 2026-02-21 04:19:46 +08:00
Tunglies 119aaee546 refactor: optimize profile change notifications to use references 2026-02-20 17:58:04 +08:00
Tunglies 5f573ca2d6 refactor: simplfy backend notify message to frontend (#6323) 2026-02-20 03:18:51 +00:00
Tunglies e5dd127bcc feat: enhance profile update handling with manual trigger 2026-02-20 11:12:18 +08:00
Tunglies 7528c238c4 chore: bump version to 2.4.7 2026-02-20 10:58:32 +08:00
wonfen 10601e873e fix: telegram release link 2026-02-20 05:46:59 +08:00
wonfen d77d655897 Release 2.4.6 2026-02-20 02:58:23 +08:00
wonfen ec6f259794 chore: replace node test with 1.0.0.1 2026-02-19 11:55:09 +08:00
Slinetrac 7a564b4ea9 fix(linux): handle empty KDE proxy schema without error (#6316)
* fix: kde sysproxy error

* docs: Changelog.md
2026-02-15 12:10:34 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> cd4ff68b5b chore(deps): update rust crate zip to v8 (#6314)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-15 03:34:31 +00:00
Slinetrac 1927b5f957 build(linux): bundle linux service binaries from sidecar (#6311)
* refactor: prebuild

* style: prettier
2026-02-15 00:37:41 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 58047cbbd1 chore(deps): update dependency react-error-boundary to v6.1.1 (#6309)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-14 02:06:10 +00:00
wonfen ddf455508f chore: replace node test with 104.16.132.229 2026-02-14 04:41:14 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> afde2f34f4 chore(deps): update dependency @types/react to v19.2.14 (#6305)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-13 11:34:17 +00:00
wonfen 9f6eb46e90 chore: update changelog 2026-02-12 14:55:39 +08:00
wonfen cf9f235270 chore: replace node test with 8888 2026-02-12 14:52:36 +08:00
Jelipo 44851466cf fix: add margin and border radius to group svg image (#6300) 2026-02-12 04:24:30 +00:00
AetherWingandSlinetrac 847a0a6afd fix(tray): resolve tray open log failed (#6301)
* fix(tray): resolve tray open log failed

* fix(ci): allow missing_const_for_fn

* fix(windows): run snapshot log cleanup unconditionally and cover all temp dirs

* refactor: simplify snapshot_path

* fix: clippy

* refactor: delete_snapshot_logs

---------

Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-02-12 03:53:36 +00:00
wonfen 81c56d46c1 perf: optimize IP info card 2026-02-12 08:49:41 +08:00
Sline 31c0910919 feat: Masque (#6303) 2026-02-11 12:38:44 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 87f55cfce7 chore(deps): update dependency node to v24.13.1 (#6299)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-11 06:41:24 +00:00
JingxinXuandSlinetrac bba71aaa4c feat: added clear btn in filter component (#6229) (#6295)
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-02-11 06:40:47 +00:00
Sline a019b26ceb fix: types (#6298) 2026-02-11 05:48:31 +00:00
wonfen a4617d1fed feat: add clear ingress/egress and data flow indicators for proxy chain 2026-02-11 04:41:46 +08:00
Sline 4d72d2d0df refactor: replace winapi w/ windows (#6291) 2026-02-10 06:06:27 +00:00
許景欣andSlinetrac 277ded4c44 fix: displays rule lineNo instead of index in rule page (#6230) (#6286)
* fix: displays rule lineNo instead of index in rule page (#6230)

* fix(rules): derive line numbers in view model without mutating shared rules

* doc: update changelog

---------

Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-02-10 05:11:14 +00:00
yuanyan3060 5f9096dd6e perf: update_tooltip (#6283) 2026-02-10 03:14:49 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Slinetrac
410b5bd317 chore(deps): lock file maintenance cargo dependencies (#6281)
* chore(deps): lock file maintenance cargo dependencies

* chore(deps): update Cargo.toml

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-02-09 07:37:23 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Slinetrac
a7041657c9 chore(deps): lock file maintenance npm dependencies (#6282)
* chore(deps): lock file maintenance npm dependencies

* chore(deps): run pnpm update

* chore: add parserOptions

* refactor: fix @eslint-react/no-implicit-key

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-02-09 07:37:07 +00:00
Tunglies 88764d763c refactor(tray): simplify tray event handling and improve menu event processing (#6278)
* refactor(tray): simplify tray event handling and improve menu event processing

* refactor(tray): enhance error handling in tray menu and icon updates

* refactor(tray): enhance tray icon event handling and add debounce for click events

* fix: remove duplicated set tooltip

* refactor(tray): simplify tray icon event handling by removing redundant parameters
2026-02-08 13:05:07 +00:00
Sline 8edfbbb1c6 fix(scheme): auto refresh core config on first URL scheme subscription (#6277)
* fix(scheme): auto refresh core config on first URL scheme subscription

* docs: Changelog.md
2026-02-08 06:34:20 +00:00
Sline 7730cd1c5b refactor: fix eslint no-useless-assignment preserve-caught-error (#6276) 2026-02-08 05:19:41 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> cad1c983e1 chore(deps): update npm dependencies to v10 (#6273)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-07 08:15:04 +00:00
TungliesandSukka 5480e57e67 feat: allow pass user-agent for IP detection (#6272)
* feat: allow pass user-agent when lookup ip API

* Update src/services/api.ts

Co-authored-by: Sukka <isukkaw@gmail.com>

* refactor: optimize user-agent retrieval with memoization

---------

Co-authored-by: Sukka <isukkaw@gmail.com>
2026-02-07 08:11:47 +00:00
Tunglies 5bcb057bf9 style: fix prettier 2026-02-07 16:15:43 +08:00
Tunglies c30eaa3678 refactor: reduce webview lock contention (#6271)
* refactor: replace handle::Handle::get_window() with WindowManager::get_main_window() in multiple files

* refactor: enhance WindowManager to return window state alongside the main window instance

* refactor: update useProfiles and ProfilePage to support profile overrides and improve patchProfilesConfig return type

* refactor: enhance handle_success to check main window existence before notifying profile changes

* refactor: simplify get_main_window_with_state by using pattern matching and improve window state handling

* refactor: fix window activation by removing unnecessary reference in activate_existing_main_window

* refactor: remove redundant macOS conditional for window_manager import
2026-02-07 05:45:15 +00:00
Tunglies 6c6e0812b8 chore: bump tauri-plugin-mihomo to 0.1.4 2026-02-07 08:11:24 +08:00
wonfen afa591c279 chore: replace node test with 1.1.1.1 2026-02-07 04:21:01 +08:00
SukkaandCopilot e9d63aee5e perf(ip-info-card): reduce IP API load even more (#6263)
* refactor(ip-info-card): reduce retry, use succeed as lastFetchTs

* refactor(ip-info-card): stop countdown during revalidation

* perf(ip-info-card): avoid aggressive schedule revalidation

* perf(ip-info-card): try stop interval on window minimized

* perf(ip-info-card): only mutate after card scroll into view once

* perf(ip-info-card): interval only when card has been visible

* chore: add more debug information

* Update src/components/home/ip-info-card.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: reset countdown state after mutate finishes

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 09:09:36 +00:00
Tunglies 781313e8f0 fix: avoid register logger when enable tracing feature 2026-02-05 17:15:05 +08:00
Tunglies c3f7ff7aa2 feat: add debug-release profile and limit signal runtime thread to one 2026-02-05 13:53:58 +08:00
Tunglies 5397808f16 chore: bump pnpm packages version 2026-02-05 11:17:05 +08:00
Tunglies 279836151c chore: bump tauri version to 2.10.1 2026-02-05 11:13:35 +08:00
AetherWing 6f424ebd2b fix(home-ui): align signal icon delay states with formatDelay (#6249)
* fix(ui): align signal icon delay states with formatDelay

* docs: changelog.md
2026-02-04 12:11:11 +00:00
Tunglies c7f5bc4e0d perf(profiles): optimize item removal by uid in profiles management 2026-02-04 12:57:59 +08:00
SukkaandCopilot 90e193099f refactor(ip-info-card): make ip info card much better (#6226)
* perf(ip-info-card): make ip info card much better

* fix(ip-info-card): remove unused useEffect deps

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor(ip-info-card): use `async-retry`, bail out non-2XX resp

* feat(ip-info-card): add new backend

* feat(ip-info-card): only revalidate when window is visible

* perf(ip-info-card): reduce re-renders when window is hidden

* fix(ip-info-card): remove `mutate` from `useEffect` arg

* Update src/components/home/ip-info-card.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: drop AbortSignal.timeout for old safati compat

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-04 01:40:13 +00:00
Tunglies b3a1fb8d23 fix(macos): update DNS to use 114.114.114.114 for TUN overwrite (#6250) 2026-02-04 01:27:33 +00:00
Tunglies a8e51cc6bb fix: improve subscription URL handling by fixing query parameter parsing 2026-02-03 10:26:50 +08:00
Tunglies 53867bc3a9 fix: enhance error reporting during service installation (#6161)
* fix: enhance error reporting during service installation

* fix: correct variable name in install_service function for clarity

* fix(windows): improve output handling in install_service function
2026-01-31 10:09:50 +00:00
Tunglies ae5d3c478a fix: resolve issue with tray operations after system resume (#6216)
* feat(limiter): add Limiter struct with clock interface and tests

* feat(limiter): integrate Limiter into tray and window management for rate limiting

* fix(tray, window_manager): update debounce timing for tray click and window operations

* refactor(limiter): change time representation from u64 to u128 for improved precision

* fix: resolve issue with tray operations after system resume

* Revert "refactor(limiter): change time representation from u64 to u128 for improved precision"

This reverts commit 2198f40f7f.
2026-01-31 09:23:20 +00:00
Tunglies 654152391b fix: unexpected clippy error 2026-01-31 16:37:51 +08:00
Tunglies 63a77b1c7d fix(macos): prioritize network interfaces for reliable proxy setup 2026-01-30 08:53:42 +08:00
Tunglies 9a0703676b fix: update IPC version, improve service IPC handling 2026-01-29 23:54:58 +08:00
Tunglies 95281632a1 fix(tray): correct spelling of 'TrayMenu' in TrayAction enum and usage 2026-01-29 19:44:24 +08:00
b17dd39f31 feat(tunnels): add tunnels viewer UI with add/delete support (#6052)
* feat(settings): add tunnels viewer UI and management logic

* docs: Changelog.md

* refactor(notice): remove redundant t() call

* refactor(tunnels): make proxy optional and follow current configuration by default

* refactor(tunnels): save after close dialog

* feat(tunnel): update check

* refactor(tunnels): merge import

* refactor(tunnels): use ipaddr.js

* docs: Changelog.md

* refactor(tunnels): enhance validation

* feat: add tunnels ti PATCH_CONFIG_INNER

* fix: sanitize invalid proxy references in tunnels

* refactor: use smartstring alias String

* docs: Changelog.md

* perf: optimize tunnels proxy validation and collection logic

---------

Co-authored-by: Slinetrac <realakayuki@gmail.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-01-29 09:32:37 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 1af326cefc chore(deps): update dependency @actions/github to v9 (#6200)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-28 03:17:16 +00:00
Tunglies 5103868119 perf: separate Clone implementation for Draft struct and remove trait bound from struct definition
Improve around 2% - 16% CPU performance
2026-01-28 10:28:26 +08:00
Tunglies c57a962109 refactor: replace useSWR with custom hooks for update and network interfaces (#6195) 2026-01-27 12:52:20 +00:00
Tunglies 36926df26c refactor: remove SWR_REALTIME configuration and simplify SWR usage in AppDataProvider 2026-01-27 20:07:48 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 9d81a13c58 chore(deps): update dependency @actions/github to v8 (#6184)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 01:51:06 +00:00
Tunglies 511fab9a9d Revert "perf: improve config processing (#6091)"
This reverts commit bf189bb144.
2026-01-26 23:36:57 +08:00
TungliesandSline 88529af8c8 fix(Linux): use PKEXEC_UID #6159 (#6160)
* fix(Linux): add GID environment variable for Linux service installation #6159

* chore: bump clash_verge_service_ipc to 2.1.2

* chore: remove CLASH_VERGE_SERVICE_GID for linux

---------

Co-authored-by: Sline <realakayuki@gmail.com>
2026-01-26 12:41:45 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Slinetrac
425096e8af chore(deps): lock file maintenance cargo dependencies (#6167)
* chore(deps): lock file maintenance cargo dependencies

* chore: run cargo upgrade and cargo update

* chore: fix clippy

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-01-26 07:45:36 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Slinetrac
8a4e2327c1 chore(deps): lock file maintenance npm dependencies (#6168)
* chore(deps): lock file maintenance npm dependencies

* chore: pnpm update

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-01-26 07:33:53 +00:00
TungliesandSlinetrac 74b1687be9 feat: implement git-hook using cargo make and add Makefile.toml (#5498)
* feat: implement pre-push checks using cargo make and add Makefile.toml for task management

* feat: enhance Makefile.toml with condition checks for tasks and improve clippy args

* fix: update file patterns for format-check task in Makefile.toml

* feat: update file patterns for eslint and typecheck tasks in Makefile.toml

* feat: refactor Makefile.toml to consolidate Rust tasks and update pre-commit checks

* feat: update Makefile.toml to add i18n-check and lint-staged tasks; modify pre-commit script

* feat: update Makefile.toml to add i18n-check and lint-staged tasks; modify pre-commit script

* refactor: simplify Makefile.toml by removing unused conditions and consolidating dependencies

* feat: update Makefile.toml to define Rust and frontend tasks for pre-commit and pre-push checks

* chore: remove unnecessary tasks

* chore: add windows override

* chore: remove format and format-check

---------

Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-01-26 07:21:02 +00:00
Tunglies 6477dd61c3 perf: reduce various timeout and retry intervals for improved responsiveness to fetch proxy infomation (#6072) 2026-01-25 07:31:34 +00:00
Tunglies 6ded9bdcde doc: changelog 2026-01-25 15:40:58 +08:00
Tunglies 13dc3feb9f perf: migrate fs method to async (#6071)
* perf(profiles): migrate file handling to async and improve error handling

* refactor(profiles): simplify cleanup_orphaned_files and adjust CleanupResult structure
2026-01-25 07:20:12 +00:00
Tunglies c7462716e5 refactor: reduce duplicated separately useSWR (#6153)
* refactor: reduce duplicated seperatlly useSWR

* refactor: streamline useSWR integration and improve error handling
2026-01-25 07:14:45 +00:00
Tunglies bf189bb144 perf: improve config processing (#6091)
* perf: improve config processing

* perf: enhance profile reordering logic and adjust logging level

* perf: add PartialEq derive to PrfSelected and PrfExtra structs for improved comparison

* perf: refactor PrfOption merge logic and streamline update_item method in IProfiles

* perf: simplify current_mapping and profiles_preview methods in IProfiles for improved readability

* perf: optimize filename matching logic in IProfiles by using a static regex
2026-01-25 07:13:38 +00:00
Tunglies 0c6631ebb0 fix(ip-info-card): handle offline state and clashConfig absence in IP info fetching (#6085)
* fix(ip-info-card): handle offline state and clashConfig absence in IP info fetching

* fix: eslint errors
2026-01-25 07:12:17 +00:00
Sline 93e7ac1bce feat(webdav): cache connection status and adjust auto-refresh behavior (#6129) 2026-01-25 06:49:12 +00:00
Sline b921098182 refactor(connections): switch manager table to TanStack column accessors and IConnectionsItem rows (#6083)
* refactor(connection-table): drive column order/visibility/sorting by TanStack Table state

* refactor(connection-table): simplify table data flow and align with built-in API

* refactor(connection-table): let column manager consume TanStack Table columns directly
2026-01-25 06:49:10 +00:00
SlineandTunglies 440f95f617 feat(misc-viewer): optional delay check interval (#6145)
Co-authored-by: Tunglies <tunglies.dev@outlook.com>
2026-01-25 06:48:16 +00:00
Tunglies b9667ad349 chore: bump version to 2.4.6 2026-01-25 14:22:22 +08:00
Tunglies 4e7cdbfcc0 Release: 2.4.5 2026-01-25 14:05:57 +08:00
Tunglies 966fd68087 fix(unix): update clash_verge_service_ipc to 2.1.1 to fix directory permissions 2026-01-25 13:35:18 +08:00
Tunglies 334cec3bde fix: update tauri-plugin-mihomo version, improve error handling #6149 2026-01-24 09:19:52 +08:00
Tunglies 6e16133393 ci(Mergify): configuration update (#6152)
Signed-off-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-01-23 14:35:57 +00:00
Tunglies 5e976c2fe1 chore: inline crate clash-verge-types to module for better maintenance (#6142) 2026-01-23 14:00:51 +00:00
DikozImpact d81aa5f233 Ru language fix (#6143)
* Ru language fix

* Update proxies.json

* Update home.json
2026-01-23 07:42:31 +08:00
Tunglies e5fc0de39a ci: downgrade Ubuntu version in autobuild workflow 2026-01-22 22:08:19 +08:00
Tunglies 6c62350cc3 Release: bump version to 2.4.5-rc.2 2026-01-21 21:26:11 +08:00
Tunglies d1649e3017 fix: update service to 2.1.0 and improve service installation for Unix systems (#6114)
* fix: update service to 2.1.0 and improve service installation for Unix systems

* fix: set GID environment variable during service installation on Linux

* Revert "fix: set GID environment variable during service installation on Linux"

This reverts commit 373aec579b.
2026-01-19 14:02:25 +08:00
Slinetrac 2869a35f1e chore(i18n): update backend i18n keys
translated by GPT-5.2.
2026-01-19 12:35:30 +08:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Slinetrac
98f12a9c72 chore(deps): lock file maintenance npm dependencies (#6119)
* chore(deps): lock file maintenance npm dependencies

* chore: run pnpm update

* fix(components): satisfy ESLint destructuring and narrow unknown errors

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-01-19 12:05:48 +08:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Slinetrac
6dc8a2f232 chore(deps): lock file maintenance cargo dependencies (#6118)
* chore(deps): lock file maintenance cargo dependencies

* chore: update Cargo.toml

* refactor: use reqwest directly

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Slinetrac <realakayuki@gmail.com>
2026-01-19 12:05:36 +08:00
Tunglies 6511f3868e fix: log IPC path issues conditionally based on tun mode setting 2026-01-18 09:13:22 +08:00
Tunglies 7da5a804f9 chore: bump service top 2.0.30, reduce memory footpoint 2026-01-18 08:43:39 +08:00
wonfen 20ed7a3abe chore: temporarily remove promotion 2026-01-17 03:33:10 +08:00
Sline fd98caccd2 revert: use-app-data (#6088)
* Revert "refactor(app-data): split monolithic context into focused SWR hooks (#5576)"

This reverts commit 8e8182f707.

# Conflicts:
#	src/components/home/clash-info-card.tsx
#	src/components/home/clash-mode-card.tsx
#	src/components/home/current-proxy-card.tsx
#	src/components/home/home-profile-card.tsx
#	src/components/proxy/provider-button.tsx
#	src/components/proxy/proxy-chain.tsx
#	src/components/proxy/proxy-groups.tsx
#	src/components/proxy/use-render-list.ts
#	src/components/rule/provider-button.tsx
#	src/components/setting/mods/sysproxy-viewer.tsx
#	src/hooks/use-clash-data.ts
#	src/hooks/use-current-proxy.ts
#	src/hooks/use-shared-swr-poller.ts
#	src/hooks/use-system-proxy-state.ts
#	src/pages/rules.tsx

* docs: Changelog.md
2026-01-16 18:32:31 +08:00
Tunglies a5f494bda2 fix: ensure external control source settings take effect immediately #6103 2026-01-16 18:27:21 +08:00
Tunglies d4d8ef3849 chore: update Mihomo(Meta) kernel version to v1.19.19 2026-01-16 12:43:12 +08:00
Slinetrac b16cbd5379 feat(backup): restore starts automatically with loading overlay without closing dialog 2026-01-16 12:32:36 +08:00
Tunglies 9e6689ef08 bump: sysproxy-rs version to 0.4.3 2026-01-15 21:25:10 +08:00
Tunglies e0c35c5ee3 fix: unexpected port in use error when change ports 2026-01-15 17:40:06 +08:00
Slinetrac 670055aba1 docs: simplify Linux wording 2026-01-15 14:09:34 +08:00
Slinetrac a780e44e69 docs: add warning to Changelog.md 2026-01-15 13:31:46 +08:00
193 changed files with 7302 additions and 5769 deletions
+3 -3
View File
@@ -307,7 +307,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -377,7 +377,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -505,7 +505,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
+29 -31
View File
@@ -46,7 +46,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -196,7 +196,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
cache: "pnpm" cache: "pnpm"
- name: Pnpm Cache - name: Pnpm Cache
@@ -253,10 +253,12 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- os: ubuntu-24.04 # It should be ubuntu-22.04 to match the cross-compilation environment
# ortherwise it is hard to resolve the dependencies
- os: ubuntu-22.04
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
arch: arm64 arch: arm64
- os: ubuntu-24.04 - os: ubuntu-22.04
target: armv7-unknown-linux-gnueabihf target: armv7-unknown-linux-gnueabihf
arch: armhf arch: armhf
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@@ -292,7 +294,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
cache: "pnpm" cache: "pnpm"
- name: Pnpm Cache - name: Pnpm Cache
@@ -311,39 +313,35 @@ jobs:
- name: Release ${{ env.TAG_CHANNEL }} Version - name: Release ${{ env.TAG_CHANNEL }} Version
run: pnpm release-version autobuild-latest run: pnpm release-version autobuild-latest
- name: Setup for linux - name: "Setup for linux"
run: | run: |-
sudo dpkg --add-architecture ${{ matrix.arch }} sudo ls -lR /etc/apt/
sudo rm -f /etc/apt/sources.list.d/ubuntu.sources cat > /tmp/sources.list << EOF
sudo tee /etc/apt/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 noble main restricted universe multiverse deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-security main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble-security main restricted universe multiverse deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-updates main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble-updates main restricted universe multiverse deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-backports main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble-backports main restricted universe multiverse
deb [arch=${{ matrix.arch }}] http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy main multiverse universe restricted
deb [arch=${{ matrix.arch }}] http://ports.ubuntu.com/ubuntu-ports noble-security main restricted universe multiverse deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main multiverse universe restricted
deb [arch=${{ matrix.arch }}] http://ports.ubuntu.com/ubuntu-ports noble-updates main restricted universe multiverse deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main multiverse universe restricted
deb [arch=${{ matrix.arch }}] http://ports.ubuntu.com/ubuntu-ports noble-backports main restricted universe multiverse deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main multiverse universe restricted
EOF EOF
sudo apt-get update -y sudo mv /etc/apt/sources.list /etc/apt/sources.list.default
sudo mv /tmp/sources.list /etc/apt/sources.list
sudo apt-get install -y libglib2.0-dev-bin sudo dpkg --add-architecture ${{ matrix.arch }}
sudo apt update
sudo apt-get install -y \ sudo apt install -y \
linux-libc-dev:${{ matrix.arch }} \ libxslt1.1:${{ matrix.arch }} \
libc6-dev:${{ matrix.arch }} \
libicu-dev:${{ matrix.arch }}
sudo apt-get install -y --no-install-recommends \
libxslt1-dev:${{ matrix.arch }} \
libwebkit2gtk-4.1-dev:${{ matrix.arch }} \ libwebkit2gtk-4.1-dev:${{ matrix.arch }} \
libayatana-appindicator3-dev:${{ matrix.arch }} \ libayatana-appindicator3-dev:${{ matrix.arch }} \
libssl-dev:${{ matrix.arch }} \ libssl-dev:${{ matrix.arch }} \
librsvg2-dev:${{ matrix.arch }} \ patchelf:${{ matrix.arch }} \
patchelf librsvg2-dev:${{ matrix.arch }}
- name: Install aarch64 tools - name: Install aarch64 tools
if: matrix.target == 'aarch64-unknown-linux-gnu' if: matrix.target == 'aarch64-unknown-linux-gnu'
@@ -441,7 +439,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
cache: "pnpm" cache: "pnpm"
- name: Pnpm Cache - name: Pnpm Cache
@@ -543,7 +541,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
- uses: pnpm/action-setup@v4.2.0 - uses: pnpm/action-setup@v4.2.0
name: Install pnpm name: Install pnpm
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true' if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
cache: "pnpm" cache: "pnpm"
- name: Pnpm Cache - name: Pnpm Cache
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
if: steps.check_frontend.outputs.frontend == 'true' if: steps.check_frontend.outputs.frontend == 'true'
with: with:
node-version: "24.13.0" node-version: "24.13.1"
cache: "pnpm" cache: "pnpm"
- name: Restore pnpm cache - name: Restore pnpm cache
+6 -6
View File
@@ -197,7 +197,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -281,7 +281,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -420,7 +420,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -505,7 +505,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -531,7 +531,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -593,7 +593,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.13.0" node-version: "24.13.1"
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
+1
View File
@@ -13,3 +13,4 @@ scripts/_env.sh
.eslintcache .eslintcache
.changelog_backups .changelog_backups
target target
CLAUDE.md
+5 -42
View File
@@ -1,51 +1,14 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
ROOT_DIR="$(git rev-parse --show-toplevel)" if ! command -v "cargo-make" >/dev/null 2>&1; then
cd "$ROOT_DIR" echo "❌ cargo-make is required for pre-commit checks."
cargo install --force cargo-make
fi
if ! command -v pnpm >/dev/null 2>&1; then if ! command -v pnpm >/dev/null 2>&1; then
echo "❌ pnpm is required for pre-commit checks." echo "❌ pnpm is required for pre-commit checks."
exit 1 exit 1
fi fi
LOCALE_DIFF="$(git diff --cached --name-only --diff-filter=ACMR | grep -E '^src/locales/' || true)" cargo make pre-commit
if [ -n "$LOCALE_DIFF" ]; then
echo "[pre-commit] Locale changes detected. Regenerating i18n types..."
pnpm i18n:types
if [ -d src/types/generated ]; then
echo "[pre-commit] Staging regenerated i18n type artifacts..."
git add src/types/generated
fi
fi
echo "[pre-commit] Running pnpm format before lint..."
pnpm format
echo "[pre-commit] Running lint-staged for JS/TS files..."
pnpm exec lint-staged
RUST_FILES="$(git diff --cached --name-only --diff-filter=ACMR | grep -E '^src-tauri/.*\.rs$' || true)"
if [ -n "$RUST_FILES" ]; then
echo "[pre-commit] Formatting Rust changes with cargo fmt..."
cargo fmt
while IFS= read -r file; do
[ -n "$file" ] && git add "$file"
done <<< "$RUST_FILES"
echo "[pre-commit] Linting Rust changes with cargo clippy..."
cargo clippy-all
if ! command -v clash-verge-logging-check >/dev/null 2>&1; then
echo "[pre-commit] Installing clash-verge-logging-check..."
cargo install --git https://github.com/clash-verge-rev/clash-verge-logging-check.git
fi
clash-verge-logging-check
fi
TS_FILES="$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(ts|tsx)$' || true)"
if [ -n "$TS_FILES" ]; then
echo "[pre-commit] Running TypeScript type check..."
pnpm typecheck
fi
echo "[pre-commit] All checks completed successfully."
+4 -31
View File
@@ -1,36 +1,9 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
remote_name="${1:-origin}" if ! command -v "cargo-make" >/dev/null 2>&1; then
remote_url="${2:-unknown}" echo "❌ cargo-make is required for pre-push checks."
cargo install --force cargo-make
ROOT_DIR="$(git rev-parse --show-toplevel)"
cd "$ROOT_DIR"
if ! command -v pnpm >/dev/null 2>&1; then
echo "❌ pnpm is required for pre-push checks."
exit 1
fi fi
echo "[pre-push] Preparing to push to '$remote_name' ($remote_url). Running full validation..." cargo make pre-push
echo "[pre-push] Checking Prettier formatting..."
pnpm format:check
echo "[pre-push] Running ESLint..."
pnpm lint
echo "[pre-push] Running TypeScript type checking..."
pnpm typecheck
if command -v cargo >/dev/null 2>&1; then
echo "[pre-push] Verifying Rust formatting..."
cargo fmt --check
echo "[pre-push] Running cargo clippy..."
cargo clippy-all
else
echo "[pre-push] ⚠️ cargo not found; skipping Rust checks."
fi
echo "[pre-push] All checks passed."
+5
View File
@@ -0,0 +1,5 @@
queue_rules:
- name: LetMeMergeForYou
batch_size: 3
allow_queue_branch_edit: true
queue_conditions: []
Generated
+687 -758
View File
File diff suppressed because it is too large Load Diff
+12 -7
View File
@@ -5,8 +5,8 @@ members = [
"crates/clash-verge-logging", "crates/clash-verge-logging",
"crates/clash-verge-signal", "crates/clash-verge-signal",
"crates/tauri-plugin-clash-verge-sysinfo", "crates/tauri-plugin-clash-verge-sysinfo",
"crates/clash-verge-types",
"crates/clash-verge-i18n", "crates/clash-verge-i18n",
"crates/clash-verge-limiter",
] ]
resolver = "2" resolver = "2"
@@ -40,26 +40,31 @@ opt-level = 0
debug = true debug = true
strip = false strip = false
[profile.debug-release]
inherits = "fast-release"
codegen-units = 1
split-debuginfo = "unpacked"
[workspace.dependencies] [workspace.dependencies]
clash-verge-draft = { path = "crates/clash-verge-draft" } clash-verge-draft = { path = "crates/clash-verge-draft" }
clash-verge-logging = { path = "crates/clash-verge-logging" } clash-verge-logging = { path = "crates/clash-verge-logging" }
clash-verge-signal = { path = "crates/clash-verge-signal" } clash-verge-signal = { path = "crates/clash-verge-signal" }
clash-verge-types = { path = "crates/clash-verge-types" }
clash-verge-i18n = { path = "crates/clash-verge-i18n" } clash-verge-i18n = { path = "crates/clash-verge-i18n" }
clash-verge-limiter = { path = "crates/clash-verge-limiter" }
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" } tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
tauri = { version = "2.9.5" } tauri = { version = "2.10.2" }
tauri-plugin-clipboard-manager = "2.3.2" tauri-plugin-clipboard-manager = "2.3.2"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] } parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.100" anyhow = "1.0.102"
criterion = { version = "0.8.1", features = ["async_tokio"] } criterion = { version = "0.8.2", features = ["async_tokio"] }
tokio = { version = "1.49.0", features = [ tokio = { version = "1.49.0", features = [
"rt-multi-thread", "rt-multi-thread",
"macros", "macros",
"time", "time",
"sync", "sync",
] } ] }
flexi_logger = "0.31.7" flexi_logger = "0.31.8"
log = "0.4.29" log = "0.4.29"
smartstring = { version = "1.0.1" } smartstring = { version = "1.0.1" }
@@ -68,7 +73,7 @@ compact_str = { version = "0.9.0", features = ["serde"] }
serde = { version = "1.0.228" } serde = { version = "1.0.228" }
serde_json = { version = "1.0.149" } serde_json = { version = "1.0.149" }
serde_yaml_ng = { version = "0.10.0" } serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.10.0" } bitflags = { version = "2.11.0" }
# *** For Windows platform only *** # *** For Windows platform only ***
deelevate = "0.2.0" deelevate = "0.2.0"
+5 -35
View File
@@ -1,49 +1,19 @@
## v2.4.5 ## v2.4.7
- **Mihomo(Meta) 内核升级至 v1.19.18**
### 🐞 修复问题 ### 🐞 修复问题
- 修复 macOS 有线网络 DNS 劫持失败 - 修复 Windows 管理员身份运行时开关 TUN 模式异常
- 修复 Monaco 编辑器内右键菜单显示异常
- 修复设置代理端口时检查端口占用
- 修复 Monaco 编辑器初始化卡 Loading
- 修复恢复备份时 `config.yaml` / `profiles.yaml` 文件内字段未正确恢复
- 修复 Windows 下系统主题同步问题
- 修复 URL Schemes 无法正常导入
- 修复 Linux 下无法安装 TUN 服务
<details> <details>
<summary><strong> ✨ 新增功能 </strong></summary> <summary><strong> ✨ 新增功能 </strong></summary>
- 允许代理页面允许高级过滤搜索
- 备份设置页面新增导入备份按钮
- 允许修改通知弹窗位置
- 支持收起导航栏(导航栏右键菜单 / 界面设置)
- 允许将出站模式显示在托盘一级菜单
- 允许禁用在托盘中显示代理组
- 支持在「编辑节点」中直接导入 AnyTLS URI 配置
- 支持关闭「验证代理绕过格式」
- 新增系统代理绕过和 TUN 排除自定义网段的可视化编辑器
</details> </details>
<details> <details>
<summary><strong> 🚀 优化改进 </strong></summary> <summary><strong> 🚀 优化改进 </strong></summary>
- 应用内更新日志支持解析并渲染 HTML 标签 - 优化订阅错误通知,仅在手动触发时
- 性能优化前后端在渲染流量图时的资源 - 隐藏日志中的订阅信息
- 在 Linux NVIDIA 显卡环境下尝试禁用 WebKit DMABUF 渲染以规避潜在问题 - 优化部分界面文案文本
- Windows 下自启动改为计划任务实现
- 改进托盘和窗口操作频率限制实现
- 使用「编辑节点」添加节点时,自动将节点添加到第一个 `select` 类型的代理组的第一位
- 隐藏侧边导航栏和悬浮跳转导航的滚动条
- 完善对 AnyTLS / Mieru / Sudoku 的 GUI 支持
- macOS 和 Linux 对服务 IPC 权限进一步限制
- 移除 Windows 自启动计划任务中冗余的 3 秒延时
- 右键错误通知可复制错误详情
- 保存 TUN 设置时优化执行流程,避免界面卡顿
- 补充 `deb` / `rpm` 依赖 `libayatana-appindicator`
- 「连接」表格标题的排序点击区域扩展到整列宽度
</details> </details>
+86
View File
@@ -0,0 +1,86 @@
[config]
skip_core_tasks = true
skip_git_env_info = true
skip_rust_env_info = true
skip_crate_env_info = true
# --- Backend ---
[tasks.rust-format]
install_crate = "rustfmt"
command = "cargo"
args = ["fmt", "--", "--emit=files"]
[tasks.rust-clippy]
description = "Run cargo clippy to lint the code"
command = "cargo"
args = ["clippy", "--all-targets", "--all-features", "--", "-D", "warnings"]
# --- Frontend ---
[tasks.eslint]
description = "Run ESLint to lint the code"
command = "pnpm"
args = ["lint"]
[tasks.eslint.windows]
command = "pnpm.cmd"
[tasks.typecheck]
description = "Run type checks"
command = "pnpm"
args = ["typecheck"]
[tasks.typecheck.windows]
command = "pnpm.cmd"
[tasks.lint-staged]
description = "Run lint-staged for staged files"
command = "pnpm"
args = ["exec", "lint-staged"]
[tasks.lint-staged.windows]
command = "pnpm.cmd"
# --- Jobs ---
# Rust format (for pre-commit)
[tasks.rust-format-check]
description = "Check Rust code formatting"
dependencies = ["rust-format"]
[tasks.rust-format-check.condition]
files_modified.input = [
"./src-tauri/**/*.rs",
"./crates/**/*.rs",
"**/Cargo.toml",
]
files_modified.output = ["./target/debug/*", "./target/release/*"]
# Rust lint (for pre-push)
[tasks.rust-lint]
description = "Run Rust linting"
dependencies = ["rust-clippy"]
[tasks.rust-lint.condition]
files_modified.input = [
"./src-tauri/**/*.rs",
"./crates/**/*.rs",
"**/Cargo.toml",
]
files_modified.output = ["./target/debug/*", "./target/release/*"]
# Frontend format (for pre-commit)
[tasks.frontend-format]
description = "Frontend format checks"
dependencies = ["lint-staged"]
# Frontend lint (for pre-push)
[tasks.frontend-lint]
description = "Frontend linting and type checking"
dependencies = ["eslint", "typecheck"]
# --- Git Hooks ---
[tasks.pre-commit]
description = "Pre-commit checks: format only"
dependencies = ["rust-format-check", "frontend-format"]
[tasks.pre-push]
description = "Pre-push checks: lint and typecheck"
dependencies = ["rust-lint", "frontend-lint"]
-13
View File
@@ -66,19 +66,6 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple).
🌐 官网:👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) 🌐 官网:👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### 本项目的构建与发布环境由 [YXVM](https://yxvm.com/aff.php?aff=827) 独立服务器全力支持,
感谢提供 独享资源、高性能、高速网络 的强大后端环境。如果你觉得下载够快、使用够爽,那是因为我们用了好服务器!
🧩 YXVM 独立服务器优势:
- 🌎 优质网络,回程优化,下载快到飞起
- 🔧 物理机独享资源,非VPS可比,性能拉满
- 🧠 适合跑代理、搭建 WEB 站 CDN 站 、搞 CI/CD 或任何高负载应用
- 💡 支持即开即用,多机房选择,CN2 / IEPL 可选
- 📦 本项目使用配置已在售,欢迎同款入手!
- 🎯 想要同款构建体验?[立即下单 YXVM 独立服务器!](https://yxvm.com/aff.php?aff=827)
## Features ## Features
- 基于性能强劲的 Rust 和 Tauri 2 框架 - 基于性能强劲的 Rust 和 Tauri 2 框架
+10 -2
View File
@@ -6,8 +6,8 @@ type DraftInner<T> = (SharedDraft<T>, Option<SharedDraft<T>>);
/// Draft 管理:committed 与 optional draft 都以 Arc<Box<T>> 存储, /// Draft 管理:committed 与 optional draft 都以 Arc<Box<T>> 存储,
// (committed_snapshot, optional_draft_snapshot) // (committed_snapshot, optional_draft_snapshot)
#[derive(Debug, Clone)] #[derive(Debug)]
pub struct Draft<T: Clone> { pub struct Draft<T> {
inner: Arc<RwLock<DraftInner<T>>>, inner: Arc<RwLock<DraftInner<T>>>,
} }
@@ -90,3 +90,11 @@ impl<T: Clone> Draft<T> {
Ok(res) Ok(res)
} }
} }
impl<T: Clone> Clone for Draft<T> {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
+46 -46
View File
@@ -1,60 +1,60 @@
_version: 1 _version: 1
notifications: notifications:
dashboardToggled: dashboardToggled:
title: Dashboard title: لوحة التحكم
body: Dashboard visibility has been updated. body: تم تحديث حالة عرض لوحة التحكم.
clashModeChanged: clashModeChanged:
title: Mode Switch title: تبديل الوضع
body: Switched to {mode}. body: تم التبديل إلى {mode}.
systemProxyToggled: systemProxyToggled:
title: System Proxy title: وكيل النظام
body: System proxy status has been updated. body: تم تحديث حالة وكيل النظام.
tunModeToggled: tunModeToggled:
title: TUN Mode title: وضع TUN
body: TUN mode status has been updated. body: تم تحديث حالة وضع TUN.
lightweightModeEntered: lightweightModeEntered:
title: Lightweight Mode title: الوضع الخفيف
body: Entered lightweight mode. body: تم الدخول إلى الوضع الخفيف.
profilesReactivated: profilesReactivated:
title: Profiles title: الملفات التعريفية
body: Profile Reactivated. body: تمت إعادة تفعيل الملف التعريفي.
appQuit: appQuit:
title: About to Exit title: على وشك الخروج
body: Clash Verge is about to exit. body: Clash Verge على وشك الخروج.
appHidden: appHidden:
title: Application Hidden title: تم إخفاء التطبيق
body: Clash Verge is running in the background. body: Clash Verge يعمل في الخلفية.
service: service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
tray: tray:
dashboard: Dashboard dashboard: لوحة التحكم
ruleMode: Rule Mode ruleMode: وضع القواعد
globalMode: Global Mode globalMode: الوضع العام
directMode: Direct Mode directMode: الوضع المباشر
outboundModes: Outbound Modes outboundModes: أوضاع الخروج
rule: Rule rule: قاعدة
direct: Direct direct: مباشر
global: Global global: عام
profiles: Profiles profiles: الملفات التعريفية
proxies: Proxies proxies: وكلاء
systemProxy: System Proxy systemProxy: وكيل النظام
tunMode: TUN Mode tunMode: وضع TUN
closeAllConnections: Close All Connections closeAllConnections: إغلاق كل الاتصالات
lightweightMode: Lightweight Mode lightweightMode: الوضع الخفيف
copyEnv: Copy Environment Variables copyEnv: نسخ متغيرات البيئة
confDir: Configuration Directory confDir: دليل الإعدادات
coreDir: Core Directory coreDir: دليل النواة
logsDir: Log Directory logsDir: دليل السجلات
openDir: Open Directory openDir: فتح الدليل
appLog: Application Log appLog: سجل التطبيق
coreLog: Core Log coreLog: سجل النواة
restartClash: Restart Clash Core restartClash: إعادة تشغيل نواة Clash
restartApp: Restart Application restartApp: إعادة تشغيل التطبيق
vergeVersion: Verge Version vergeVersion: إصدار Verge
more: More more: المزيد
exit: Exit exit: خروج
tooltip: tooltip:
systemProxy: System Proxy systemProxy: وكيل النظام
tun: TUN tun: TUN
profile: Profile profile: ملف تعريفي
+43 -43
View File
@@ -1,60 +1,60 @@
_version: 1 _version: 1
notifications: notifications:
dashboardToggled: dashboardToggled:
title: Dashboard title: Übersicht
body: Dashboard visibility has been updated. body: Die Sichtbarkeit der Übersicht wurde aktualisiert.
clashModeChanged: clashModeChanged:
title: Mode Switch title: Moduswechsel
body: Switched to {mode}. body: Auf {mode} umgeschaltet.
systemProxyToggled: systemProxyToggled:
title: System Proxy title: Systemproxy
body: System proxy status has been updated. body: Der Status des Systemproxys wurde aktualisiert.
tunModeToggled: tunModeToggled:
title: TUN Mode title: TUN-Modus
body: TUN mode status has been updated. body: Der Status des TUN-Modus wurde aktualisiert.
lightweightModeEntered: lightweightModeEntered:
title: Lightweight Mode title: Leichtmodus
body: Entered lightweight mode. body: Leichtmodus aktiviert.
profilesReactivated: profilesReactivated:
title: Profiles title: Profile
body: Profile Reactivated. body: Profil reaktiviert.
appQuit: appQuit:
title: About to Exit title: Beenden steht bevor
body: Clash Verge is about to exit. body: Clash Verge wird gleich beendet.
appHidden: appHidden:
title: Application Hidden title: Anwendung ausgeblendet
body: Clash Verge is running in the background. body: Clash Verge läuft im Hintergrund.
service: service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
tray: tray:
dashboard: Dashboard dashboard: Übersicht
ruleMode: Rule Mode ruleMode: Regelmodus
globalMode: Global Mode globalMode: Globaler Modus
directMode: Direct Mode directMode: Direktmodus
outboundModes: Outbound Modes outboundModes: Ausgangsmodi
rule: Regel rule: Regel
direct: Direkt direct: Direkt
global: Global global: Global
profiles: Profiles profiles: Profile
proxies: Proxies proxies: Proxy
systemProxy: System Proxy systemProxy: Systemproxy
tunMode: TUN Mode tunMode: TUN-Modus
closeAllConnections: Close All Connections closeAllConnections: Alle Verbindungen schließen
lightweightMode: Lightweight Mode lightweightMode: Leichtmodus
copyEnv: Copy Environment Variables copyEnv: Umgebungsvariablen kopieren
confDir: Configuration Directory confDir: Konfigurationsverzeichnis
coreDir: Core Directory coreDir: Core-Verzeichnis
logsDir: Log Directory logsDir: Log-Verzeichnis
openDir: Open Directory openDir: Verzeichnis öffnen
appLog: Application Log appLog: Anwendungslog
coreLog: Core Log coreLog: Core-Log
restartClash: Restart Clash Core restartClash: Clash-Core neu starten
restartApp: Restart Application restartApp: Anwendung neu starten
vergeVersion: Verge Version vergeVersion: Verge-Version
more: More more: Mehr
exit: Exit exit: Beenden
tooltip: tooltip:
systemProxy: System Proxy systemProxy: Systemproxy
tun: TUN tun: TUN
profile: Profile profile: Profil
+42 -42
View File
@@ -1,60 +1,60 @@
_version: 1 _version: 1
notifications: notifications:
dashboardToggled: dashboardToggled:
title: Dashboard title: Panel
body: Dashboard visibility has been updated. body: La visibilidad del panel se ha actualizado.
clashModeChanged: clashModeChanged:
title: Mode Switch title: Cambio de modo
body: Switched to {mode}. body: Cambiado a {mode}.
systemProxyToggled: systemProxyToggled:
title: System Proxy title: Proxy del sistema
body: System proxy status has been updated. body: El estado del proxy del sistema se ha actualizado.
tunModeToggled: tunModeToggled:
title: TUN Mode title: Modo TUN
body: TUN mode status has been updated. body: El estado del modo TUN se ha actualizado.
lightweightModeEntered: lightweightModeEntered:
title: Lightweight Mode title: Modo ligero
body: Entered lightweight mode. body: Se ha entrado en el modo ligero.
profilesReactivated: profilesReactivated:
title: Profiles title: Perfiles
body: Profile Reactivated. body: Perfil reactivado.
appQuit: appQuit:
title: About to Exit title: A punto de salir
body: Clash Verge is about to exit. body: Clash Verge está a punto de salir.
appHidden: appHidden:
title: Application Hidden title: Aplicación oculta
body: Clash Verge is running in the background. body: Clash Verge se está ejecutando en segundo plano.
service: service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador.
tray: tray:
dashboard: Dashboard dashboard: Panel
ruleMode: Rule Mode ruleMode: Modo de reglas
globalMode: Global Mode globalMode: Modo global
directMode: Direct Mode directMode: Modo directo
outboundModes: Outbound Modes outboundModes: Modos de salida
rule: Regla rule: Regla
direct: Directo direct: Directo
global: Global global: Global
profiles: Profiles profiles: Perfiles
proxies: Proxies proxies: Proxies
systemProxy: System Proxy systemProxy: Proxy del sistema
tunMode: TUN Mode tunMode: Modo TUN
closeAllConnections: Close All Connections closeAllConnections: Cerrar todas las conexiones
lightweightMode: Lightweight Mode lightweightMode: Modo ligero
copyEnv: Copy Environment Variables copyEnv: Copiar variables de entorno
confDir: Configuration Directory confDir: Directorio de configuración
coreDir: Core Directory coreDir: Directorio del núcleo
logsDir: Log Directory logsDir: Directorio de registros
openDir: Open Directory openDir: Abrir directorio
appLog: Application Log appLog: Registro de la aplicación
coreLog: Core Log coreLog: Registro del núcleo
restartClash: Restart Clash Core restartClash: Reiniciar el núcleo de Clash
restartApp: Restart Application restartApp: Reiniciar aplicación
vergeVersion: Verge Version vergeVersion: Versión de Verge
more: More more: Más
exit: Exit exit: Salir
tooltip: tooltip:
systemProxy: System Proxy systemProxy: Proxy del sistema
tun: TUN tun: TUN
profile: Profile profile: Perfil
+46 -46
View File
@@ -1,60 +1,60 @@
_version: 1 _version: 1
notifications: notifications:
dashboardToggled: dashboardToggled:
title: Dashboard title: داشبورد
body: Dashboard visibility has been updated. body: وضعیت نمایش داشبورد به‌روزرسانی شد.
clashModeChanged: clashModeChanged:
title: Mode Switch title: تغییر حالت
body: Switched to {mode}. body: به {mode} تغییر کرد.
systemProxyToggled: systemProxyToggled:
title: System Proxy title: پروکسی سیستم
body: System proxy status has been updated. body: وضعیت پروکسی سیستم به‌روزرسانی شد.
tunModeToggled: tunModeToggled:
title: TUN Mode title: حالت TUN
body: TUN mode status has been updated. body: وضعیت حالت TUN به‌روزرسانی شد.
lightweightModeEntered: lightweightModeEntered:
title: Lightweight Mode title: حالت سبک
body: Entered lightweight mode. body: به حالت سبک وارد شد.
profilesReactivated: profilesReactivated:
title: Profiles title: پروفایل‌ها
body: Profile Reactivated. body: پروفایل دوباره فعال شد.
appQuit: appQuit:
title: About to Exit title: در آستانه خروج
body: Clash Verge is about to exit. body: Clash Verge در آستانه خروج است.
appHidden: appHidden:
title: Application Hidden title: برنامه پنهان شد
body: Clash Verge is running in the background. body: Clash Verge در پس‌زمینه در حال اجراست.
service: service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
tray: tray:
dashboard: Dashboard dashboard: داشبورد
ruleMode: Rule Mode ruleMode: حالت قوانین
globalMode: Global Mode globalMode: حالت سراسری
directMode: Direct Mode directMode: حالت مستقیم
outboundModes: Outbound Modes outboundModes: حالت‌های خروجی
rule: Rule rule: قانون
direct: Direct direct: مستقیم
global: Global global: سراسری
profiles: Profiles profiles: پروفایل‌ها
proxies: Proxies proxies: پروکسی‌ها
systemProxy: System Proxy systemProxy: پروکسی سیستم
tunMode: TUN Mode tunMode: حالت TUN
closeAllConnections: Close All Connections closeAllConnections: بستن همه اتصال‌ها
lightweightMode: Lightweight Mode lightweightMode: حالت سبک
copyEnv: Copy Environment Variables copyEnv: کپی متغیرهای محیطی
confDir: Configuration Directory confDir: پوشه پیکربندی
coreDir: Core Directory coreDir: پوشه هسته
logsDir: Log Directory logsDir: پوشه گزارش‌ها
openDir: Open Directory openDir: باز کردن پوشه
appLog: Application Log appLog: گزارش برنامه
coreLog: Core Log coreLog: گزارش هسته
restartClash: Restart Clash Core restartClash: راه‌اندازی مجدد هسته Clash
restartApp: Restart Application restartApp: راه‌اندازی مجدد برنامه
vergeVersion: Verge Version vergeVersion: نسخه Verge
more: More more: بیشتر
exit: Exit exit: خروج
tooltip: tooltip:
systemProxy: System Proxy systemProxy: پروکسی سیستم
tun: TUN tun: TUN
profile: Profile profile: پروفایل
+43 -43
View File
@@ -1,60 +1,60 @@
_version: 1 _version: 1
notifications: notifications:
dashboardToggled: dashboardToggled:
title: Dashboard title: Dasbor
body: Dashboard visibility has been updated. body: Visibilitas dasbor telah diperbarui.
clashModeChanged: clashModeChanged:
title: Mode Switch title: Peralihan Mode
body: Switched to {mode}. body: Beralih ke {mode}.
systemProxyToggled: systemProxyToggled:
title: System Proxy title: Proksi Sistem
body: System proxy status has been updated. body: Status proksi sistem telah diperbarui.
tunModeToggled: tunModeToggled:
title: TUN Mode title: Mode TUN
body: TUN mode status has been updated. body: Status mode TUN telah diperbarui.
lightweightModeEntered: lightweightModeEntered:
title: Lightweight Mode title: Mode Ringan
body: Entered lightweight mode. body: Masuk ke mode ringan.
profilesReactivated: profilesReactivated:
title: Profiles title: Profil
body: Profile Reactivated. body: Profil diaktifkan kembali.
appQuit: appQuit:
title: About to Exit title: Akan Keluar
body: Clash Verge is about to exit. body: Clash Verge akan keluar.
appHidden: appHidden:
title: Application Hidden title: Aplikasi Disembunyikan
body: Clash Verge is running in the background. body: Clash Verge berjalan di latar belakang.
service: service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator.
tray: tray:
dashboard: Dashboard dashboard: Dasbor
ruleMode: Rule Mode ruleMode: Mode Aturan
globalMode: Global Mode globalMode: Mode Global
directMode: Direct Mode directMode: Mode Langsung
outboundModes: Outbound Modes outboundModes: Mode Keluar
rule: Aturan rule: Aturan
direct: Langsung direct: Langsung
global: Global global: Global
profiles: Profiles profiles: Profil
proxies: Proxies proxies: Proksi
systemProxy: System Proxy systemProxy: Proksi Sistem
tunMode: TUN Mode tunMode: Mode TUN
closeAllConnections: Close All Connections closeAllConnections: Tutup Semua Koneksi
lightweightMode: Lightweight Mode lightweightMode: Mode Ringan
copyEnv: Copy Environment Variables copyEnv: Salin Variabel Lingkungan
confDir: Configuration Directory confDir: Direktori Konfigurasi
coreDir: Core Directory coreDir: Direktori Core
logsDir: Log Directory logsDir: Direktori Log
openDir: Open Directory openDir: Buka Direktori
appLog: Application Log appLog: Log Aplikasi
coreLog: Core Log coreLog: Log Core
restartClash: Restart Clash Core restartClash: Mulai Ulang Core Clash
restartApp: Restart Application restartApp: Mulai Ulang Aplikasi
vergeVersion: Verge Version vergeVersion: Versi Verge
more: More more: Lainnya
exit: Exit exit: Keluar
tooltip: tooltip:
systemProxy: System Proxy systemProxy: Proksi Sistem
tun: TUN tun: TUN
profile: Profile profile: Profil
+43 -43
View File
@@ -1,60 +1,60 @@
_version: 1 _version: 1
notifications: notifications:
dashboardToggled: dashboardToggled:
title: Dashboard title: ダッシュボード
body: Dashboard visibility has been updated. body: ダッシュボードの表示状態が更新されました。
clashModeChanged: clashModeChanged:
title: Mode Switch title: モード切り替え
body: Switched to {mode}. body: "{mode} に切り替えました。"
systemProxyToggled: systemProxyToggled:
title: System Proxy title: システムプロキシ
body: System proxy status has been updated. body: システムプロキシの状態が更新されました。
tunModeToggled: tunModeToggled:
title: TUN Mode title: TUN モード
body: TUN mode status has been updated. body: TUN モードの状態が更新されました。
lightweightModeEntered: lightweightModeEntered:
title: Lightweight Mode title: 軽量モード
body: Entered lightweight mode. body: 軽量モードに入りました。
profilesReactivated: profilesReactivated:
title: Profiles title: プロファイル
body: Profile Reactivated. body: プロファイルが再有効化されました。
appQuit: appQuit:
title: About to Exit title: 終了間近
body: Clash Verge is about to exit. body: Clash Verge はまもなく終了します。
appHidden: appHidden:
title: Application Hidden title: アプリが非表示
body: Clash Verge is running in the background. body: Clash Verge はバックグラウンドで実行中です。
service: service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
tray: tray:
dashboard: Dashboard dashboard: ダッシュボード
ruleMode: Rule Mode ruleMode: ルールモード
globalMode: Global Mode globalMode: グローバルモード
directMode: Direct Mode directMode: ダイレクトモード
outboundModes: Outbound Modes outboundModes: アウトバウンドモード
rule: ルール rule: ルール
direct: ダイレクト direct: ダイレクト
global: グローバル global: グローバル
profiles: Profiles profiles: プロファイル
proxies: Proxies proxies: プロキシ
systemProxy: System Proxy systemProxy: システムプロキシ
tunMode: TUN Mode tunMode: TUN モード
closeAllConnections: Close All Connections closeAllConnections: すべての接続を閉じる
lightweightMode: Lightweight Mode lightweightMode: 軽量モード
copyEnv: Copy Environment Variables copyEnv: 環境変数をコピー
confDir: Configuration Directory confDir: 設定ディレクトリ
coreDir: Core Directory coreDir: コアディレクトリ
logsDir: Log Directory logsDir: ログディレクトリ
openDir: Open Directory openDir: ディレクトリを開く
appLog: Application Log appLog: アプリケーションログ
coreLog: Core Log coreLog: コアログ
restartClash: Restart Clash Core restartClash: Clash コアを再起動
restartApp: Restart Application restartApp: アプリケーションを再起動
vergeVersion: Verge Version vergeVersion: Verge バージョン
more: More more: その他
exit: Exit exit: 終了
tooltip: tooltip:
systemProxy: System Proxy systemProxy: システムプロキシ
tun: TUN tun: TUN
profile: Profile profile: プロファイル
+5 -5
View File
@@ -16,8 +16,8 @@ notifications:
title: 경량 모드 title: 경량 모드
body: 경량 모드에 진입했습니다. body: 경량 모드에 진입했습니다.
profilesReactivated: profilesReactivated:
title: Profiles title: 프로필
body: Profile Reactivated. body: 프로필이 다시 활성화되었습니다.
appQuit: appQuit:
title: 곧 종료 title: 곧 종료
body: Clash Verge가 곧 종료됩니다. body: Clash Verge가 곧 종료됩니다.
@@ -25,14 +25,14 @@ notifications:
title: 앱이 숨겨짐 title: 앱이 숨겨짐
body: Clash Verge가 백그라운드에서 실행 중입니다. body: Clash Verge가 백그라운드에서 실행 중입니다.
service: service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
tray: tray:
dashboard: 대시보드 dashboard: 대시보드
ruleMode: 규칙 모드 ruleMode: 규칙 모드
globalMode: 전역 모드 globalMode: 전역 모드
directMode: 직접 모드 directMode: 직접 모드
outboundModes: Outbound Modes outboundModes: 아웃바운드 모드
rule: 규칙 rule: 규칙
direct: 직접 direct: 직접
global: 글로벌 global: 글로벌
+43 -43
View File
@@ -1,60 +1,60 @@
_version: 1 _version: 1
notifications: notifications:
dashboardToggled: dashboardToggled:
title: Dashboard title: Панель
body: Dashboard visibility has been updated. body: Видимость панели обновлена.
clashModeChanged: clashModeChanged:
title: Mode Switch title: Смена режима
body: Switched to {mode}. body: Переключено на {mode}.
systemProxyToggled: systemProxyToggled:
title: System Proxy title: Системный прокси
body: System proxy status has been updated. body: Статус системного прокси обновлен.
tunModeToggled: tunModeToggled:
title: TUN Mode title: Режим TUN
body: TUN mode status has been updated. body: Статус режима TUN обновлен.
lightweightModeEntered: lightweightModeEntered:
title: Lightweight Mode title: Легкий режим
body: Entered lightweight mode. body: Включен легкий режим.
profilesReactivated: profilesReactivated:
title: Profiles title: Профили
body: Profile Reactivated. body: Профиль повторно активирован.
appQuit: appQuit:
title: About to Exit title: Скорый выход
body: Clash Verge is about to exit. body: Clash Verge скоро завершит работу.
appHidden: appHidden:
title: Application Hidden title: Приложение скрыто
body: Clash Verge is running in the background. body: Clash Verge работает в фоновом режиме.
service: service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
tray: tray:
dashboard: Dashboard dashboard: Панель
ruleMode: Rule Mode ruleMode: Режим правил
globalMode: Global Mode globalMode: Глобальный режим
directMode: Direct Mode directMode: Прямой режим
outboundModes: Outbound Modes outboundModes: Исходящие режимы
rule: Правило rule: Правило
direct: Прямой direct: Прямой
global: Глобальный global: Глобальный
profiles: Profiles profiles: Профили
proxies: Proxies proxies: Прокси
systemProxy: System Proxy systemProxy: Системный прокси
tunMode: TUN Mode tunMode: Режим TUN
closeAllConnections: Close All Connections closeAllConnections: Закрыть все соединения
lightweightMode: Lightweight Mode lightweightMode: Легкий режим
copyEnv: Copy Environment Variables copyEnv: Копировать переменные среды
confDir: Configuration Directory confDir: Каталог конфигурации
coreDir: Core Directory coreDir: Каталог ядра
logsDir: Log Directory logsDir: Каталог журналов
openDir: Open Directory openDir: Открыть каталог
appLog: Application Log appLog: Журнал приложения
coreLog: Core Log coreLog: Журнал ядра
restartClash: Restart Clash Core restartClash: Перезапустить ядро Clash
restartApp: Restart Application restartApp: Перезапустить приложение
vergeVersion: Verge Version vergeVersion: Версия Verge
more: More more: Еще
exit: Exit exit: Выход
tooltip: tooltip:
systemProxy: System Proxy systemProxy: Системный прокси
tun: TUN tun: TUN
profile: Profile profile: Профиль
+43 -43
View File
@@ -1,60 +1,60 @@
_version: 1 _version: 1
notifications: notifications:
dashboardToggled: dashboardToggled:
title: Dashboard title: Gösterge Paneli
body: Dashboard visibility has been updated. body: Gösterge panelinin görünürlüğü güncellendi.
clashModeChanged: clashModeChanged:
title: Mode Switch title: Mod Değişimi
body: Switched to {mode}. body: "{mode} moduna geçildi."
systemProxyToggled: systemProxyToggled:
title: System Proxy title: Sistem Vekil'i
body: System proxy status has been updated. body: Sistem vekil'i durumu güncellendi.
tunModeToggled: tunModeToggled:
title: TUN Mode title: TUN Modu
body: TUN mode status has been updated. body: TUN modu durumu güncellendi.
lightweightModeEntered: lightweightModeEntered:
title: Lightweight Mode title: Hafif Mod
body: Entered lightweight mode. body: Hafif moda geçildi.
profilesReactivated: profilesReactivated:
title: Profiles title: Profiller
body: Profile Reactivated. body: Profil yeniden etkinleştirildi.
appQuit: appQuit:
title: About to Exit title: Çıkış Yapılmak Üzere
body: Clash Verge is about to exit. body: Clash Verge kapanmak üzere.
appHidden: appHidden:
title: Application Hidden title: Uygulama Gizlendi
body: Clash Verge is running in the background. body: Clash Verge arka planda çalışıyor.
service: service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
tray: tray:
dashboard: Dashboard dashboard: Gösterge Paneli
ruleMode: Rule Mode ruleMode: Kural Modu
globalMode: Global Mode globalMode: Küresel Mod
directMode: Direct Mode directMode: Doğrudan Mod
outboundModes: Outbound Modes outboundModes: Giden Modlar
rule: Kural rule: Kural
direct: Doğrudan direct: Doğrudan
global: Küresel global: Küresel
profiles: Profiles profiles: Profiller
proxies: Proxies proxies: Vekil'ler
systemProxy: System Proxy systemProxy: Sistem Vekil'i
tunMode: TUN Mode tunMode: TUN Modu
closeAllConnections: Close All Connections closeAllConnections: Tüm Bağlantıları Kapat
lightweightMode: Lightweight Mode lightweightMode: Hafif Mod
copyEnv: Copy Environment Variables copyEnv: Ortam Değişkenlerini Kopyala
confDir: Configuration Directory confDir: Yapılandırma Dizini
coreDir: Core Directory coreDir: Çekirdek Dizini
logsDir: Log Directory logsDir: Günlük Dizini
openDir: Open Directory openDir: Dizini Aç
appLog: Application Log appLog: Uygulama Günlüğü
coreLog: Core Log coreLog: Çekirdek Günlüğü
restartClash: Restart Clash Core restartClash: Clash Çekirdeğini Yeniden Başlat
restartApp: Restart Application restartApp: Uygulamayı Yeniden Başlat
vergeVersion: Verge Version vergeVersion: Verge Sürümü
more: More more: Daha Fazla
exit: Exit exit: Çıkış
tooltip: tooltip:
systemProxy: System Proxy systemProxy: Sistem Vekil'i
tun: TUN tun: TUN
profile: Profile profile: Profil
+46 -46
View File
@@ -1,60 +1,60 @@
_version: 1 _version: 1
notifications: notifications:
dashboardToggled: dashboardToggled:
title: Dashboard title: Идарә панеле
body: Dashboard visibility has been updated. body: Идарә панеленең күренеше яңартылды.
clashModeChanged: clashModeChanged:
title: Mode Switch title: Режим алыштыру
body: Switched to {mode}. body: "{mode} режимына күчтел."
systemProxyToggled: systemProxyToggled:
title: System Proxy title: Системалы прокси
body: System proxy status has been updated. body: Системалы прокси хәле яңартылды.
tunModeToggled: tunModeToggled:
title: TUN Mode title: TUN режимы
body: TUN mode status has been updated. body: TUN режимы хәле яңартылды.
lightweightModeEntered: lightweightModeEntered:
title: Lightweight Mode title: Җиңел режим
body: Entered lightweight mode. body: Җиңел режимга күчелде.
profilesReactivated: profilesReactivated:
title: Profiles title: Профильләр
body: Profile Reactivated. body: Профиль яңадан активлаштырылды.
appQuit: appQuit:
title: About to Exit title: Чыгар алдыннан
body: Clash Verge is about to exit. body: Clash Verge чыгарга җыена.
appHidden: appHidden:
title: Application Hidden title: Кушымта яшерелде
body: Clash Verge is running in the background. body: Clash Verge фон режимында эшли.
service: service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
tray: tray:
dashboard: Dashboard dashboard: Идарә панеле
ruleMode: Rule Mode ruleMode: Кагыйдә режимы
globalMode: Global Mode globalMode: Глобаль режим
directMode: Direct Mode directMode: Турыдан-туры режим
outboundModes: Outbound Modes outboundModes: Чыгыш режимнары
rule: Rule rule: Кагыйдә
direct: Direct direct: Турыдан-туры
global: Global global: Глобаль
profiles: Profiles profiles: Профильләр
proxies: Proxies proxies: Проксилар
systemProxy: System Proxy systemProxy: Системалы прокси
tunMode: TUN Mode tunMode: TUN режимы
closeAllConnections: Close All Connections closeAllConnections: Барлык тоташуларны ябу
lightweightMode: Lightweight Mode lightweightMode: Җиңел режим
copyEnv: Copy Environment Variables copyEnv: Мохит үзгәрүчәннәрен күчерү
confDir: Configuration Directory confDir: Конфигурация каталогы
coreDir: Core Directory coreDir: Ядро каталогы
logsDir: Log Directory logsDir: Журнал каталогы
openDir: Open Directory openDir: Каталогны ачу
appLog: Application Log appLog: Кушымта журналы
coreLog: Core Log coreLog: Ядро журналы
restartClash: Restart Clash Core restartClash: Clash ядрәсен кабат җибәрү
restartApp: Restart Application restartApp: Кушымтаны кабат җибәрү
vergeVersion: Verge Version vergeVersion: Verge версиясе
more: More more: Күбрәк
exit: Exit exit: Чыгу
tooltip: tooltip:
systemProxy: System Proxy systemProxy: Системалы прокси
tun: TUN tun: TUN
profile: Profile profile: Профиль
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "clash-verge-limiter"
version = "0.1.0"
edition = "2024"
[dependencies]
[lints]
workspace = true
+165
View File
@@ -0,0 +1,165 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub type SystemLimiter = Limiter<SystemClock>;
pub trait Clock: Send + Sync {
fn now_ms(&self) -> u64;
}
impl<T: Clock + ?Sized> Clock for &T {
fn now_ms(&self) -> u64 {
(**self).now_ms()
}
}
impl<T: Clock + ?Sized> Clock for Arc<T> {
fn now_ms(&self) -> u64 {
(**self).now_ms()
}
}
pub struct SystemClock;
impl Clock for SystemClock {
fn now_ms(&self) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
}
pub struct Limiter<C: Clock = SystemClock> {
last_run_ms: AtomicU64,
period_ms: u64,
clock: C,
}
impl<C: Clock> Limiter<C> {
pub const fn new(period: Duration, clock: C) -> Self {
Self {
last_run_ms: AtomicU64::new(0),
period_ms: period.as_millis() as u64,
clock,
}
}
pub fn check(&self) -> bool {
let now = self.clock.now_ms();
let last = self.last_run_ms.load(Ordering::Relaxed);
if now < last + self.period_ms && now >= last {
return false;
}
self.last_run_ms
.compare_exchange(last, now, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
}
}
#[cfg(test)]
mod extra_tests {
use super::*;
use std::sync::Arc;
use std::thread;
struct MockClock(AtomicU64);
impl Clock for MockClock {
fn now_ms(&self) -> u64 {
self.0.load(Ordering::SeqCst)
}
}
#[test]
fn test_zero_period_always_passes() {
let mock = MockClock(AtomicU64::new(100));
let limiter = Limiter::new(Duration::from_millis(0), &mock);
assert!(limiter.check());
assert!(limiter.check());
}
#[test]
fn test_boundary_condition() {
let period_ms = 100;
let mock = MockClock(AtomicU64::new(1000));
let limiter = Limiter::new(Duration::from_millis(period_ms), &mock);
assert!(limiter.check());
mock.0.store(1099, Ordering::SeqCst);
assert!(!limiter.check());
mock.0.store(1100, Ordering::SeqCst);
assert!(limiter.check(), "Should pass exactly at period boundary");
}
#[test]
fn test_high_concurrency_consistency() {
let period = Duration::from_millis(1000);
let mock = Arc::new(MockClock(AtomicU64::new(1000)));
let limiter = Arc::new(Limiter::new(period, Arc::clone(&mock)));
assert!(limiter.check());
mock.0.store(2500, Ordering::SeqCst);
let mut handles = vec![];
for _ in 0..20 {
let l = Arc::clone(&limiter);
handles.push(thread::spawn(move || l.check()));
}
#[allow(clippy::unwrap_used)]
let results: Vec<bool> = handles.into_iter().map(|h| h.join().unwrap()).collect();
let success_count = results.iter().filter(|&&x| x).count();
assert_eq!(success_count, 1);
assert_eq!(limiter.last_run_ms.load(Ordering::SeqCst), 2500);
}
#[test]
fn test_extreme_time_jump() {
let mock = MockClock(AtomicU64::new(100));
let limiter = Limiter::new(Duration::from_millis(100), &mock);
assert!(limiter.check());
mock.0.store(u64::MAX - 10, Ordering::SeqCst);
assert!(limiter.check());
}
#[test]
fn test_system_clock_real_path() {
let clock = SystemClock;
let start = clock.now_ms();
assert!(start > 0);
std::thread::sleep(Duration::from_millis(10));
assert!(clock.now_ms() >= start);
}
#[test]
fn test_limiter_with_system_clock_default() {
let limiter = Limiter::new(Duration::from_millis(100), SystemClock);
assert!(limiter.check());
}
#[test]
fn test_coverage_time_backward() {
let mock = MockClock(AtomicU64::new(5000));
let limiter = Limiter::new(Duration::from_millis(100), &mock);
assert!(limiter.check());
mock.0.store(4000, Ordering::SeqCst);
assert!(limiter.check(), "Should pass and reset when time moves backward");
assert_eq!(limiter.last_run_ms.load(Ordering::SeqCst), 4000);
}
}
+14 -12
View File
@@ -14,18 +14,20 @@ where
F: Fn() -> Fut + Send + Sync + 'static, F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future + Send + 'static, Fut: Future + Send + 'static,
{ {
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() { RUNTIME.get_or_init(
Ok(rt) => Some(rt), || match tokio::runtime::Builder::new_current_thread().enable_all().build() {
Err(e) => { Ok(rt) => Some(rt),
logging!( Err(e) => {
info, logging!(
Type::SystemSignal, info,
"register shutdown signal failed, create tokio runtime error: {}", Type::SystemSignal,
e "register shutdown signal failed, create tokio runtime error: {}",
); e
None );
} None
}); }
},
);
#[cfg(unix)] #[cfg(unix)]
unix::register(f); unix::register(f);
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "clash-verge-types"
version = "0.1.0"
edition = "2024"
rust-version = "1.91"
[dependencies]
serde = { workspace = true }
serde_yaml_ng = { workspace = true }
smartstring = { workspace = true }
[lints]
workspace = true
-1
View File
@@ -1 +0,0 @@
pub mod runtime;
@@ -8,10 +8,12 @@ rust-version = "1.91"
tauri = { workspace = true } tauri = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true } tauri-plugin-clipboard-manager = { workspace = true }
parking_lot = { workspace = true } parking_lot = { workspace = true }
sysinfo = { version = "0.37.2", features = ["network", "system"] } # sysinfo 0.38.2 conflicts with dark-light
# see https://github.com/GuillaumeGomez/sysinfo/issues/1623
sysinfo = { version = "0.38", features = ["network", "system"] }
[target.'cfg(not(windows))'.dependencies] [target.'cfg(not(windows))'.dependencies]
libc = "0.2.180" libc = "0.2.182"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
deelevate = { workspace = true } deelevate = { workspace = true }
@@ -120,6 +120,12 @@ fn is_binary_admin() -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
#[inline]
#[cfg(unix)]
pub fn current_gid() -> u32 {
unsafe { libc::getgid() }
}
#[inline] #[inline]
pub fn list_network_interfaces() -> Vec<String> { pub fn list_network_interfaces() -> Vec<String> {
let mut networks = Networks::new(); let mut networks = Networks::new();
+98
View File
@@ -1,3 +1,101 @@
## v(2.4.6)
> [!IMPORTANT]
> 历经多轮磨合与修正,这是自 2.0 以来我们最满意的里程碑版本。建议所有用户立即升级。
### 🐞 修复问题
- 修复首次启动时代理信息刷新缓慢
- 修复无网络时无限请求 IP 归属查询
- 修复 WebDAV 页面重试逻辑
- 修复 Linux 通过 GUI 安装服务模式权限不符合预期
- 修复 macOS 因网口顺序导致无法正确设置代理
- 修复恢复休眠后无法操作托盘
- 修复首页当前节点图标语义显示不一致
- 修复使用 URL scheme 导入订阅时没有及时重载配置
- 修复规则界面里的行号展示逻辑
- 修复 Windows 托盘打开日志失败
- 修复 KDE 首次启动报错
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 升级 Mihomo 内核到最新
- 支持订阅设置自动延时监测间隔
- 新增流量隧道管理界面,支持可视化添加/删除隧道配置
- Masque 协议的 GUI 支持
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 安装服务失败时报告更详细的错误
- 避免脏订阅地址无法 Scheme 导入订阅
- macOS TUN 覆盖 DNS 时使用 114.114.114.114
- 连通性测试替换为更快的 http://1.0.0.1
- 连接、规则、日志等页面的过滤搜索组件新增了清空输入框按钮
- 链式代理增加明显的入口出口与数据流向标识
- 优化 IP 信息卡
- 美化代理组图标样式
- 移除 Linux resources 文件夹下多余的服务二进制文件
</details>
## v2.4.5
- **Mihomo(Meta) 内核升级至 v1.19.19**
### 🐞 修复问题
- 修复 macOS 有线网络 DNS 劫持失败
- 修复 Monaco 编辑器内右键菜单显示异常
- 修复设置代理端口时检查端口占用
- 修复 Monaco 编辑器初始化卡 Loading
- 修复恢复备份时 `config.yaml` / `profiles.yaml` 文件内字段未正确恢复
- 修复 Windows 下系统主题同步问题
- 修复 URL Schemes 无法正常导入
- 修复 Linux 下无法安装 TUN 服务
- 修复可能的端口被占用误报
- 修复设置允许外部控制来源不能立即生效
- 修复前端性能回归问题
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 允许代理页面允许高级过滤搜索
- 备份设置页面新增导入备份按钮
- 允许修改通知弹窗位置
- 支持收起导航栏(导航栏右键菜单 / 界面设置)
- 允许将出站模式显示在托盘一级菜单
- 允许禁用在托盘中显示代理组
- 支持在「编辑节点」中直接导入 AnyTLS URI 配置
- 支持关闭「验证代理绕过格式」
- 新增系统代理绕过和 TUN 排除自定义网段的可视化编辑器
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 应用内更新日志支持解析并渲染 HTML 标签
- 性能优化前后端在渲染流量图时的资源
- 在 Linux NVIDIA 显卡环境下尝试禁用 WebKit DMABUF 渲染以规避潜在问题
- Windows 下自启动改为计划任务实现
- 改进托盘和窗口操作频率限制实现
- 使用「编辑节点」添加节点时,自动将节点添加到第一个 `select` 类型的代理组的第一位
- 隐藏侧边导航栏和悬浮跳转导航的滚动条
- 完善对 AnyTLS / Mieru / Sudoku 的 GUI 支持
- macOS 和 Linux 对服务 IPC 权限进一步限制
- 移除 Windows 自启动计划任务中冗余的 3 秒延时
- 右键错误通知可复制错误详情
- 保存 TUN 设置时优化执行流程,避免界面卡顿
- 补充 `deb` / `rpm` 依赖 `libayatana-appindicator`
- 「连接」表格标题的排序点击区域扩展到整列宽度
- 备份恢复时显示加载覆盖层,恢复过程无需再手动关闭对话框
</details>
## v2.4.4 ## v2.4.4
- **Mihomo(Meta) 内核升级至 v1.19.17** - **Mihomo(Meta) 内核升级至 v1.19.17**
-13
View File
@@ -69,19 +69,6 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
🌐 Official Website: 👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) 🌐 Official Website: 👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### Build Infrastructure Sponsor — [YXVM Dedicated Servers](https://yxvm.com/aff.php?aff=827)
Our builds and releases run on YXVM dedicated servers that deliver premium resources, strong performance, and high-speed networking. If downloads feel fast and usage feels snappy, it is thanks to robust hardware.
🧩 Highlights of YXVM Dedicated Servers:
- 🌎 Optimized global routes for dramatically faster downloads
- 🔧 Bare-metal resources instead of shared VPS capacity for maximum performance
- 🧠 Great for proxy workloads, hosting web/CDN services, CI/CD pipelines, or any high-load tasks
- 💡 Ready to use instantly with multiple datacenter options, including CN2 and IEPL
- 📦 The configuration used by this project is on sale—feel free to get the same setup
- 🎯 Want the same build environment? [Order a YXVM server today](https://yxvm.com/aff.php?aff=827)
## Features ## Features
- Built on high-performance Rust with the Tauri 2 framework - Built on high-performance Rust with the Tauri 2 framework
-13
View File
@@ -63,19 +63,6 @@ Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) pa
- Desbloquea servicios de streaming y acceso a ChatGPT - Desbloquea servicios de streaming y acceso a ChatGPT
- Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) - Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### Patrocinador de la infraestructura de compilación — [Servidores dedicados YXVM](https://yxvm.com/aff.php?aff=827)
Las compilaciones y lanzamientos del proyecto se ejecutan en servidores dedicados de YXVM, que proporcionan recursos premium, alto rendimiento y redes de alta velocidad. Si las descargas son rápidas y el uso es fluido, es gracias a este hardware robusto.
🧩 Ventajas de los servidores dedicados YXVM:
- 🌎 Rutas globales optimizadas para descargas significativamente más rápidas
- 🔧 Recursos bare-metal, en lugar de VPS compartidos, para obtener el máximo rendimiento
- 🧠 Ideales para proxys, alojamiento de sitios web/CDN, pipelines de CI/CD o cualquier carga elevada
- 💡 Listos para usar al instante, con múltiples centros de datos disponibles (incluidos CN2 e IEPL)
- 📦 La misma configuración utilizada por este proyecto está disponible para su compra
- 🎯 ¿Quieres el mismo entorno de compilación? [Solicita un servidor YXVM hoy](https://yxvm.com/aff.php?aff=827)
## Funciones ## Funciones
- Basado en Rust de alto rendimiento y en el framework Tauri 2 - Basado en Rust de alto rendimiento y en el framework Tauri 2
-12
View File
@@ -62,18 +62,6 @@
- پشتیبانی از سرویس‌های استریم و دسترسی به ChatGPT - پشتیبانی از سرویس‌های استریم و دسترسی به ChatGPT
- وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) - وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### حامی زیرساخت ساخت — [سرورهای اختصاصی YXVM](https://yxvm.com/aff.php?aff=827)
بیلدها و نسخه‌های ما روی سرورهای اختصاصی YXVM اجرا می‌شوند که منابع ممتاز، عملکرد قوی و شبکه پرسرعت را ارائه می‌دهند. اگر دانلودها سریع و استفاده از آن سریع به نظر می‌رسد، به لطف سخت‌افزار قوی است.
🧩 نکات برجسته سرورهای اختصاصی YXVM:
- 🌎 مسیرهای جهانی بهینه شده برای دانلودهای بسیار سریعتر
- 🔧 منابع فیزیکی به جای ظرفیت VPS مشترک برای حداکثر کارایی
- 🧠 عالی برای بارهای کاری پروکسی، میزبانی سرویس‌های وب/CDN، خطوط لوله CI/CD یا هرگونه کار با بار بالا
- 💡 آماده استفاده فوری با گزینه‌های متعدد مرکز داده، از جمله CN2 و IEPL
- 📦 پیکربندی مورد استفاده در این پروژه در حال فروش است - می‌توانید همان تنظیمات را تهیه کنید.
- 🎯 آیا محیط ساخت مشابهی می‌خواهید؟ [همین امروز یک سرور YXVM سفارش دهید](https://yxvm.com/aff.php?aff=827)
## ویژگی‌ها ## ویژگی‌ها
- ساخته شده بر اساس Rust با کارایی بالا و فریم‌ورک Tauri 2 - ساخته شده بر اساس Rust با کارایی بالا و فریم‌ورک Tauri 2
-13
View File
@@ -63,19 +63,6 @@ Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポー
- ストリーミングおよび ChatGPT の利用にも対応 - ストリーミングおよび ChatGPT の利用にも対応
- 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) - 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### ビルド環境スポンサー — [YXVM 専用サーバー](https://yxvm.com/aff.php?aff=827)
本プロジェクトのビルドとリリースは、YXVM の専用サーバーによって支えられています。高速ダウンロードや快適な操作性は、強力なハードウェアがあってこそです。
🧩 YXVM 専用サーバーの特長:
- 🌎 最適化されたグローバル回線で圧倒的なダウンロード速度
- 🔧 VPS とは異なるベアメタル資源で最高性能を発揮
- 🧠 プロキシ運用、Web/CDN ホスティング、CI/CD など高負荷ワークロードに最適
- 💡 複数データセンターから即時利用可能。CN2 や IEPL も選択可
- 📦 本プロジェクトが使用している構成も販売中。同じ環境を入手できます
- 🎯 同じビルド体験をしたい方は [今すぐ YXVM サーバーを注文](https://yxvm.com/aff.php?aff=827)
## 機能 ## 機能
- 高性能な Rust と Tauri 2 フレームワークに基づくデスクトップアプリ - 高性能な Rust と Tauri 2 フレームワークに基づくデスクトップアプリ
-13
View File
@@ -63,19 +63,6 @@ Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니
- 스트리밍 및 ChatGPT 접근 지원 - 스트리밍 및 ChatGPT 접근 지원
- 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) - 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### 빌드 인프라 스폰서 — [YXVM 전용 서버](https://yxvm.com/aff.php?aff=827)
본 프로젝트의 빌드 및 릴리스는 YXVM 전용 서버에서 구동됩니다. 빠른 다운로드와 경쾌한 사용감은 탄탄한 하드웨어 덕분입니다.
🧩 YXVM 전용 서버 하이라이트:
- 🌎 최적화된 글로벌 라우팅으로 대폭 빨라진 다운로드
- 🔧 공유 VPS가 아닌 베어메탈 자원으로 최대 성능 제공
- 🧠 프록시 워크로드, Web/CDN 호스팅, CI/CD, 고부하 작업에 적합
- 💡 CN2 / IEPL 등 다양한 데이터센터 옵션, 즉시 사용 가능
- 📦 본 프로젝트가 사용하는 구성도 판매 중 — 동일한 환경을 사용할 수 있습니다
- 🎯 동일한 빌드 환경이 필요하다면 [지금 YXVM 서버 주문](https://yxvm.com/aff.php?aff=827)
## 기능 ## 기능
- 고성능 Rust와 Tauri 2 프레임워크 기반 데스크톱 앱 - 고성능 Rust와 Tauri 2 프레임워크 기반 데스크톱 앱
-13
View File
@@ -59,19 +59,6 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
- Разблокировка потоковые сервисы и ChatGPT - Разблокировка потоковые сервисы и ChatGPT
- Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) - Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### Среда сборки и публикации этого проекта полностью поддерживается выделенным сервером [YXVM](https://yxvm.com/aff.php?aff=827)
Благодарим вас за предоставление надежной бэкэнд-среды с эксклюзивными ресурсами, высокой производительностью и высокоскоростной сетью. Если вы считаете, что загрузка файлов происходит достаточно быстро, а использование — достаточно плавно, то это потому, что мы используем серверы высшего уровня!
🧩 Преимущества выделенного сервера YXVM:
- 🌎 Премиум-сеть с оптимизацией обратного пути для молниеносной скорости загрузки
- 🔧 Выделенные физические серверные ресурсы, не имеющие аналогов среди VPS, обеспечивающие максимальную производительность
- 🧠 Идеально подходит для прокси, хостинга веб-сайтов/CDN-сайтов, рабочих процессов CI/CD или любых приложений с высокой нагрузкой
- 💡 Поддержка использования сразу после включения, выбор нескольких дата-центров, CN2 / IEPL на выбор
- 📦 Эта конфигурация в настоящее время доступна для покупки — не стесняйтесь заказывать ту же модель!
- 🎯 Хотите попробовать такую же сборку? [Закажите выделенный сервер YXVM прямо сейчас!](https://yxvm.com/aff.php?aff=827)
## Фичи ## Фичи
- Основан на произвоительном Rust и фреймворке Tauri 2 - Основан на произвоительном Rust и фреймворке Tauri 2
+5
View File
@@ -37,6 +37,11 @@ export default defineConfig([
languageOptions: { languageOptions: {
globals: globals.browser, globals: globals.browser,
parserOptions: {
projectService: {
allowDefaultProject: ["src/polyfills/*.js"],
},
},
}, },
settings: { settings: {
+41 -39
View File
@@ -1,6 +1,6 @@
{ {
"name": "clash-verge", "name": "clash-verge",
"version": "2.4.5-rc.1", "version": "2.4.7",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {
"prepare": "husky || true", "prepare": "husky || true",
@@ -41,80 +41,82 @@
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@juggle/resize-observer": "^3.4.0", "@juggle/resize-observer": "^3.4.0",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^7.3.7", "@mui/icons-material": "^7.3.8",
"@mui/lab": "7.0.0-beta.17", "@mui/lab": "7.0.0-beta.17",
"@mui/material": "^7.3.7", "@mui/material": "^7.3.8",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.18",
"@tauri-apps/api": "2.9.1", "@tauri-apps/api": "2.10.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-http": "~2.5.5", "@tauri-apps/plugin-http": "~2.5.7",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "2.3.4", "@tauri-apps/plugin-shell": "2.3.5",
"@tauri-apps/plugin-updater": "2.9.0", "@tauri-apps/plugin-updater": "2.10.0",
"ahooks": "^3.9.6", "ahooks": "^3.9.6",
"axios": "^1.13.2", "axios": "^1.13.5",
"dayjs": "1.11.19", "dayjs": "1.11.19",
"foxact": "^0.2.52", "foxact": "^0.2.52",
"i18next": "^25.7.4", "foxts": "^5.2.1",
"i18next": "^25.8.13",
"ipaddr.js": "^2.3.0",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"lodash-es": "^4.17.22", "lodash-es": "^4.17.23",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"monaco-yaml": "^5.4.0", "monaco-yaml": "^5.4.1",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"react": "19.2.3", "react": "19.2.4",
"react-dom": "19.2.3", "react-dom": "19.2.4",
"react-error-boundary": "6.0.3", "react-error-boundary": "6.1.1",
"react-hook-form": "^7.71.0", "react-hook-form": "^7.71.2",
"react-i18next": "16.5.2", "react-i18next": "16.5.4",
"react-markdown": "10.1.0", "react-markdown": "10.1.0",
"react-router": "^7.12.0", "react-router": "^7.13.0",
"react-virtuoso": "^4.18.1", "react-virtuoso": "^4.18.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"swr": "^2.3.8", "swr": "^2.4.0",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main", "tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
"types-pac": "^1.0.3" "types-pac": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {
"@actions/github": "^7.0.0", "@actions/github": "^9.0.0",
"@eslint-react/eslint-plugin": "^2.5.5", "@eslint-react/eslint-plugin": "^2.13.0",
"@eslint/js": "^9.39.2", "@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.9.6", "@tauri-apps/cli": "2.10.0",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.7", "@types/node": "^24.10.13",
"@types/react": "19.2.8", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@vitejs/plugin-legacy": "^7.2.1", "@vitejs/plugin-legacy": "^7.2.1",
"@vitejs/plugin-react-swc": "^4.2.2", "@vitejs/plugin-react-swc": "^4.2.3",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"cli-color": "^2.0.4", "cli-color": "^2.0.4",
"commander": "^14.0.2", "commander": "^14.0.3",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"eslint": "^9.39.2", "eslint": "^10.0.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4", "eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1", "eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-compiler": "19.1.0-rc.2", "eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.5.0",
"eslint-plugin-unused-imports": "^4.3.0", "eslint-plugin-unused-imports": "^4.4.1",
"glob": "^13.0.0", "glob": "^13.0.6",
"globals": "^17.0.0", "globals": "^17.3.0",
"https-proxy-agent": "^7.0.6", "https-proxy-agent": "^7.0.6",
"husky": "^9.1.7", "husky": "^9.1.7",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"prettier": "^3.7.4", "prettier": "^3.8.1",
"sass": "^1.97.2", "sass": "^1.97.3",
"tar": "^7.5.2", "tar": "^7.5.9",
"terser": "^5.44.1", "terser": "^5.46.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.52.0", "typescript-eslint": "^8.56.0",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-svgr": "^4.5.0" "vite-plugin-svgr": "^4.5.0"
}, },
@@ -128,7 +130,7 @@
] ]
}, },
"type": "module", "type": "module",
"packageManager": "pnpm@10.28.0", "packageManager": "pnpm@10.29.2",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@parcel/watcher", "@parcel/watcher",
+1883 -1926
View File
File diff suppressed because it is too large Load Diff
+16 -10
View File
@@ -68,6 +68,11 @@ const SIDECAR_HOST = target
.toString() .toString()
.match(/(?<=host: ).+(?=\s*)/g)[0]; .match(/(?<=host: ).+(?=\s*)/g)[0];
const RESOURCES_DIR = path.join(cwd, "src-tauri", "resources");
const SIDECAR_DIR = path.join(cwd, "src-tauri", "sidecar");
// Linux service binaries are bundled as externalBin sidecars (see tauri.linux.conf.json)
const SERVICE_DIR = platform === "linux" ? SIDECAR_DIR : RESOURCES_DIR;
// ======================= // =======================
// Version Cache // Version Cache
// ======================= // =======================
@@ -369,9 +374,8 @@ async function downloadFile(url, outPath) {
// ======================= // =======================
async function resolveSidecar(binInfo) { async function resolveSidecar(binInfo) {
const { name, targetFile, zipFile, exeFile, downloadURL } = binInfo; const { name, targetFile, zipFile, exeFile, downloadURL } = binInfo;
const sidecarDir = path.join(cwd, "src-tauri", "sidecar"); const sidecarPath = path.join(SIDECAR_DIR, targetFile);
const sidecarPath = path.join(sidecarDir, targetFile); await fsp.mkdir(SIDECAR_DIR, { recursive: true });
await fsp.mkdir(sidecarDir, { recursive: true });
if (!FORCE && fs.existsSync(sidecarPath)) { if (!FORCE && fs.existsSync(sidecarPath)) {
log_success(`"${name}" already exists, skipping download`); log_success(`"${name}" already exists, skipping download`);
@@ -460,9 +464,9 @@ async function resolveSidecar(binInfo) {
} }
async function resolveResource(binInfo) { async function resolveResource(binInfo) {
const { file, downloadURL, localPath } = binInfo; const { file, downloadURL, localPath, dir } = binInfo;
const resDir = path.join(cwd, "src-tauri/resources"); const baseDir = dir ?? RESOURCES_DIR;
const targetPath = path.join(resDir, file); const targetPath = path.join(baseDir, file);
if (!FORCE && fs.existsSync(targetPath) && !downloadURL && !localPath) { if (!FORCE && fs.existsSync(targetPath) && !downloadURL && !localPath) {
log_success(`"${file}" already exists, skipping`); log_success(`"${file}" already exists, skipping`);
@@ -474,7 +478,7 @@ async function resolveResource(binInfo) {
log_success(`"${file}" already exists, skipping download`); log_success(`"${file}" already exists, skipping download`);
return; return;
} }
await fsp.mkdir(resDir, { recursive: true }); await fsp.mkdir(baseDir, { recursive: true });
await downloadFile(downloadURL, targetPath); await downloadFile(downloadURL, targetPath);
await updateHashCache(targetPath); await updateHashCache(targetPath);
} }
@@ -483,7 +487,7 @@ async function resolveResource(binInfo) {
if (!(await hasFileChanged(localPath, targetPath))) { if (!(await hasFileChanged(localPath, targetPath))) {
return; return;
} }
await fsp.mkdir(resDir, { recursive: true }); await fsp.mkdir(baseDir, { recursive: true });
await fsp.copyFile(localPath, targetPath); await fsp.copyFile(localPath, targetPath);
await updateHashCache(targetPath); await updateHashCache(targetPath);
log_success(`Copied file: ${file}`); log_success(`Copied file: ${file}`);
@@ -545,12 +549,11 @@ const resolveServicePermission = async () => {
"clash-verge-service-install*", "clash-verge-service-install*",
"clash-verge-service-uninstall*", "clash-verge-service-uninstall*",
]; ];
const resDir = path.join(cwd, "src-tauri/resources");
const hashCache = await loadHashCache(); const hashCache = await loadHashCache();
let hasChanges = false; let hasChanges = false;
for (const f of serviceExecutables) { for (const f of serviceExecutables) {
const files = glob.sync(path.join(resDir, f)); const files = glob.sync(path.join(SERVICE_DIR, f));
for (const filePath of files) { for (const filePath of files) {
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
const currentHash = await calculateFileHash(filePath); const currentHash = await calculateFileHash(filePath);
@@ -584,6 +587,7 @@ const resolveService = () => {
const suffix = platform === "linux" ? "-" + SIDECAR_HOST : ""; const suffix = platform === "linux" ? "-" + SIDECAR_HOST : "";
return resolveResource({ return resolveResource({
file: "clash-verge-service" + suffix + ext, file: "clash-verge-service" + suffix + ext,
dir: SERVICE_DIR,
downloadURL: `${SERVICE_URL}/clash-verge-service${ext}`, downloadURL: `${SERVICE_URL}/clash-verge-service${ext}`,
}); });
}; };
@@ -592,6 +596,7 @@ const resolveInstall = () => {
const suffix = platform === "linux" ? "-" + SIDECAR_HOST : ""; const suffix = platform === "linux" ? "-" + SIDECAR_HOST : "";
return resolveResource({ return resolveResource({
file: "clash-verge-service-install" + suffix + ext, file: "clash-verge-service-install" + suffix + ext,
dir: SERVICE_DIR,
downloadURL: `${SERVICE_URL}/clash-verge-service-install${ext}`, downloadURL: `${SERVICE_URL}/clash-verge-service-install${ext}`,
}); });
}; };
@@ -600,6 +605,7 @@ const resolveUninstall = () => {
const suffix = platform === "linux" ? "-" + SIDECAR_HOST : ""; const suffix = platform === "linux" ? "-" + SIDECAR_HOST : "";
return resolveResource({ return resolveResource({
file: "clash-verge-service-uninstall" + suffix + ext, file: "clash-verge-service-uninstall" + suffix + ext,
dir: SERVICE_DIR,
downloadURL: `${SERVICE_URL}/clash-verge-service-uninstall${ext}`, downloadURL: `${SERVICE_URL}/clash-verge-service-uninstall${ext}`,
}); });
}; };
+2 -1
View File
@@ -90,7 +90,8 @@ async function sendTelegramNotification() {
const releaseTitle = isAutobuild ? "滚动更新版发布" : "正式发布"; const releaseTitle = isAutobuild ? "滚动更新版发布" : "正式发布";
const encodedVersion = encodeURIComponent(version); const encodedVersion = encodeURIComponent(version);
const content = `<b>🎉 <a href="https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild">Clash Verge Rev v${version}</a> ${releaseTitle}</b>\n\n${formattedContent}`; const releaseTag = isAutobuild ? "autobuild" : `v${version}`;
const content = `<b>🎉 <a href="https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/${releaseTag}">Clash Verge Rev v${version}</a> ${releaseTitle}</b>\n\n${formattedContent}`;
// 发送到 Telegram // 发送到 Telegram
try { try {
+20 -36
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "clash-verge" name = "clash-verge"
version = "2.4.5-rc.1" version = "2.4.7"
description = "clash verge" description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"] authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only" license = "GPL-3.0-only"
@@ -22,20 +22,19 @@ tauri-dev = []
tokio-trace = ["console-subscriber"] tokio-trace = ["console-subscriber"]
clippy = ["tauri/test"] clippy = ["tauri/test"]
tracing = [] tracing = []
tracing-full = []
[package.metadata.bundle] [package.metadata.bundle]
identifier = "io.github.clash-verge-rev.clash-verge-rev" identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.5.3", features = [] } tauri-build = { version = "2.5.5", features = [] }
[dependencies] [dependencies]
clash-verge-draft = { workspace = true } clash-verge-draft = { workspace = true }
clash-verge-logging = { workspace = true } clash-verge-logging = { workspace = true }
clash-verge-signal = { workspace = true } clash-verge-signal = { workspace = true }
clash-verge-types = { workspace = true }
clash-verge-i18n = { workspace = true } clash-verge-i18n = { workspace = true }
clash-verge-limiter = { workspace = true }
tauri-plugin-clash-verge-sysinfo = { workspace = true } tauri-plugin-clash-verge-sysinfo = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true } tauri-plugin-clipboard-manager = { workspace = true }
tauri = { workspace = true, features = [ tauri = { workspace = true, features = [
@@ -60,78 +59,63 @@ warp = { version = "0.4.2", features = ["server"] }
open = "5.3.3" open = "5.3.3"
dunce = "1.0.5" dunce = "1.0.5"
nanoid = "0.4" nanoid = "0.4"
chrono = "0.4.42" chrono = "0.4.43"
boa_engine = "0.21.0" boa_engine = "0.21.0"
once_cell = { version = "1.21.3", features = ["parking_lot"] } once_cell = { version = "1.21.3", features = ["parking_lot"] }
delay_timer = "0.11.6" delay_timer = "0.11.6"
percent-encoding = "2.3.2" percent-encoding = "2.3.2"
reqwest = { version = "0.13.1", features = [ reqwest = { version = "0.13.2", features = [
"json", "json",
"cookies", "cookies",
"rustls", "rustls",
"form", "form",
] } ] }
regex = "1.12.2" regex = "1.12.3"
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", features = [ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", branch = "0.4.5", features = [
"guard", "guard",
] } ] }
network-interface = { version = "2.0.5", features = ["serde"] } network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.3.4" tauri-plugin-shell = "2.3.5"
tauri-plugin-dialog = "2.5.0" tauri-plugin-dialog = "2.6.0"
tauri-plugin-fs = "2.4.5" tauri-plugin-fs = "2.4.5"
tauri-plugin-process = "2.3.1" tauri-plugin-process = "2.3.1"
tauri-plugin-deep-link = "2.4.6" tauri-plugin-deep-link = "2.4.7"
tauri-plugin-window-state = "2.4.1" tauri-plugin-window-state = "2.4.1"
zip = "7.0.0" zip = "8.1.0"
reqwest_dav = "0.2.2" reqwest_dav = "0.3.2"
aes-gcm = { version = "0.10.3", features = ["std"] } aes-gcm = { version = "0.10.3", features = ["std"] }
base64 = "0.22.1" base64 = "0.22.1"
getrandom = "0.3.4" getrandom = "0.4.1"
futures = "0.3.31" futures = "0.3.32"
gethostname = "1.1.0" gethostname = "1.1.0"
scopeguard = "1.2.0" scopeguard = "1.2.0"
tauri-plugin-notification = "2.3.3" tauri-plugin-notification = "2.3.3"
tokio-stream = "0.1.18" tokio-stream = "0.1.18"
backoff = { version = "0.4.0", features = ["tokio"] } backoff = { version = "0.4.0", features = ["tokio"] }
tauri-plugin-http = "2.5.5" tauri-plugin-http = "2.5.7"
console-subscriber = { version = "0.5.0", optional = true } console-subscriber = { version = "0.5.0", optional = true }
tauri-plugin-devtools = { version = "2.0.1" } tauri-plugin-devtools = { version = "2.0.1" }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo" } 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" } clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" }
async-trait = "0.1.89" async-trait = "0.1.89"
clash_verge_service_ipc = { version = "2.0.29", features = [ clash_verge_service_ipc = { version = "2.1.3", features = [
"client", "client",
], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" } ], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" }
arc-swap = "1.8.0" arc-swap = "1.8.2"
rust_iso3166 = "0.1.14" rust_iso3166 = "0.1.14"
# Use the git repo until the next release after v2.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" } dark-light = { git = "https://github.com/rust-dark-light/dark-light" }
governor = "0.10.4"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
deelevate = { workspace = true } deelevate = { workspace = true }
runas = "=1.2.0" runas = "=1.2.0"
winreg = "0.55.0" winreg = "0.55.0"
winapi = { version = "0.3.9", features = [ windows = { version = "0.62.2", features = ["Win32_Globalization"] }
"winbase",
"fileapi",
"winnt",
"handleapi",
"errhandlingapi",
"minwindef",
"winerror",
"stringapiset",
"tlhelp32",
"processthreadsapi",
"winhttp",
"winreg",
"winnls",
] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.5.1" tauri-plugin-autostart = "2.5.1"
tauri-plugin-global-shortcut = "2.3.1" tauri-plugin-global-shortcut = "2.3.1"
tauri-plugin-updater = "2.9.0" tauri-plugin-updater = "2.10.0"
[dev-dependencies] [dev-dependencies]
criterion = { workspace = true } criterion = { workspace = true }
+8 -2
View File
@@ -45,14 +45,20 @@ pub fn open_web_url(url: String) -> CmdResult<()> {
/// 打开 Verge 最新日志 /// 打开 Verge 最新日志
#[tauri::command] #[tauri::command]
pub async fn open_app_log() -> CmdResult<()> { pub async fn open_app_log() -> CmdResult<()> {
open::that(dirs::app_latest_log().stringify_err()?).stringify_err() let log_path = dirs::app_latest_log().stringify_err()?;
#[cfg(target_os = "windows")]
let log_path = crate::utils::help::snapshot_path(&log_path).stringify_err()?;
open::that(log_path).stringify_err()
} }
// TODO 后续可以为前端提供接口,当前作为托盘菜单使用 // TODO 后续可以为前端提供接口,当前作为托盘菜单使用
/// 打开 Clash 最新日志 /// 打开 Clash 最新日志
#[tauri::command] #[tauri::command]
pub async fn open_core_log() -> CmdResult<()> { pub async fn open_core_log() -> CmdResult<()> {
open::that(dirs::clash_latest_log().stringify_err()?).stringify_err() let log_path = dirs::clash_latest_log().stringify_err()?;
#[cfg(target_os = "windows")]
let log_path = crate::utils::help::snapshot_path(&log_path).stringify_err()?;
open::that(log_path).stringify_err()
} }
/// 打开/关闭开发者工具 /// 打开/关闭开发者工具
+13 -11
View File
@@ -1,5 +1,6 @@
use super::CmdResult; use super::CmdResult;
use super::StringifyErr as _; use super::StringifyErr as _;
use crate::utils::window_manager::WindowManager;
use crate::{ use crate::{
config::{ config::{
Config, IProfiles, PrfItem, PrfOption, Config, IProfiles, PrfItem, PrfOption,
@@ -64,7 +65,7 @@ pub async fn enhance_profiles() -> CmdResult {
/// 导入配置文件 /// 导入配置文件
#[tauri::command] #[tauri::command]
pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult { pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
logging!(info, Type::Cmd, "[导入订阅] 开始导入: {}", url); logging!(info, Type::Cmd, "[导入订阅] 开始导入: {}", help::mask_url(&url));
// 直接依赖 PrfItem::from_url 自身的超时/重试逻辑,不再使用 tokio::time::timeout 包裹 // 直接依赖 PrfItem::from_url 自身的超时/重试逻辑,不再使用 tokio::time::timeout 包裹
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await { let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
@@ -95,19 +96,18 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
if let Some(uid) = &item.uid { if let Some(uid) = &item.uid {
logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid); logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid);
handle::Handle::notify_profile_changed(uid.clone()); handle::Handle::notify_profile_changed(uid);
} }
// 异步保存配置文件并发送全局通知 // 异步保存配置文件并发送全局通知
let uid_clone = item.uid.clone(); if let Some(uid) = &item.uid {
if let Some(uid) = uid_clone {
// 延迟发送,确保文件已完全写入 // 延迟发送,确保文件已完全写入
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid); logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid);
handle::Handle::notify_profile_changed(uid); handle::Handle::notify_profile_changed(uid);
} }
logging!(info, Type::Cmd, "[导入订阅] 导入完成: {}", url); logging!(info, Type::Cmd, "[导入订阅] 导入完成: {}", help::mask_url(&url));
AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange); AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange);
Ok(()) Ok(())
} }
@@ -136,7 +136,7 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
match profiles_append_item_with_filedata_safe(&item, file_data).await { match profiles_append_item_with_filedata_safe(&item, file_data).await {
Ok(_) => { Ok(_) => {
// 发送配置变更通知 // 发送配置变更通知
if let Some(uid) = item.uid.clone() { if let Some(uid) = &item.uid {
logging!(info, Type::Cmd, "[创建订阅] 发送配置变更通知: {}", uid); logging!(info, Type::Cmd, "[创建订阅] 发送配置变更通知: {}", uid);
handle::Handle::notify_profile_changed(uid); handle::Handle::notify_profile_changed(uid);
} }
@@ -157,7 +157,7 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
/// 更新配置文件 /// 更新配置文件
#[tauri::command] #[tauri::command]
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult { pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
match feat::update_profile(&index, option.as_ref(), true, true).await { match feat::update_profile(&index, option.as_ref(), true, true, true).await {
Ok(_) => { Ok(_) => {
let _: () = Config::profiles().await.apply(); let _: () = Config::profiles().await.apply();
Ok(()) Ok(())
@@ -183,7 +183,7 @@ pub async fn delete_profile(index: String) -> CmdResult {
handle::Handle::refresh_clash(); handle::Handle::refresh_clash();
// 发送配置变更通知 // 发送配置变更通知
logging!(info, Type::Cmd, "[删除订阅] 发送配置变更通知: {}", index); logging!(info, Type::Cmd, "[删除订阅] 发送配置变更通知: {}", index);
handle::Handle::notify_profile_changed(index); handle::Handle::notify_profile_changed(&index);
AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange); AutoBackupManager::trigger_backup(AutoBackupTrigger::ProfileChange);
} }
Err(e) => { Err(e) => {
@@ -310,9 +310,11 @@ async fn handle_success(current_value: Option<&String>) -> CmdResult<bool> {
logging!(warn, Type::Cmd, "Warning: 异步保存配置文件失败: {e}"); logging!(warn, Type::Cmd, "Warning: 异步保存配置文件失败: {e}");
} }
if let Some(current) = current_value { if let Some(current) = current_value
&& WindowManager::get_main_window().is_some()
{
logging!(info, Type::Cmd, "向前端发送配置变更事件: {}", current); logging!(info, Type::Cmd, "向前端发送配置变更事件: {}", current);
handle::Handle::notify_profile_changed(current.to_owned()); handle::Handle::notify_profile_changed(current);
} }
Ok(true) Ok(true)
@@ -431,7 +433,7 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
logging!(error, Type::Timer, "刷新定时器失败: {}", e); logging!(error, Type::Timer, "刷新定时器失败: {}", e);
} else { } else {
// 刷新成功后发送自定义事件,不触发配置重载 // 刷新成功后发送自定义事件,不触发配置重载
crate::core::handle::Handle::notify_timer_updated(index); crate::core::handle::Handle::notify_timer_updated(&index);
} }
}); });
} }
+73 -4
View File
@@ -1,6 +1,6 @@
use super::{IClashTemp, IProfiles, IVerge}; use super::{IClashTemp, IProfiles, IVerge};
use crate::{ use crate::{
config::{PrfItem, profiles_append_item_safe}, config::{PrfItem, profiles_append_item_safe, runtime::IRuntime},
constants::{files, timing}, constants::{files, timing},
core::{ core::{
CoreManager, CoreManager,
@@ -16,9 +16,9 @@ use anyhow::{Result, anyhow};
use backoff::{Error as BackoffError, ExponentialBackoff}; use backoff::{Error as BackoffError, ExponentialBackoff};
use clash_verge_draft::Draft; use clash_verge_draft::Draft;
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
use clash_verge_types::runtime::IRuntime; use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String; use smartstring::alias::String;
use std::path::PathBuf; use std::{collections::HashSet, path::PathBuf};
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin; use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
use tokio::sync::OnceCell; use tokio::sync::OnceCell;
use tokio::time::sleep; use tokio::time::sleep;
@@ -188,7 +188,9 @@ impl Config {
} }
pub async fn generate() -> Result<()> { pub async fn generate() -> Result<()> {
let (config, exists_keys, logs) = enhance::enhance().await; let (mut config, exists_keys, logs) = enhance::enhance().await;
sanitize_tunnels_proxy(&mut config);
Self::runtime().await.edit_draft(|d| { Self::runtime().await.edit_draft(|d| {
*d = IRuntime { *d = IRuntime {
@@ -250,6 +252,73 @@ impl Config {
} }
} }
fn sanitize_tunnels_proxy(config: &mut Mapping) {
// 检查是否存在 tunnels
if !config
.get("tunnels")
.and_then(|v| v.as_sequence())
.is_some_and(|t| tunnels_need_validation(t))
{
return;
}
// 在需要时,收集可用目标(proxies + proxy-groups + 内建)
let mut valid: HashSet<String> = HashSet::with_capacity(64);
collect_names(config, "proxies", &mut valid);
collect_names(config, "proxy-groups", &mut valid);
valid.insert("DIRECT".into());
valid.insert("REJECT".into());
let Some(tunnels) = config.get_mut("tunnels").and_then(|v| v.as_sequence_mut()) else {
return;
};
// 修改 tunnels:删除无效 proxy
for item in tunnels {
let Some(tunnel) = item.as_mapping_mut() else { continue };
let Some(proxy_name) = tunnel.get("proxy").and_then(|v| v.as_str()) else {
continue;
};
if proxy_name == "DIRECT" || proxy_name == "REJECT" {
continue;
}
if !valid.contains(proxy_name) {
tunnel.remove("proxy");
}
}
}
// tunnels 存在且至少有一条 tunnel 的 proxy 需要校验时才返回 true
fn tunnels_need_validation(tunnels: &[Value]) -> bool {
tunnels.iter().any(|item| {
item.as_mapping()
.and_then(|t| t.get("proxy"))
.and_then(|p| p.as_str())
.is_some_and(|name| name != "DIRECT" && name != "REJECT")
})
}
fn collect_names(config: &Mapping, list_key: &str, out: &mut HashSet<String>) {
let Some(Value::Sequence(seq)) = config.get(list_key) else {
return;
};
for item in seq {
let Value::Mapping(map) = item else {
continue;
};
if let Some(Value::String(n)) = map.get("name")
&& !n.is_empty()
{
out.insert(n.into());
}
}
}
#[derive(Debug)] #[derive(Debug)]
pub enum ConfigType { pub enum ConfigType {
Run, Run,
+1
View File
@@ -4,6 +4,7 @@ mod config;
mod encrypt; mod encrypt;
mod prfitem; mod prfitem;
pub mod profiles; pub mod profiles;
pub mod runtime;
mod verge; mod verge;
pub use self::{clash::*, config::*, encrypt::*, prfitem::*, profiles::*, verge::*}; pub use self::{clash::*, config::*, encrypt::*, prfitem::*, profiles::*, verge::*};
+45 -3
View File
@@ -12,6 +12,9 @@ use serde_yaml_ng::Mapping;
use smartstring::alias::String; use smartstring::alias::String;
use std::time::Duration; use std::time::Duration;
use tokio::fs; use tokio::fs;
// TODO, use other re-export
use reqwest_dav::re_exports::url::form_urlencoded;
use tauri::Url;
#[derive(Debug, Clone, Deserialize, Serialize, Default)] #[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct PrfItem { pub struct PrfItem {
@@ -278,9 +281,17 @@ impl PrfItem {
ProxyType::None ProxyType::None
}; };
let url = fix_dirty_url(url)?;
// 使用网络管理器发送请求 // 使用网络管理器发送请求
let resp = match NetworkManager::new() let resp = match NetworkManager::new()
.get_with_interrupt(url, proxy_type, Some(timeout), user_agent.clone(), accept_invalid_certs) .get_with_interrupt(
url.as_str(),
proxy_type,
Some(timeout),
user_agent.clone(),
accept_invalid_certs,
)
.await .await
{ {
Ok(r) => r, Ok(r) => r,
@@ -340,7 +351,9 @@ impl PrfItem {
}, },
} }
} }
None => Some(crate::utils::help::get_last_part_and_decode(url).unwrap_or_else(|| "Remote File".into())), None => {
Some(crate::utils::help::get_last_part_and_decode(url.as_str()).unwrap_or_else(|| "Remote File".into()))
}
}; };
let update_interval = match update_interval { let update_interval = match update_interval {
Some(val) => Some(val), Some(val) => Some(val),
@@ -410,7 +423,7 @@ impl PrfItem {
name: Some(name), name: Some(name),
desc: desc.cloned(), desc: desc.cloned(),
file: Some(file), file: Some(file),
url: Some(url.into()), url: Some(url.as_str().into()),
selected: None, selected: None,
extra, extra,
option: Some(PrfOption { option: Some(PrfOption {
@@ -569,3 +582,32 @@ impl PrfItem {
const fn default_allow_auto_update() -> Option<bool> { const fn default_allow_auto_update() -> Option<bool> {
Some(true) Some(true)
} }
/// Fix URLs where query parameters are incorrectly appended to the path segment
///
/// Incorrect Example: https://example.com/path&param1=value1
fn fix_dirty_url(input: &str) -> Result<Url> {
let mut url = match Url::parse(input) {
Ok(u) => u,
Err(e) => {
return Err(anyhow::anyhow!(
"failed to parse deep link url: {:?}, input: {:?}",
e,
input
));
}
};
if url.query().is_none() && url.path().contains('&') {
let path = url.path().to_string();
if let Some((clean_path, dirty_params)) = path.split_once('&') {
url.set_path(clean_path);
url.query_pairs_mut()
.extend_pairs(form_urlencoded::parse(dirty_params.as_bytes()));
}
}
Ok(url)
}
+33 -36
View File
@@ -31,8 +31,8 @@ pub struct IProfilePreview<'a> {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CleanupResult { pub struct CleanupResult {
pub total_files: usize, pub total_files: usize,
pub deleted_files: Vec<String>, pub deleted_files: usize,
pub failed_deletions: Vec<String>, pub failed_deletions: usize,
} }
macro_rules! patch { macro_rules! patch {
@@ -45,13 +45,9 @@ macro_rules! patch {
impl IProfiles { impl IProfiles {
// Helper to find and remove an item by uid from the items vec, returning its file name (if any). // 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> { fn take_item_file_by_uid(items: &mut Vec<PrfItem>, target_uid: Option<&str>) -> Option<String> {
for (i, _) in items.iter().enumerate() { let index = items.iter().position(|item| item.uid.as_deref() == target_uid)?;
if items[i].uid == target_uid { items.remove(index).file
return items.remove(i).file;
}
}
None
} }
pub async fn new() -> Self { pub async fn new() -> Self {
@@ -267,33 +263,38 @@ impl IProfiles {
pub async fn delete_item(&mut self, uid: &String) -> Result<bool> { pub async fn delete_item(&mut self, uid: &String) -> Result<bool> {
let current = self.current.as_ref().unwrap_or(uid); let current = self.current.as_ref().unwrap_or(uid);
let current = current.clone(); let current = current.clone();
let item = self.get_item(uid)?; let (merge_uid, script_uid, rules_uid, proxies_uid, groups_uid) = {
let merge_uid = item.option.as_ref().and_then(|e| e.merge.clone()); let item = self.get_item(uid)?;
let script_uid = item.option.as_ref().and_then(|e| e.script.clone()); let option = item.option.as_ref();
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()); option.and_then(|e| e.merge.clone()),
let groups_uid = item.option.as_ref().and_then(|e| e.groups.clone()); option.and_then(|e| e.script.clone()),
option.and_then(|e| e.rules.clone()),
option.and_then(|e| e.proxies.clone()),
option.and_then(|e| e.groups.clone()),
)
};
let mut items = self.items.take().unwrap_or_default(); let mut items = self.items.take().unwrap_or_default();
// remove the main item (if exists) and delete its file // 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())) { if let Some(file) = Self::take_item_file_by_uid(&mut items, Some(uid.as_str())) {
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await; let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await;
} }
// remove related extension items (merge, script, rules, proxies, groups) // remove related extension items (merge, script, rules, proxies, groups)
if let Some(file) = Self::take_item_file_by_uid(&mut items, merge_uid.clone()) { if let Some(file) = Self::take_item_file_by_uid(&mut items, merge_uid.as_deref()) {
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await; 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()) { if let Some(file) = Self::take_item_file_by_uid(&mut items, script_uid.as_deref()) {
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await; 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()) { if let Some(file) = Self::take_item_file_by_uid(&mut items, rules_uid.as_deref()) {
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await; 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()) { if let Some(file) = Self::take_item_file_by_uid(&mut items, proxies_uid.as_deref()) {
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await; 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()) { if let Some(file) = Self::take_item_file_by_uid(&mut items, groups_uid.as_deref()) {
let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await; let _ = dirs::app_profiles_dir()?.join(file.as_str()).remove_if_exists().await;
} }
// delete the original uid // delete the original uid
@@ -365,15 +366,11 @@ impl IProfiles {
} }
/// 以 app 中的 profile 列表为准,删除不再需要的文件 /// 以 app 中的 profile 列表为准,删除不再需要的文件
pub async fn cleanup_orphaned_files(&self) -> Result<CleanupResult> { pub async fn cleanup_orphaned_files(&self) -> Result<()> {
let profiles_dir = dirs::app_profiles_dir()?; let profiles_dir = dirs::app_profiles_dir()?;
if !profiles_dir.exists() { if !profiles_dir.exists() {
return Ok(CleanupResult { return Ok(());
total_files: 0,
deleted_files: vec![],
failed_deletions: vec![],
});
} }
// 获取所有 active profile 的文件名集合 // 获取所有 active profile 的文件名集合
@@ -384,11 +381,11 @@ impl IProfiles {
// 扫描 profiles 目录下的所有文件 // 扫描 profiles 目录下的所有文件
let mut total_files = 0; let mut total_files = 0;
let mut deleted_files = vec![]; let mut deleted_files = 0;
let mut failed_deletions = vec![]; let mut failed_deletions = 0;
for entry in std::fs::read_dir(&profiles_dir)? { let mut dir_entries = tokio::fs::read_dir(&profiles_dir).await?;
let entry = entry?; while let Some(entry) = dir_entries.next_entry().await? {
let path = entry.path(); let path = entry.path();
if !path.is_file() { if !path.is_file() {
@@ -410,11 +407,11 @@ impl IProfiles {
if !active_files.contains(file_name) { if !active_files.contains(file_name) {
match path.to_path_buf().remove_if_exists().await { match path.to_path_buf().remove_if_exists().await {
Ok(_) => { Ok(_) => {
deleted_files.push(file_name.into()); deleted_files += 1;
logging!(debug, Type::Config, "已清理冗余文件: {file_name}"); logging!(debug, Type::Config, "已清理冗余文件: {file_name}");
} }
Err(e) => { Err(e) => {
failed_deletions.push(format!("{file_name}: {e}").into()); failed_deletions += 1;
logging!(warn, Type::Config, "Warning: 清理文件失败: {file_name} - {e}"); logging!(warn, Type::Config, "Warning: 清理文件失败: {file_name} - {e}");
} }
} }
@@ -433,11 +430,11 @@ impl IProfiles {
Type::Config, Type::Config,
"Profile 文件清理完成: 总文件数={}, 删除文件数={}, 失败数={}", "Profile 文件清理完成: 总文件数={}, 删除文件数={}, 失败数={}",
result.total_files, result.total_files,
result.deleted_files.len(), result.deleted_files,
result.failed_deletions.len() result.failed_deletions
); );
Ok(result) Ok(())
} }
/// 不删除全局扩展配置 /// 不删除全局扩展配置
@@ -2,7 +2,9 @@ use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String; use smartstring::alias::String;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
const PATCH_CONFIG_INNER: [&str; 4] = ["allow-lan", "ipv6", "log-level", "unified-delay"]; use crate::enhance::field::use_keys;
const PATCH_CONFIG_INNER: [&str; 5] = ["allow-lan", "ipv6", "log-level", "unified-delay", "tunnels"];
#[derive(Default, Clone)] #[derive(Default, Clone)]
pub struct IRuntime { pub struct IRuntime {
@@ -20,7 +22,7 @@ impl IRuntime {
Self::default() Self::default()
} }
// 这里只更改 allow-lan | ipv6 | log-level | tun // 这里只更改 allow-lan | ipv6 | log-level | tun | tunnels
#[inline] #[inline]
pub fn patch_config(&mut self, patch: &Mapping) { pub fn patch_config(&mut self, patch: &Mapping) {
let config = if let Some(config) = self.config.as_mut() { let config = if let Some(config) = self.config.as_mut() {
@@ -136,13 +138,3 @@ 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
})
}
+4
View File
@@ -155,6 +155,9 @@ pub struct IVerge {
/// 是否自动检测当前节点延迟 /// 是否自动检测当前节点延迟
pub enable_auto_delay_detection: Option<bool>, pub enable_auto_delay_detection: Option<bool>,
/// 自动检测当前节点延迟的间隔(分钟)
pub auto_delay_detection_interval_minutes: Option<u64>,
/// 是否使用内部的脚本支持,默认为真 /// 是否使用内部的脚本支持,默认为真
pub enable_builtin_enhanced: Option<bool>, pub enable_builtin_enhanced: Option<bool>,
@@ -523,6 +526,7 @@ impl IVerge {
patch!(default_latency_test); patch!(default_latency_test);
patch!(default_latency_timeout); patch!(default_latency_timeout);
patch!(enable_auto_delay_detection); patch!(enable_auto_delay_detection);
patch!(auto_delay_detection_interval_minutes);
patch!(enable_builtin_enhanced); patch!(enable_builtin_enhanced);
patch!(proxy_layout_column); patch!(proxy_layout_column);
patch!(test_list); patch!(test_list);
-1
View File
@@ -23,7 +23,6 @@ pub mod timing {
use super::Duration; use super::Duration;
pub const CONFIG_UPDATE_DEBOUNCE: Duration = Duration::from_millis(300); 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 STARTUP_ERROR_DELAY: Duration = Duration::from_secs(2);
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
+2 -3
View File
@@ -14,7 +14,6 @@ use std::{
sync::Arc, sync::Arc,
time::Duration, time::Duration,
}; };
use tauri_plugin_http::reqwest as tauri_reqwest;
use tokio::{fs, time::timeout}; use tokio::{fs, time::timeout};
use zip::write::SimpleFileOptions; use zip::write::SimpleFileOptions;
@@ -111,12 +110,12 @@ impl WebDavClient {
// 创建新的客户端 // 创建新的客户端
let client = reqwest_dav::ClientBuilder::new() let client = reqwest_dav::ClientBuilder::new()
.set_agent( .set_agent(
tauri_reqwest::Client::builder() reqwest::Client::builder()
.use_rustls_tls() .use_rustls_tls()
.danger_accept_invalid_certs(true) .danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(op.timeout())) .timeout(Duration::from_secs(op.timeout()))
.user_agent(format!("clash-verge/{APP_VERSION} ({OS} WebDAV-Client)")) .user_agent(format!("clash-verge/{APP_VERSION} ({OS} WebDAV-Client)"))
.redirect(tauri_reqwest::redirect::Policy::custom(|attempt| { .redirect(reqwest::redirect::Policy::custom(|attempt| {
// 允许所有请求类型的重定向,包括PUT // 允许所有请求类型的重定向,包括PUT
if attempt.previous().len() >= 5 { if attempt.previous().len() >= 5 {
attempt.error("重定向次数过多") attempt.error("重定向次数过多")
+20 -79
View File
@@ -1,11 +1,7 @@
use crate::{APP_HANDLE, singleton, utils::window_manager::WindowManager}; use crate::{APP_HANDLE, singleton};
use parking_lot::RwLock;
use smartstring::alias::String; use smartstring::alias::String;
use std::sync::{ use std::sync::atomic::{AtomicBool, Ordering};
Arc, use tauri::AppHandle;
atomic::{AtomicBool, Ordering},
};
use tauri::{AppHandle, Manager as _, WebviewWindow};
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _}; use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
use tokio::sync::RwLockReadGuard; use tokio::sync::RwLockReadGuard;
@@ -14,14 +10,12 @@ use super::notification::{FrontendEvent, NotificationSystem};
#[derive(Debug)] #[derive(Debug)]
pub struct Handle { pub struct Handle {
is_exiting: AtomicBool, is_exiting: AtomicBool,
pub(crate) notification_system: Arc<RwLock<Option<NotificationSystem>>>,
} }
impl Default for Handle { impl Default for Handle {
fn default() -> Self { fn default() -> Self {
Self { Self {
is_exiting: AtomicBool::new(false), is_exiting: AtomicBool::new(false),
notification_system: Arc::new(RwLock::new(Some(NotificationSystem::new()))),
} }
} }
} }
@@ -33,19 +27,6 @@ impl Handle {
Self::default() Self::default()
} }
pub fn init(&self) {
if self.is_exiting() {
return;
}
let mut system_opt = self.notification_system.write();
if let Some(system) = system_opt.as_mut()
&& !system.is_running()
{
system.start();
}
}
pub fn app_handle() -> &'static AppHandle { pub fn app_handle() -> &'static AppHandle {
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
APP_HANDLE.get().expect("App handle not initialized") APP_HANDLE.get().expect("App handle not initialized")
@@ -55,66 +36,34 @@ impl Handle {
Self::app_handle().mihomo().read().await Self::app_handle().mihomo().read().await
} }
pub fn get_window() -> Option<WebviewWindow> {
Self::app_handle().get_webview_window("main")
}
pub fn refresh_clash() { pub fn refresh_clash() {
let handle = Self::global(); Self::send_event(FrontendEvent::RefreshClash);
if handle.is_exiting() {
return;
}
let system_opt = handle.notification_system.read();
if let Some(system) = system_opt.as_ref() {
system.send_event(FrontendEvent::RefreshClash);
}
} }
pub fn refresh_verge() { pub fn refresh_verge() {
let handle = Self::global(); Self::send_event(FrontendEvent::RefreshVerge);
if handle.is_exiting() {
return;
}
let system_opt = handle.notification_system.read();
if let Some(system) = system_opt.as_ref() {
system.send_event(FrontendEvent::RefreshVerge);
}
} }
pub fn notify_profile_changed(profile_id: String) { pub fn notify_profile_changed(profile_id: &String) {
Self::send_event(FrontendEvent::ProfileChanged { Self::send_event(FrontendEvent::ProfileChanged {
current_profile_id: profile_id, current_profile_id: profile_id,
}); });
} }
pub fn notify_timer_updated(profile_index: String) { pub fn notify_timer_updated(profile_index: &String) {
Self::send_event(FrontendEvent::TimerUpdated { profile_index }); Self::send_event(FrontendEvent::TimerUpdated { profile_index });
} }
pub fn notify_profile_update_started(uid: String) { pub fn notify_profile_update_started(uid: &String) {
Self::send_event(FrontendEvent::ProfileUpdateStarted { uid }); Self::send_event(FrontendEvent::ProfileUpdateStarted { uid });
} }
pub fn notify_profile_update_completed(uid: String) { pub fn notify_profile_update_completed(uid: &String) {
Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid }); Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid });
} }
// TODO 利用 &str 等缩短 Clone pub fn notice_message<S: AsRef<str>, M: Into<String>>(status: S, msg: M) {
pub fn notice_message<S: Into<String>, M: Into<String>>(status: S, msg: M) { let status_str = status.as_ref();
let handle = Self::global();
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(); let msg_str = msg.into();
Self::send_event(FrontendEvent::NoticeMessage { Self::send_event(FrontendEvent::NoticeMessage {
@@ -123,29 +72,21 @@ impl Handle {
}); });
} }
pub fn set_is_exiting(&self) {
self.is_exiting.store(true, Ordering::Release);
}
pub fn is_exiting(&self) -> bool {
self.is_exiting.load(Ordering::Acquire)
}
fn send_event(event: FrontendEvent) { fn send_event(event: FrontendEvent) {
let handle = Self::global(); let handle = Self::global();
if handle.is_exiting() { if handle.is_exiting() {
return; return;
} }
let system_opt = handle.notification_system.read(); NotificationSystem::send_event(event);
if let Some(system) = system_opt.as_ref() {
system.send_event(event);
}
}
pub fn set_is_exiting(&self) {
self.is_exiting.store(true, Ordering::Release);
let mut system_opt = self.notification_system.write();
if let Some(system) = system_opt.as_mut() {
system.shutdown();
}
}
pub fn is_exiting(&self) -> bool {
self.is_exiting.load(Ordering::Acquire)
} }
} }
+5 -3
View File
@@ -1,6 +1,7 @@
use crate::process::AsyncHandler; use crate::process::AsyncHandler;
use crate::singleton; use crate::singleton;
use crate::utils::notification::{NotificationEvent, notify_event}; use crate::utils::notification::{NotificationEvent, notify_event};
use crate::utils::window_manager::WindowManager;
use crate::{config::Config, core::handle, feat, module::lightweight::entry_lightweight_mode}; use crate::{config::Config, core::handle, feat, module::lightweight::entry_lightweight_mode};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
@@ -243,7 +244,7 @@ impl Hotkey {
logging!(debug, Type::Hotkey, "Hotkey pressed: {:?}", hotkey_event); logging!(debug, Type::Hotkey, "Hotkey pressed: {:?}", hotkey_event);
let hotkey = hotkey_event.key; let hotkey = hotkey_event.key;
if hotkey == Code::KeyQ && is_quit { if hotkey == Code::KeyQ && is_quit {
if let Some(window) = handle::Handle::get_window() if let Some(window) = WindowManager::get_main_window()
&& window.is_focused().unwrap_or(false) && window.is_focused().unwrap_or(false)
{ {
logging!(debug, Type::Hotkey, "Executing quit function"); logging!(debug, Type::Hotkey, "Executing quit function");
@@ -260,8 +261,9 @@ impl Hotkey {
Self::execute_function(function); Self::execute_function(function);
} else { } else {
use crate::utils::window_manager::WindowManager; use crate::utils::window_manager::WindowManager;
let is_visible = WindowManager::is_main_window_visible(); let window = WindowManager::get_main_window();
let is_focused = WindowManager::is_main_window_focused(); let is_visible = WindowManager::is_main_window_visible(window.as_ref());
let is_focused = WindowManager::is_main_window_focused(window.as_ref());
if is_focused && is_visible { if is_focused && is_visible {
Self::execute_function(function); Self::execute_function(function);
+3 -3
View File
@@ -68,7 +68,7 @@ impl Logger {
self.log_max_size.store(log_max_size, Ordering::SeqCst); self.log_max_size.store(log_max_size, Ordering::SeqCst);
self.log_max_count.store(log_max_count, Ordering::SeqCst); self.log_max_count.store(log_max_count, Ordering::SeqCst);
#[cfg(not(feature = "tauri-dev"))] #[cfg(not(any(feature = "tauri-dev", feature = "tokio-trace")))]
{ {
let log_spec = Self::generate_log_spec(log_level); let log_spec = Self::generate_log_spec(log_level);
let log_dir = dirs::app_logs_dir()?; let log_dir = dirs::app_logs_dir()?;
@@ -88,9 +88,9 @@ impl Logger {
let mut filter_modules = vec!["wry", "tokio_tungstenite", "tungstenite"]; let mut filter_modules = vec!["wry", "tokio_tungstenite", "tungstenite"];
#[cfg(not(feature = "tracing"))] #[cfg(not(feature = "tracing"))]
filter_modules.push("tauri"); filter_modules.push("tauri::ipc");
#[cfg(feature = "tracing")] #[cfg(feature = "tracing")]
filter_modules.extend(["tauri_plugin_mihomo", "kode_bridge"]); filter_modules.extend(["kode_bridge"]);
let logger = logger.filter(Box::new(clash_verge_logging::NoModuleFilter(filter_modules))); let logger = logger.filter(Box::new(clash_verge_logging::NoModuleFilter(filter_modules)));
let handle = logger.start()?; let handle = logger.start()?;
+1 -2
View File
@@ -1,13 +1,12 @@
use super::CoreManager; use super::CoreManager;
use crate::{ use crate::{
config::{Config, ConfigType}, config::{Config, ConfigType, runtime::IRuntime},
constants::timing, constants::timing,
core::{handle, validate::CoreConfigValidator}, core::{handle, validate::CoreConfigValidator},
utils::{dirs, help}, utils::{dirs, help},
}; };
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use clash_verge_types::runtime::IRuntime;
use smartstring::alias::String; use smartstring::alias::String;
use std::{collections::HashSet, path::PathBuf, time::Instant}; use std::{collections::HashSet, path::PathBuf, time::Instant};
use tauri_plugin_mihomo::Error as MihomoError; use tauri_plugin_mihomo::Error as MihomoError;
+7 -1
View File
@@ -83,7 +83,7 @@ impl CoreManager {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
async fn wait_for_service_if_needed(&self) { async fn wait_for_service_if_needed(&self) {
use crate::{config::Config, constants::timing}; use crate::{config::Config, constants::timing, core::service};
use backoff::{Error as BackoffError, ExponentialBackoff}; use backoff::{Error as BackoffError, ExponentialBackoff};
let needs_service = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false); let needs_service = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
@@ -108,6 +108,12 @@ impl CoreManager {
return Ok(()); return Ok(());
} }
// If the service IPC path is not ready yet, treat it as transient and retry.
// Running init/refresh too early can mark service state unavailable and break later config reloads.
if !service::is_service_ipc_path_exists() {
return Err(BackoffError::transient(anyhow::anyhow!("Service IPC not ready")));
}
manager.init().await.map_err(BackoffError::transient)?; manager.init().await.map_err(BackoffError::transient)?;
let _ = manager.refresh().await; let _ = manager.refresh().await;
+1 -1
View File
@@ -64,7 +64,7 @@ impl CoreManager {
match event { match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line) tauri_plugin_shell::process::CommandEvent::Stdout(line)
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => { | tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
let message = CompactString::from(String::from_utf8_lossy(&line).as_ref()); let message = CompactString::from(&*String::from_utf8_lossy(&line));
Logger::global().writer_sidecar_log(Level::Error, &message); Logger::global().writer_sidecar_log(Level::Error, &message);
CLASH_LOGGER.append_log(message).await; CLASH_LOGGER.append_log(message).await;
} }
+17 -108
View File
@@ -1,100 +1,27 @@
use super::handle::Handle; use crate::utils::window_manager::WindowManager;
use crate::constants::timing;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use serde_json::json;
use smartstring::alias::String; use smartstring::alias::String;
use std::{sync::mpsc, thread};
use tauri::{Emitter as _, WebviewWindow}; use tauri::{Emitter as _, WebviewWindow};
// TODO 重构或优化,避免 Clone 过多 #[derive(Debug)]
#[derive(Debug, Clone)] pub enum FrontendEvent<'a> {
pub enum FrontendEvent {
RefreshClash, RefreshClash,
RefreshVerge, RefreshVerge,
NoticeMessage { status: String, message: String }, NoticeMessage { status: &'a str, message: String },
ProfileChanged { current_profile_id: String }, ProfileChanged { current_profile_id: &'a String },
TimerUpdated { profile_index: String }, TimerUpdated { profile_index: &'a String },
ProfileUpdateStarted { uid: String }, ProfileUpdateStarted { uid: &'a String },
ProfileUpdateCompleted { uid: String }, ProfileUpdateCompleted { uid: &'a String },
} }
#[derive(Debug)] #[derive(Debug)]
pub struct NotificationSystem { pub struct NotificationSystem {}
sender: Option<mpsc::Sender<FrontendEvent>>,
#[allow(clippy::type_complexity)]
worker_handle: Option<thread::JoinHandle<()>>,
}
impl Default for NotificationSystem {
fn default() -> Self {
Self::new()
}
}
impl NotificationSystem { impl NotificationSystem {
pub const fn new() -> Self { fn emit_to_window(window: &WebviewWindow, event: FrontendEvent) {
Self { let (event_name, Ok(payload)) = Self::serialize_event(event) else {
sender: None,
worker_handle: None,
}
}
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() {
return;
}
let (tx, rx) = mpsc::channel();
self.sender = Some(tx);
//? 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));
match result {
Ok(handle) => self.worker_handle = Some(handle),
Err(e) => logging!(error, Type::System, "Failed to start notification worker: {}", e),
}
}
fn worker_loop(rx: mpsc::Receiver<FrontendEvent>) {
let handle = Handle::global();
loop {
if handle.is_exiting() {
break;
}
match rx.recv() {
Ok(event) => Self::process_event(handle, event),
Err(e) => {
logging!(error, Type::System, "Notification System will exit, recv error: {}", e);
break;
}
}
}
}
fn process_event(handle: &super::handle::Handle, event: FrontendEvent) {
let binding = handle.notification_system.read();
let system = match binding.as_ref() {
Some(s) => s,
None => return,
};
if let Some(window) = super::handle::Handle::get_window() {
system.emit_to_window(&window, event);
drop(binding);
thread::sleep(timing::EVENT_EMIT_DELAY);
}
}
fn emit_to_window(&self, window: &WebviewWindow, event: FrontendEvent) {
let (event_name, payload) = self.serialize_event(event);
let Ok(payload) = payload else {
return; return;
}; };
@@ -103,9 +30,7 @@ impl NotificationSystem {
} }
} }
fn serialize_event(&self, event: FrontendEvent) -> (&'static str, Result<serde_json::Value, serde_json::Error>) { fn serialize_event(event: FrontendEvent) -> (&'static str, Result<serde_json::Value, serde_json::Error>) {
use serde_json::json;
match event { match event {
FrontendEvent::RefreshClash => ("verge://refresh-clash-config", Ok(json!("yes"))), FrontendEvent::RefreshClash => ("verge://refresh-clash-config", Ok(json!("yes"))),
FrontendEvent::RefreshVerge => ("verge://refresh-verge-config", Ok(json!("yes"))), FrontendEvent::RefreshVerge => ("verge://refresh-verge-config", Ok(json!("yes"))),
@@ -119,25 +44,9 @@ impl NotificationSystem {
} }
} }
pub fn send_event(&self, event: FrontendEvent) -> bool { pub(crate) fn send_event(event: FrontendEvent) {
if !self.is_running() { if let Some(window) = WindowManager::get_main_window() {
return false; Self::emit_to_window(&window, event);
}
if let Some(sender) = &self.sender {
sender.send(event).is_ok()
} else {
false
}
}
pub fn shutdown(&mut self) {
if let Some(sender) = self.sender.take() {
drop(sender);
}
if let Some(handle) = self.worker_handle.take() {
let _ = handle.join();
} }
} }
} }
+112 -41
View File
@@ -3,18 +3,19 @@ use crate::{
core::{logger::Logger, tray::Tray}, core::{logger::Logger, tray::Tray},
utils::dirs, utils::dirs,
}; };
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, anyhow, bail};
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
use clash_verge_service_ipc::CoreConfig; use clash_verge_service_ipc::CoreConfig;
use compact_str::CompactString; use compact_str::CompactString;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use std::{ use std::{
borrow::Cow,
env::current_exe, env::current_exe,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command as StdCommand, process::Command as StdCommand,
time::Duration, time::Duration,
}; };
use tokio::sync::Mutex; use tokio::{sync::Mutex, time::sleep};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServiceStatus { pub enum ServiceStatus {
@@ -64,6 +65,7 @@ fn uninstall_service() -> Result<()> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn install_service() -> Result<()> { fn install_service() -> Result<()> {
use std::process::Output;
logging!(info, Type::Service, "install service"); logging!(info, Type::Service, "install service");
use deelevate::{PrivilegeLevel, Token}; use deelevate::{PrivilegeLevel, Token};
@@ -79,13 +81,30 @@ fn install_service() -> Result<()> {
let token = Token::with_current_process()?; let token = Token::with_current_process()?;
let level = token.privilege_level()?; let level = token.privilege_level()?;
let status = match level { let output = match level {
PrivilegeLevel::NotPrivileged => RunasCommand::new(install_path).show(false).status()?, PrivilegeLevel::NotPrivileged => {
_ => StdCommand::new(install_path).creation_flags(0x08000000).status()?, let status = RunasCommand::new(&install_path).show(false).status()?;
Output {
status,
stdout: Vec::new(),
stderr: Vec::new(),
}
}
_ => {
// StdCommand returns Output directly
StdCommand::new(&install_path).creation_flags(0x08000000).output()?
}
}; };
if !status.success() { if let Some((code, err)) = check_output_error(&output) {
bail!("failed to install service with status {}", status.code().unwrap_or(-1)); logging!(
error,
Type::Service,
"failed to install service code: {}, details: {}",
code,
err
);
bail!("failed to install service code: {}, details: {}", code, err);
} }
Ok(()) Ok(())
@@ -160,41 +179,42 @@ fn install_service() -> Result<()> {
let install_shell: String = install_path.to_string_lossy().replace(" ", "\\ "); let install_shell: String = install_path.to_string_lossy().replace(" ", "\\ ");
let elevator = crate::utils::help::linux_elevator(); let elevator = crate::utils::help::linux_elevator();
let status = if linux_running_as_root() { let output = if linux_running_as_root() {
StdCommand::new(&install_path).status()? StdCommand::new(&install_path).output()?
} else { } else {
let result = StdCommand::new(&elevator) let result = StdCommand::new(&elevator)
.arg("sh") .arg("sh")
.arg("-c") .arg("-c")
.arg(&install_shell) .arg(&install_shell)
.status()?; .output()?;
// 如果 pkexec 执行失败,回退到 sudo // 如果 pkexec 执行失败,回退到 sudo
if !result.success() && elevator.contains("pkexec") { if !result.status.success() && elevator.contains("pkexec") {
logging!( logging!(
warn, warn,
Type::Service, Type::Service,
"pkexec failed with code {}, falling back to sudo", "pkexec failed with code {}, falling back to sudo",
result.code().unwrap_or(-1) result.status.code().unwrap_or(-1)
); );
StdCommand::new("sudo") StdCommand::new("sudo")
.arg("sh") .arg("sh")
.arg("-c") .arg("-c")
.arg(&install_shell) .arg(&install_shell)
.status()? .output()?
} else { } else {
result result
} }
}; };
logging!(
info,
Type::Service,
"install status code:{}",
status.code().unwrap_or(-1)
);
if !status.success() { if let Some((code, err)) = check_output_error(&output) {
bail!("failed to install service with status {}", status.code().unwrap_or(-1)); logging!(
error,
Type::Service,
"failed to install service code: {}, details: {}",
code,
err
);
bail!("failed to install service code: {}, details: {}", code, err);
} }
Ok(()) Ok(())
@@ -256,19 +276,43 @@ fn install_service() -> Result<()> {
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref()); // clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
let gid = tauri_plugin_clash_verge_sysinfo::current_gid();
let prompt = clash_verge_i18n::t!("service.adminInstallPrompt"); let prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
let command = let command = format!(
format!(r#"do shell script "sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""#); r#"do shell script "sudo CLASH_VERGE_SERVICE_GID={gid} '{install_shell}'" with administrator privileges with prompt "{prompt}""#
);
let status = StdCommand::new("osascript").args(vec!["-e", &command]).status()?; let output = StdCommand::new("osascript").args(vec!["-e", &command]).output()?;
if let Some((code, err)) = check_output_error(&output) {
if !status.success() { logging!(
bail!("failed to install service with status {}", status.code().unwrap_or(-1)); error,
Type::Service,
"failed to install service code: {}, details: {}",
code,
err
);
bail!("failed to install service code: {}, details: {}", code, err);
} }
Ok(()) Ok(())
} }
fn check_output_error(output: &std::process::Output) -> Option<(i32, Cow<'_, str>)> {
if output.status.success() {
return None;
}
let code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
return Some((code, stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.is_empty() {
return Some((code, stdout));
}
Some((code, Cow::Borrowed("Unknown error")))
}
fn reinstall_service() -> Result<()> { fn reinstall_service() -> Result<()> {
logging!(info, Type::Service, "reinstall service"); logging!(info, Type::Service, "reinstall service");
@@ -334,10 +378,7 @@ pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result
pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> { pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
logging!(info, Type::Service, "正在尝试通过服务启动核心"); logging!(info, Type::Service, "正在尝试通过服务启动核心");
let mut manager = SERVICE_MANAGER.lock().await; SERVICE_MANAGER.lock().await.refresh().await?;
let status = manager.check_service_comprehensive().await;
manager.handle_service_status(&status).await?;
drop(manager);
logging!(info, Type::Service, "服务已运行且版本匹配,直接使用"); logging!(info, Type::Service, "服务已运行且版本匹配,直接使用");
start_with_existing_service(config_file).await start_with_existing_service(config_file).await
@@ -381,20 +422,50 @@ pub(super) async fn stop_core_by_service() -> Result<()> {
/// 检查服务是否正在运行 /// 检查服务是否正在运行
pub async fn is_service_available() -> Result<()> { pub async fn is_service_available() -> Result<()> {
if let Err(e) = Path::metadata(clash_verge_service_ipc::IPC_PATH.as_ref()) { if let Err(e) = Path::metadata(clash_verge_service_ipc::IPC_PATH.as_ref()) {
logging!(warn, Type::Service, "Some issue with service IPC Path: {}", e); let verge = Config::verge().await;
let verge_last = verge.latest_arc();
let is_enable = verge_last.enable_tun_mode.unwrap_or(false);
if is_enable {
logging!(warn, Type::Service, "Some issue with service IPC Path: {}", e);
}
return Err(e.into()); return Err(e.into());
} }
clash_verge_service_ipc::connect().await?; clash_verge_service_ipc::connect().await?;
Ok(()) Ok(())
} }
/// 等待一会,再检查服务是否正在运行
/// TODO 使用 tokio select 之类机制并结合 timeout 实现更优雅的等待机制,期望等待文件出现,再尝试连接
pub async fn wait_and_check_service_available(status: &mut ServiceManager) -> Result<()> { pub async fn wait_and_check_service_available(status: &mut ServiceManager) -> Result<()> {
status.0 = ServiceStatus::Unavailable("Waiting for service to be available".into()); wait_for_service_ipc(status, "Waiting for service to be available").await
clash_verge_service_ipc::connect().await?; }
status.0 = ServiceStatus::Ready;
Ok(()) async fn wait_for_service_ipc(status: &mut ServiceManager, reason: &str) -> Result<()> {
status.0 = ServiceStatus::Unavailable(reason.into());
let config = ServiceManager::config();
let mut attempts = 0u32;
#[allow(unused_assignments)]
let mut last_err = anyhow!("service not ready");
loop {
if Path::new(clash_verge_service_ipc::IPC_PATH).exists() {
match clash_verge_service_ipc::connect().await {
Ok(_) => {
status.0 = ServiceStatus::Ready;
return Ok(());
}
Err(e) => last_err = e,
}
} else {
last_err = anyhow!("IPC path not ready");
}
if attempts >= config.max_retries as u32 {
break;
}
attempts += 1;
sleep(config.retry_delay).await;
}
Err(last_err)
} }
pub fn is_service_ipc_path_exists() -> bool { pub fn is_service_ipc_path_exists() -> bool {
@@ -408,9 +479,9 @@ impl ServiceManager {
pub const fn config() -> clash_verge_service_ipc::IpcConfig { pub const fn config() -> clash_verge_service_ipc::IpcConfig {
clash_verge_service_ipc::IpcConfig { clash_verge_service_ipc::IpcConfig {
default_timeout: Duration::from_millis(100), default_timeout: Duration::from_millis(150),
retry_delay: Duration::from_millis(200), retry_delay: Duration::from_millis(250),
max_retries: 6, max_retries: 20,
} }
} }
+5 -5
View File
@@ -409,12 +409,12 @@ impl Timer {
} }
/// Emit update events for frontend notification /// Emit update events for frontend notification
fn emit_update_event(_uid: &str, _is_start: bool) { fn emit_update_event(uid: &String, is_start: bool) {
{ {
if _is_start { if is_start {
super::handle::Handle::notify_profile_update_started(_uid.into()); super::handle::Handle::notify_profile_update_started(uid);
} else { } else {
super::handle::Handle::notify_profile_update_completed(_uid.into()); super::handle::Handle::notify_profile_update_completed(uid);
} }
} }
} }
@@ -430,7 +430,7 @@ impl Timer {
let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid); let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid);
logging!(info, Type::Timer, "配置 {} 是否为当前激活配置: {}", uid, is_current); logging!(info, Type::Timer, "配置 {} 是否为当前激活配置: {}", uid, is_current);
feat::update_profile(uid, None, is_current, false).await feat::update_profile(uid, None, is_current, false, false).await
}) })
.await .await
{ {
+2 -2
View File
@@ -55,7 +55,7 @@ pub(crate) enum TrayAction {
SystemProxy, SystemProxy,
TunMode, TunMode,
MainWindow, MainWindow,
TrayMenue, TrayMenu,
Unknown, Unknown,
} }
@@ -65,7 +65,7 @@ impl From<&str> for TrayAction {
"system_proxy" => Self::SystemProxy, "system_proxy" => Self::SystemProxy,
"tun_mode" => Self::TunMode, "tun_mode" => Self::TunMode,
"main_window" => Self::MainWindow, "main_window" => Self::MainWindow,
"tray_menue" => Self::TrayMenue, "tray_menu" => Self::TrayMenu,
_ => Self::Unknown, _ => Self::Unknown,
} }
} }
+121 -109
View File
@@ -9,8 +9,9 @@ use crate::{
Type, cmd, config::Config, feat, logging, module::lightweight::is_in_lightweight_mode, Type, cmd, config::Config, feat, logging, module::lightweight::is_in_lightweight_mode,
utils::dirs::find_target_icons, utils::dirs::find_target_icons,
}; };
use governor::{DefaultDirectRateLimiter, Quota, RateLimiter}; use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
use tauri::tray::TrayIconBuilder; use clash_verge_logging::logging_error;
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin; use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
use tauri_plugin_mihomo::models::Proxies; use tauri_plugin_mihomo::models::Proxies;
use tokio::fs; use tokio::fs;
@@ -19,12 +20,10 @@ use super::handle;
use anyhow::Result; use anyhow::Result;
use smartstring::alias::String; use smartstring::alias::String;
use std::collections::HashMap; use std::collections::HashMap;
use std::num::NonZeroU32;
use std::time::Duration; use std::time::Duration;
use tauri::{ use tauri::{
AppHandle, Wry, AppHandle, Wry,
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
tray::{MouseButton, MouseButtonState, TrayIconEvent},
}; };
mod menu_def; mod menu_def;
use menu_def::{MenuIds, MenuTexts}; use menu_def::{MenuIds, MenuTexts};
@@ -33,13 +32,13 @@ use menu_def::{MenuIds, MenuTexts};
type ProxyMenuItem = (Option<Submenu<Wry>>, Vec<Box<dyn IsMenuItem<Wry>>>); type ProxyMenuItem = (Option<Submenu<Wry>>, Vec<Box<dyn IsMenuItem<Wry>>>);
const TRAY_CLICK_DEBOUNCE_MS: u64 = 1_275; const TRAY_CLICK_DEBOUNCE_MS: u64 = 300;
#[derive(Clone)] #[derive(Clone)]
struct TrayState {} struct TrayState {}
pub struct Tray { pub struct Tray {
limiter: DefaultDirectRateLimiter, limiter: SystemLimiter,
} }
impl TrayState { impl TrayState {
@@ -136,11 +135,7 @@ impl Default for Tray {
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
fn default() -> Self { fn default() -> Self {
Self { Self {
limiter: RateLimiter::direct( limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock),
Quota::with_period(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS))
.unwrap()
.allow_burst(NonZeroU32::new(1).unwrap()),
),
} }
} }
} }
@@ -190,7 +185,7 @@ impl Tray {
.tray_by_id("main") .tray_by_id("main")
.ok_or_else(|| anyhow::anyhow!("Failed to get main tray"))?; .ok_or_else(|| anyhow::anyhow!("Failed to get main tray"))?;
match tray_event { match tray_event {
TrayAction::TrayMenue => tray.set_show_menu_on_left_click(true)?, TrayAction::TrayMenu => tray.set_show_menu_on_left_click(true)?,
_ => tray.set_show_menu_on_left_click(false)?, _ => tray.set_show_menu_on_left_click(false)?,
} }
Ok(()) Ok(())
@@ -207,6 +202,11 @@ impl Tray {
} }
async fn update_menu_internal(&self, app_handle: &AppHandle) -> Result<()> { async fn update_menu_internal(&self, app_handle: &AppHandle) -> Result<()> {
let Some(tray) = app_handle.tray_by_id("main") else {
logging!(warn, Type::Tray, "Failed to update tray menu: tray not found");
return Ok(());
};
let verge = Config::verge().await.latest_arc(); let verge = Config::verge().await.latest_arc();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false); let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false); let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
@@ -227,28 +227,24 @@ impl Tray {
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default(); let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
let is_lightweight_mode = is_in_lightweight_mode(); let is_lightweight_mode = is_in_lightweight_mode();
match app_handle.tray_by_id("main") { logging_error!(
Some(tray) => { Type::Tray,
let _ = tray.set_menu(Some( tray.set_menu(Some(
create_tray_menu( create_tray_menu(
app_handle, app_handle,
Some(mode.as_str()), Some(mode.as_str()),
*system_proxy, *system_proxy,
*tun_mode, *tun_mode,
tun_mode_available, tun_mode_available,
profiles_preview, profiles_preview,
is_lightweight_mode, is_lightweight_mode,
) )
.await?, .await?,
)); ))
logging!(debug, Type::Tray, "托盘菜单更新成功"); );
Ok(())
} logging!(debug, Type::Tray, "托盘菜单更新成功");
None => { Ok(())
logging!(warn, Type::Tray, "Failed to update tray menu: tray not found");
Ok(())
}
}
} }
/// 更新托盘图标 /// 更新托盘图标
@@ -261,12 +257,9 @@ impl Tray {
let app_handle = handle::Handle::app_handle(); let app_handle = handle::Handle::app_handle();
let tray = match app_handle.tray_by_id("main") { let Some(tray) = app_handle.tray_by_id("main") else {
Some(tray) => tray, logging!(warn, Type::Tray, "Failed to update tray icon: tray not found");
None => { return Ok(());
logging!(warn, Type::Tray, "Failed to update tray icon: tray not found");
return Ok(());
}
}; };
let (_is_custom_icon, icon_bytes) = TrayState::get_tray_icon(verge).await; let (_is_custom_icon, icon_bytes) = TrayState::get_tray_icon(verge).await;
@@ -274,8 +267,11 @@ impl Tray {
let colorful = verge.tray_icon.clone().unwrap_or_else(|| "monochrome".into()); let colorful = verge.tray_icon.clone().unwrap_or_else(|| "monochrome".into());
let is_colorful = colorful == "colorful"; let is_colorful = colorful == "colorful";
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?)); logging_error!(
let _ = tray.set_icon_as_template(!is_colorful); Type::Tray,
tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?))
);
logging_error!(Type::Tray, tray.set_icon_as_template(!is_colorful));
Ok(()) Ok(())
} }
@@ -288,17 +284,17 @@ impl Tray {
let app_handle = handle::Handle::app_handle(); let app_handle = handle::Handle::app_handle();
let tray = match app_handle.tray_by_id("main") { let Some(tray) = app_handle.tray_by_id("main") else {
Some(tray) => tray, logging!(warn, Type::Tray, "Failed to update tray icon: tray not found");
None => { return Ok(());
logging!(warn, Type::Tray, "Failed to update tray icon: tray not found");
return Ok(());
}
}; };
let (_is_custom_icon, icon_bytes) = TrayState::get_tray_icon(verge).await; let (_is_custom_icon, icon_bytes) = TrayState::get_tray_icon(verge).await;
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?)); logging_error!(
Type::Tray,
tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?))
);
Ok(()) Ok(())
} }
@@ -312,14 +308,11 @@ impl Tray {
let app_handle = handle::Handle::app_handle(); let app_handle = handle::Handle::app_handle();
let verge = Config::verge().await.latest_arc(); let verge = Config::verge().await.latest_arc();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false); let system_proxy = verge.enable_system_proxy.unwrap_or(false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false); let tun_mode = verge.enable_tun_mode.unwrap_or(false);
let switch_map = { let switch_str = |flag: bool| {
let mut map = std::collections::HashMap::new(); if flag { "on" } else { "off" }
map.insert(true, "on");
map.insert(false, "off");
map
}; };
let mut current_profile_name = "None".into(); let mut current_profile_name = "None".into();
@@ -351,18 +344,19 @@ impl Tray {
"Clash Verge {}\n{}: {}\n{}: {}\n{}: {}", "Clash Verge {}\n{}: {}\n{}: {}\n{}: {}",
reassembled_version, reassembled_version,
sys_proxy_text, sys_proxy_text,
switch_map[system_proxy], switch_str(system_proxy),
tun_text, tun_text,
switch_map[tun_mode], switch_str(tun_mode),
profile_text, profile_text,
current_profile_name current_profile_name
); );
if let Some(tray) = app_handle.tray_by_id("main") { let Some(tray) = app_handle.tray_by_id("main") else {
let _ = tray.set_tooltip(Some(&tooltip));
} else {
logging!(warn, Type::Tray, "Failed to update tray tooltip: tray not found"); logging!(warn, Type::Tray, "Failed to update tray tooltip: tray not found");
} return Ok(());
};
logging_error!(Type::Tray, tray.set_tooltip(Some(&tooltip)));
Ok(()) Ok(())
} }
@@ -414,56 +408,17 @@ impl Tray {
} }
let tray = builder.build(app_handle)?; let tray = builder.build(app_handle)?;
let tray_event = verge.tray_event.clone().unwrap_or_else(|| "main_window".into()); tray.on_tray_icon_event(on_tray_icon_event);
let tray_action = TrayAction::from(tray_event.as_str());
tray.on_tray_icon_event(move |_app_handle, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Down,
..
} = event
{
// 添加防抖检查,防止快速连击
#[allow(clippy::use_self)]
if !Tray::global().should_handle_tray_click() {
return;
}
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);
}
};
}
});
tray.on_menu_event(on_menu_event); tray.on_menu_event(on_menu_event);
Ok(()) Ok(())
} }
fn should_handle_tray_click(&self) -> bool { fn should_handle_tray_click(&self) -> bool {
let res = self.limiter.check().is_ok(); let allow = self.limiter.check();
if !res { if !allow {
logging!(debug, Type::Tray, "tray click rate limited"); logging!(debug, Type::Tray, "tray click rate limited");
} }
res allow
} }
} }
@@ -925,14 +880,68 @@ async fn create_tray_menu(
Ok(menu) Ok(menu)
} }
fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) {
if matches!(
tray_event,
TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. }
) {
return;
}
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Down,
..
} = tray_event
{
// 添加防抖检查,防止快速连击
#[allow(clippy::use_self)]
if !Tray::global().should_handle_tray_click() {
return;
}
AsyncHandler::spawn(|| async move {
let verge = Config::verge().await.data_arc();
let verge_tray_event = verge.tray_event.clone().unwrap_or_else(|| "main_window".into());
let verge_tray_action = TrayAction::from(verge_tray_event.as_str());
logging!(debug, Type::Tray, "tray event: {verge_tray_action:?}");
match verge_tray_action {
TrayAction::SystemProxy => {
let _ = feat::toggle_system_proxy().await;
}
TrayAction::TunMode => {
let _ = feat::toggle_tun_mode(None).await;
}
TrayAction::MainWindow => {
if !lightweight::exit_lightweight_mode().await {
WindowManager::show_main_window().await;
};
}
_ => {
logging!(warn, Type::Tray, "invalid tray event: {}", verge_tray_event);
}
};
});
}
}
fn on_menu_event(_: &AppHandle, event: MenuEvent) { fn on_menu_event(_: &AppHandle, event: MenuEvent) {
if !Tray::global().should_handle_tray_click() {
return;
}
if event.id.as_ref().is_empty() {
return;
}
AsyncHandler::spawn(|| async move { AsyncHandler::spawn(|| async move {
match event.id.as_ref() { match event.id.as_ref() {
mode @ (MenuIds::RULE_MODE | MenuIds::GLOBAL_MODE | MenuIds::DIRECT_MODE) => { mode @ (MenuIds::RULE_MODE | MenuIds::GLOBAL_MODE | MenuIds::DIRECT_MODE) => {
// Removing the the "tray_" prefix and "_mode" suffix // Removing the the "tray_" prefix and "_mode" suffix
let mode = &mode[5..mode.len() - 5]; if let Some(stripped) = mode.strip_prefix("tray_")
logging!(info, Type::ProxyMode, "Switch Proxy Mode To: {}", mode); && let Some(final_mode) = stripped.strip_suffix("_mode")
feat::change_clash_mode(mode.into()).await; {
logging!(info, Type::ProxyMode, "Switch Proxy Mode To: {}", final_mode);
feat::change_clash_mode(final_mode.into()).await;
}
} }
MenuIds::DASHBOARD => { MenuIds::DASHBOARD => {
logging!(info, Type::Tray, "托盘菜单点击: 打开窗口"); logging!(info, Type::Tray, "托盘菜单点击: 打开窗口");
@@ -980,7 +989,10 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
feat::quit().await; feat::quit().await;
} }
id if id.starts_with("profiles_") => { id if id.starts_with("profiles_") => {
let profile_index = &id["profiles_".len()..]; let profile_index = match id.strip_prefix("profiles_") {
Some(index_str) => index_str,
None => return,
};
feat::toggle_proxy_profile(profile_index.into()).await; feat::toggle_proxy_profile(profile_index.into()).await;
} }
id if id.starts_with("proxy_") => { id if id.starts_with("proxy_") => {
+7 -10
View File
@@ -61,14 +61,11 @@ pub fn use_sort(config: Mapping) -> Mapping {
ret ret
} }
pub fn use_keys(config: &Mapping) -> Vec<String> { #[inline]
config pub fn use_keys<'a>(config: &'a Mapping) -> impl Iterator<Item = String> + 'a {
.iter() config.iter().filter_map(|(key, _)| key.as_str()).map(|s: &str| {
.filter_map(|(key, _)| key.as_str()) let mut s: String = s.into();
.map(|s: &str| { s.make_ascii_lowercase();
let mut s: String = s.into(); s
s.make_ascii_lowercase(); })
s
})
.collect()
} }
+1 -1
View File
@@ -310,7 +310,7 @@ fn process_global_items(
profile_name: &String, profile_name: &String,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) { ) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
let mut result_map = HashMap::new(); let mut result_map = HashMap::new();
let mut exists_keys = use_keys(&config); let mut exists_keys = use_keys(&config).collect::<Vec<_>>();
if let ChainType::Merge(merge) = global_merge.data { if let ChainType::Merge(merge) = global_merge.data {
exists_keys.extend(use_keys(&merge)); exists_keys.extend(use_keys(&merge));
+1 -1
View File
@@ -61,7 +61,7 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
{ {
AsyncHandler::spawn(move || async move { AsyncHandler::spawn(move || async move {
crate::utils::resolve::dns::restore_public_dns().await; crate::utils::resolve::dns::restore_public_dns().await;
crate::utils::resolve::dns::set_public_dns("223.6.6.6".to_string()).await; crate::utils::resolve::dns::set_public_dns("114.114.114.114".to_string()).await;
}); });
} }
} }
+20 -8
View File
@@ -2,6 +2,7 @@ use crate::{
cmd, cmd,
config::{Config, PrfItem, PrfOption, profiles::profiles_draft_update_item_safe}, config::{Config, PrfItem, PrfOption, profiles::profiles_draft_update_item_safe},
core::{CoreManager, handle, tray}, core::{CoreManager, handle, tray},
utils::help::{mask_err, mask_url},
}; };
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
@@ -83,9 +84,11 @@ async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result
Type::Config, Type::Config,
"[订阅更新] {} 是远程订阅,URL: {}", "[订阅更新] {} 是远程订阅,URL: {}",
uid, uid,
item.url mask_url(
.as_ref() item.url
.ok_or_else(|| anyhow::anyhow!("Profile URL is None"))? .as_ref()
.ok_or_else(|| anyhow::anyhow!("Profile URL is None"))?
)
); );
Ok(Some(( Ok(Some((
item.url.clone().ok_or_else(|| anyhow::anyhow!("Profile URL is None"))?, item.url.clone().ok_or_else(|| anyhow::anyhow!("Profile URL is None"))?,
@@ -99,6 +102,7 @@ async fn perform_profile_update(
url: &String, url: &String,
opt: Option<&PrfOption>, opt: Option<&PrfOption>,
option: Option<&PrfOption>, option: Option<&PrfOption>,
is_mannual_trigger: bool,
) -> Result<bool> { ) -> Result<bool> {
logging!(info, Type::Config, "[订阅更新] 开始下载新的订阅内容"); logging!(info, Type::Config, "[订阅更新] 开始下载新的订阅内容");
let mut merged_opt = PrfOption::merge(opt, option); let mut merged_opt = PrfOption::merge(opt, option);
@@ -125,7 +129,8 @@ async fn perform_profile_update(
logging!( logging!(
warn, warn,
Type::Config, Type::Config,
"Warning: [订阅更新] 正常更新失败: {err},尝试使用Clash代理更新" "Warning: [订阅更新] 正常更新失败: {},尝试使用Clash代理更新",
mask_err(&err.to_string())
); );
last_err = err; last_err = err;
} }
@@ -146,7 +151,8 @@ async fn perform_profile_update(
logging!( logging!(
warn, warn,
Type::Config, Type::Config,
"Warning: [订阅更新] 正常更新失败: {err},尝试使用Clash代理更新" "Warning: [订阅更新] Clash代理更新失败: {},尝试使用系统代理更新",
mask_err(&err.to_string())
); );
last_err = err; last_err = err;
} }
@@ -167,13 +173,16 @@ async fn perform_profile_update(
logging!( logging!(
warn, warn,
Type::Config, Type::Config,
"Warning: [订阅更新] 正常更新失败: {err},尝试使用系统代理更新" "Warning: [订阅更新] 系统代理更新失败: {},所有重试均已失败",
mask_err(&err.to_string())
); );
last_err = err; last_err = err;
} }
} }
handle::Handle::notice_message("update_failed_even_with_clash", format!("{profile_name} - {last_err}")); if is_mannual_trigger {
handle::Handle::notice_message("update_failed_even_with_clash", format!("{profile_name} - {last_err}"));
}
Ok(is_current) Ok(is_current)
} }
@@ -182,12 +191,15 @@ pub async fn update_profile(
option: Option<&PrfOption>, option: Option<&PrfOption>,
auto_refresh: bool, auto_refresh: bool,
ignore_auto_update: bool, ignore_auto_update: bool,
is_mannual_trigger: bool,
) -> Result<()> { ) -> Result<()> {
logging!(info, Type::Config, "[订阅更新] 开始更新订阅 {}", uid); logging!(info, Type::Config, "[订阅更新] 开始更新订阅 {}", uid);
let url_opt = should_update_profile(uid, ignore_auto_update).await?; let url_opt = should_update_profile(uid, ignore_auto_update).await?;
let should_refresh = match url_opt { let should_refresh = match url_opt {
Some((url, opt)) => perform_profile_update(uid, &url, opt.as_ref(), option).await? && auto_refresh, Some((url, opt)) => {
perform_profile_update(uid, &url, opt.as_ref(), option, is_mannual_trigger).await? && auto_refresh
}
None => auto_refresh, None => auto_refresh,
}; };
+1 -1
View File
@@ -178,7 +178,7 @@ pub async fn hide() {
add_light_weight_timer().await; add_light_weight_timer().await;
} }
if let Some(window) = handle::Handle::get_window() if let Some(window) = WindowManager::get_main_window()
&& window.is_visible().unwrap_or(false) && window.is_visible().unwrap_or(false)
{ {
let _ = window.hide(); let _ = window.hide();
+1 -17
View File
@@ -19,12 +19,10 @@ use crate::{
use anyhow::Result; use anyhow::Result;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use std::time::Duration;
use tauri::{AppHandle, Manager as _}; use tauri::{AppHandle, Manager as _};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_deep_link::DeepLinkExt as _; use tauri_plugin_deep_link::DeepLinkExt as _;
use tauri_plugin_mihomo::RejectPolicy;
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new(); pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
/// Application initialization helper functions /// Application initialization helper functions
@@ -59,15 +57,6 @@ mod app_init {
tauri_plugin_mihomo::Builder::new() tauri_plugin_mihomo::Builder::new()
.protocol(tauri_plugin_mihomo::models::Protocol::LocalSocket) .protocol(tauri_plugin_mihomo::models::Protocol::LocalSocket)
.socket_path(crate::config::IClashTemp::guard_external_controller_ipc()) .socket_path(crate::config::IClashTemp::guard_external_controller_ipc())
.pool_config(
tauri_plugin_mihomo::IpcPoolConfigBuilder::new()
.min_connections(1)
.max_connections(32)
.idle_timeout(std::time::Duration::from_secs(60))
.health_check_interval(std::time::Duration::from_secs(60))
.reject_policy(RejectPolicy::Timeout(Duration::from_secs(3)))
.build(),
)
.build(), .build(),
); );
@@ -251,7 +240,6 @@ pub fn run() {
logging!(error, Type::Setup, "Failed to setup window state: {}", e); logging!(error, Type::Setup, "Failed to setup window state: {}", e);
} }
resolve::resolve_setup_handle();
resolve::resolve_setup_async(); resolve::resolve_setup_async();
resolve::resolve_setup_sync(); resolve::resolve_setup_sync();
resolve::init_signal(); resolve::init_signal();
@@ -265,7 +253,6 @@ pub fn run() {
mod event_handlers { mod event_handlers {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use crate::module::lightweight; use crate::module::lightweight;
#[cfg(target_os = "macos")]
use crate::utils::window_manager::WindowManager; use crate::utils::window_manager::WindowManager;
use crate::{ use crate::{
config::Config, config::Config,
@@ -284,7 +271,6 @@ pub fn run() {
} }
logging!(info, Type::System, "应用就绪"); logging!(info, Type::System, "应用就绪");
handle::Handle::global().init();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if let Some(window) = _app_handle.get_webview_window("main") { if let Some(window) = _app_handle.get_webview_window("main") {
@@ -294,8 +280,6 @@ pub fn run() {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub async fn handle_reopen(has_visible_windows: bool) { pub async fn handle_reopen(has_visible_windows: bool) {
handle::Handle::global().init();
if lightweight::is_in_lightweight_mode() { if lightweight::is_in_lightweight_mode() {
lightweight::exit_lightweight_mode().await; lightweight::exit_lightweight_mode().await;
return; return;
@@ -317,7 +301,7 @@ pub fn run() {
if let tauri::WindowEvent::CloseRequested { api, .. } = api { if let tauri::WindowEvent::CloseRequested { api, .. } = api {
api.prevent_close(); api.prevent_close();
if let Some(window) = core::handle::Handle::get_window() { if let Some(window) = WindowManager::get_main_window() {
let _ = window.hide(); let _ = window.hide();
} }
} }
+5 -5
View File
@@ -1,6 +1,6 @@
use crate::{ use crate::{
config::Config, config::Config,
core::{handle, timer::Timer, tray::Tray}, core::{timer::Timer, tray::Tray},
process::AsyncHandler, process::AsyncHandler,
}; };
@@ -143,7 +143,7 @@ pub async fn add_light_weight_timer() {
} }
fn setup_window_close_listener() { fn setup_window_close_listener() {
if let Some(window) = handle::Handle::get_window() { if let Some(window) = WindowManager::get_main_window() {
let handler_id = window.listen("tauri://close-requested", move |_event| { let handler_id = window.listen("tauri://close-requested", move |_event| {
std::mem::drop(AsyncHandler::spawn(|| async { std::mem::drop(AsyncHandler::spawn(|| async {
if let Err(e) = setup_light_weight_timer().await { if let Err(e) = setup_light_weight_timer().await {
@@ -161,7 +161,7 @@ fn setup_window_close_listener() {
} }
fn cancel_window_close_listener() { fn cancel_window_close_listener() {
if let Some(window) = handle::Handle::get_window() { if let Some(window) = WindowManager::get_main_window() {
let id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel); let id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel);
if id != 0 { if id != 0 {
window.unlisten(id); window.unlisten(id);
@@ -171,7 +171,7 @@ fn cancel_window_close_listener() {
} }
fn setup_webview_focus_listener() { fn setup_webview_focus_listener() {
if let Some(window) = handle::Handle::get_window() { if let Some(window) = WindowManager::get_main_window() {
let handler_id = window.listen("tauri://focus", move |_event| { let handler_id = window.listen("tauri://focus", move |_event| {
logging_error!(Type::Lightweight, cancel_light_weight_timer()); logging_error!(Type::Lightweight, cancel_light_weight_timer());
logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时"); logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时");
@@ -181,7 +181,7 @@ fn setup_webview_focus_listener() {
} }
fn cancel_webview_focus_listener() { fn cancel_webview_focus_listener() {
if let Some(window) = handle::Handle::get_window() { if let Some(window) = WindowManager::get_main_window() {
let id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel); let id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel);
if id != 0 { if id != 0 {
window.unlisten(id); window.unlisten(id);
+109
View File
@@ -4,6 +4,8 @@ use clash_verge_logging::{Type, logging};
use nanoid::nanoid; use nanoid::nanoid;
use serde::{Serialize, de::DeserializeOwned}; use serde::{Serialize, de::DeserializeOwned};
use serde_yaml_ng::Mapping; use serde_yaml_ng::Mapping;
#[cfg(target_os = "windows")]
use std::path::Path;
use std::{path::PathBuf, str::FromStr}; use std::{path::PathBuf, str::FromStr};
/// read data from yaml as struct T /// read data from yaml as struct T
@@ -96,6 +98,92 @@ pub fn parse_str<T: FromStr>(target: &str, key: &str) -> Option<T> {
}) })
} }
/// Mask sensitive parts of a subscription URL for safe logging.
/// Examples:
/// - `https://example.com/api/v1/clash?token=abc123` → `https://example.com/api/v1/clash?token=***`
/// - `https://example.com/abc123def456ghi789/clash` → `https://example.com/***/clash`
pub fn mask_url(url: &str) -> String {
// Split off query string
let (path_part, query_part) = match url.find('?') {
Some(pos) => (&url[..pos], Some(&url[pos + 1..])),
None => (url, None),
};
// Extract scheme+host prefix (everything up to the first '/' after "://")
let host_end = path_part
.find("://")
.and_then(|scheme_end| {
path_part[scheme_end + 3..]
.find('/')
.map(|slash| scheme_end + 3 + slash)
})
.unwrap_or(path_part.len());
let scheme_and_host = &path_part[..host_end];
let path = &path_part[host_end..]; // starts with '/' or empty
let mut result = scheme_and_host.to_owned();
// Mask path segments that look like tokens (longer than 16 chars)
if !path.is_empty() {
let masked: Vec<&str> = path
.split('/')
.map(|seg| if seg.len() > 16 { "***" } else { seg })
.collect();
result.push_str(&masked.join("/"));
}
// Keep query param keys, mask values
if let Some(query) = query_part {
result.push('?');
let masked_query: Vec<String> = query
.split('&')
.map(|param| match param.find('=') {
Some(eq) => format!("{}=***", &param[..eq]),
None => param.to_owned(),
})
.collect();
result.push_str(&masked_query.join("&"));
}
result
}
/// Mask all URLs embedded in an error/log string for safe logging.
///
/// Scans the string for `http://` or `https://` and replaces each URL
/// (terminated by whitespace or `)`, `]`, `"`, `'`) with its masked form.
/// Text between URLs is copied verbatim.
pub fn mask_err(err: &str) -> String {
let mut result = String::with_capacity(err.len());
let mut remaining = err;
loop {
let http = remaining.find("http://");
let https = remaining.find("https://");
let start = match (http, https) {
(None, None) => {
result.push_str(remaining);
break;
}
(Some(a), None) | (None, Some(a)) => a,
(Some(a), Some(b)) => a.min(b),
};
result.push_str(&remaining[..start]);
remaining = &remaining[start..];
let url_end = remaining
.find(|c: char| c.is_whitespace() || matches!(c, ')' | ']' | '"' | '\''))
.unwrap_or(remaining.len());
result.push_str(&mask_url(&remaining[..url_end]));
remaining = &remaining[url_end..];
}
result
}
/// get the last part of the url, if not found, return empty string /// get the last part of the url, if not found, return empty string
pub fn get_last_part_and_decode(url: &str) -> Option<String> { pub fn get_last_part_and_decode(url: &str) -> Option<String> {
let path = url.split('?').next().unwrap_or(""); // Splits URL and takes the path part let path = url.split('?').next().unwrap_or(""); // Splits URL and takes the path part
@@ -134,3 +222,24 @@ pub fn linux_elevator() -> String {
Err(_) => "sudo".to_string(), Err(_) => "sudo".to_string(),
} }
} }
#[cfg(target_os = "windows")]
/// copy the file to the dist path and return the dist path
pub fn snapshot_path(original_path: &Path) -> Result<PathBuf> {
let temp_dir = original_path
.parent()
.ok_or_else(|| anyhow!("Invalid log path"))?
.join("temp");
std::fs::create_dir_all(&temp_dir)?;
let temp_path = temp_dir.join(format!(
"{}_{}.log",
original_path.file_stem().unwrap_or_default().to_string_lossy(),
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S")
));
std::fs::copy(original_path, &temp_path)?;
Ok(temp_path)
}
+27
View File
@@ -13,11 +13,35 @@ use crate::{
use anyhow::Result; use anyhow::Result;
use chrono::{Local, TimeZone as _}; use chrono::{Local, TimeZone as _};
use clash_verge_logging::Type; use clash_verge_logging::Type;
#[cfg(target_os = "windows")]
use std::path::Path;
use std::{path::PathBuf, str::FromStr as _}; use std::{path::PathBuf, str::FromStr as _};
use tauri_plugin_shell::ShellExt as _; use tauri_plugin_shell::ShellExt as _;
use tokio::fs; use tokio::fs;
use tokio::fs::DirEntry; use tokio::fs::DirEntry;
#[cfg(target_os = "windows")]
async fn delete_snapshot_logs(log_dir: &Path) -> Result<()> {
let temp_dirs = [
log_dir.join("temp"),
log_dir.join("service").join("temp"),
log_dir.join("sidecar").join("temp"),
];
for temp_dir in temp_dirs.iter().filter(|d| d.exists()) {
let mut entries = fs::read_dir(temp_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("log") {
let _ = path.remove_if_exists().await;
logging!(info, Type::Setup, "delete snapshot log file: {}", path.display());
}
}
}
Ok(())
}
// TODO flexi_logger 提供了最大保留天数,或许我们应该用内置删除log文件 // TODO flexi_logger 提供了最大保留天数,或许我们应该用内置删除log文件
/// 删除log文件 /// 删除log文件
pub async fn delete_log() -> Result<()> { pub async fn delete_log() -> Result<()> {
@@ -26,6 +50,9 @@ pub async fn delete_log() -> Result<()> {
return Ok(()); return Ok(());
} }
#[cfg(target_os = "windows")]
delete_snapshot_logs(&log_dir).await?;
let auto_log_clean = { let auto_log_clean = {
let verge = Config::verge().await; let verge = Config::verge().await;
let verge = verge.data_arc(); let verge = verge.data_arc();
+2 -9
View File
@@ -5,7 +5,7 @@ use anyhow::Result;
use crate::{ use crate::{
config::Config, config::Config,
core::{ core::{
CoreManager, Timer, handle, CoreManager, Timer,
hotkey::Hotkey, hotkey::Hotkey,
logger::Logger, logger::Logger,
service::{SERVICE_MANAGER, ServiceManager, is_service_ipc_path_exists}, service::{SERVICE_MANAGER, ServiceManager, is_service_ipc_path_exists},
@@ -33,15 +33,12 @@ pub fn init_work_dir_and_logger() -> anyhow::Result<()> {
init_work_config().await; init_work_config().await;
init_resources().await; init_resources().await;
logging!(info, Type::Setup, "Initializing logger"); logging!(info, Type::Setup, "Initializing logger");
// #[cfg(not(feature = "tokio-trace"))]
Logger::global().init().await?; Logger::global().init().await?;
Ok(()) Ok(())
}) })
} }
pub fn resolve_setup_handle() {
init_handle();
}
pub fn resolve_setup_sync() { pub fn resolve_setup_sync() {
AsyncHandler::spawn(|| async { AsyncHandler::spawn(|| async {
AsyncHandler::spawn_blocking(init_scheme); AsyncHandler::spawn_blocking(init_scheme);
@@ -94,10 +91,6 @@ pub async fn resolve_reset_async() -> Result<(), anyhow::Error> {
Ok(()) Ok(())
} }
pub fn init_handle() {
handle::Handle::global().init();
}
pub(super) fn init_scheme() { pub(super) fn init_scheme() {
logging_error!(Type::Setup, init::init_scheme()); logging_error!(Type::Setup, init::init_scheme());
} }
+123 -77
View File
@@ -1,13 +1,13 @@
use std::time::Duration; use std::time::Duration;
use anyhow::{Result, bail}; use anyhow::Result;
use percent_encoding::percent_decode_str; use percent_encoding::percent_decode_str;
use smartstring::alias::String; use smartstring::alias::String;
use tauri::Url; use tauri::Url;
use crate::{ use crate::{
config::{Config, PrfItem, profiles}, config::{Config, PrfItem, profiles},
core::handle, core::{CoreManager, handle},
}; };
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
@@ -22,90 +22,136 @@ pub(super) async fn resolve_scheme(param: &str) -> Result<()> {
param param
}; };
// 解析 URL let link_parsed =
let link_parsed = match Url::parse(param_str) { Url::parse(param_str).map_err(|e| anyhow::anyhow!("failed to parse deep link: {:?}, param: {:?}", e, param))?;
Ok(url) => url,
Err(e) => {
bail!("failed to parse deep link: {:?}, param: {:?}", e, param);
}
};
let (url_param, name) = if link_parsed.scheme() == "clash" || link_parsed.scheme() == "clash-verge" { let Some((url, name)) = extract_subscription_info(&link_parsed) else {
let name_owned: Option<String> = link_parsed
.query_pairs()
.find(|(key, _)| key == "name")
.map(|(_, value)| value.into_owned().into());
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()..].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
}
} else {
None
};
(url_param, name_owned)
} else {
(None, None)
};
let url = if let Some(ref url) = url_param {
url
} else {
logging!(error, Type::Config, "missing url parameter in deep link: {}", param_str); logging!(error, Type::Config, "missing url parameter in deep link: {}", param_str);
return Ok(()); return Ok(());
}; };
let mut item = match PrfItem::from_url(url, name.as_ref(), None, None).await { import_subscription(&url, name.as_ref()).await;
Ok(item) => item, Ok(())
Err(e) => { }
logging!(error, Type::Config, "failed to parse profile from url: {:?}", e);
handle::Handle::notice_message("import_sub_url::error", e.to_string()); fn extract_subscription_info(link_parsed: &Url) -> Option<(std::string::String, Option<String>)> {
return Ok(()); if !matches!(link_parsed.scheme(), "clash" | "clash-verge") {
return None;
}
let name = link_parsed
.query_pairs()
.find(|(key, _)| key == "name")
.map(|(_, value)| value.into_owned().into());
let url = extract_subscription_url(link_parsed)?;
Some((url, name))
}
fn extract_subscription_url(link_parsed: &Url) -> Option<std::string::String> {
let query = link_parsed.query()?;
let prefix = "url=";
let pos = query.find(prefix)?;
let raw_url = query[pos + prefix.len()..].trim();
Some(decode_subscription_url(raw_url))
}
fn decode_subscription_url(raw_url: &str) -> std::string::String {
// Avoid double-decoding nested subscription URLs; decode only when needed.
if Url::parse(raw_url).is_ok() {
return raw_url.to_string();
}
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
}
async fn import_subscription(url: &str, name: Option<&String>) {
let had_current_profile = {
let profiles = Config::profiles().await;
profiles.latest_arc().current.is_some()
};
let Some(mut item) = fetch_profile_item(url, name).await else {
return;
}; };
let uid = item.uid.clone().unwrap_or_default(); let uid = item.uid.clone().unwrap_or_default();
match profiles::profiles_append_item_safe(&mut item).await { if let Err(e) = profiles::profiles_append_item_safe(&mut item).await {
Ok(_) => { logging!(error, Type::Config, "failed to import subscription url: {:?}", e);
Config::profiles().await.apply(); Config::profiles().await.discard();
let _ = Config::profiles().await.data_arc().save_file().await; handle::Handle::notice_message("import_sub_url::error", e.to_string());
handle::Handle::notice_message( return;
"import_sub_url::ok",
"", // 空 msg 传入,我们不希望导致 后端-前端-后端 死循环,这里只做提醒。
);
handle::Handle::refresh_verge();
handle::Handle::notify_profile_changed(uid.clone());
tokio::time::sleep(Duration::from_millis(100)).await;
handle::Handle::notify_profile_changed(uid);
}
Err(e) => {
logging!(error, Type::Config, "failed to import subscription url: {:?}", e);
Config::profiles().await.discard();
handle::Handle::notice_message("import_sub_url::error", e.to_string());
return Ok(());
}
} }
Ok(()) Config::profiles().await.apply();
let _ = Config::profiles().await.data_arc().save_file().await;
handle::Handle::notice_message(
"import_sub_url::ok",
"", // 空 msg 传入,我们不希望导致 后端-前端-后端 死循环,这里只做提醒。
);
post_import_updates(&uid, had_current_profile).await;
}
async fn fetch_profile_item(url: &str, name: Option<&String>) -> Option<PrfItem> {
match PrfItem::from_url(url, name, None, None).await {
Ok(item) => Some(item),
Err(e) => {
logging!(error, Type::Config, "failed to parse profile from url: {:?}", e);
handle::Handle::notice_message("import_sub_url::error", e.to_string());
None
}
}
}
async fn post_import_updates(uid: &String, had_current_profile: bool) {
handle::Handle::refresh_verge();
handle::Handle::notify_profile_changed(uid);
tokio::time::sleep(Duration::from_millis(100)).await;
let should_update_core = if uid.is_empty() || had_current_profile {
false
} else {
let profiles = Config::profiles().await;
profiles.latest_arc().is_current_profile_index(uid)
};
handle::Handle::notify_profile_changed(uid);
if should_update_core {
refresh_core_config().await;
}
}
async fn refresh_core_config() {
logging!(
info,
Type::Config,
"Deep link import set current profile; refreshing core config"
);
match CoreManager::global().update_config().await {
Ok((true, _)) => handle::Handle::refresh_clash(),
Ok((false, msg)) => {
let message = if msg.is_empty() {
String::from("Failed to apply subscription configuration")
} else {
msg
};
logging!(warn, Type::Config, "Apply config failed: {}", message);
handle::Handle::notice_message("config_validate::error", message);
}
Err(err) => {
logging!(error, Type::Config, "Apply config error: {}", err);
handle::Handle::notice_message("update_failed", format!("{err}"));
}
}
} }
+5 -24
View File
@@ -5,8 +5,7 @@ use std::fs;
use std::os::windows::process::CommandExt as _; use std::os::windows::process::CommandExt as _;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Output}; use std::process::{Command, Output};
use winapi::um::stringapiset::MultiByteToWideChar; use windows::Win32::Globalization::{GetACP, GetOEMCP, MULTI_BYTE_TO_WIDE_CHAR_FLAGS, MultiByteToWideChar};
use winapi::um::winnls::{GetACP, GetOEMCP};
const CREATE_NO_WINDOW: u32 = 0x08000000; const CREATE_NO_WINDOW: u32 = 0x08000000;
const TASK_NAME_USER: &str = "Clash Verge"; const TASK_NAME_USER: &str = "Clash Verge";
@@ -207,34 +206,16 @@ fn decode_with_code_page(bytes: &[u8], code_page: u32) -> Option<String> {
return None; return None;
} }
let required = unsafe { let required = unsafe { MultiByteToWideChar(code_page, MULTI_BYTE_TO_WIDE_CHAR_FLAGS(0), bytes, None) };
MultiByteToWideChar(
code_page,
0,
bytes.as_ptr() as *const i8,
len as i32,
std::ptr::null_mut(),
0,
)
};
if required == 0 { if required <= 0 {
return None; return None;
} }
let mut wide = vec![0u16; required as usize]; let mut wide = vec![0u16; required as usize];
let written = unsafe { let written = unsafe { MultiByteToWideChar(code_page, MULTI_BYTE_TO_WIDE_CHAR_FLAGS(0), bytes, Some(&mut wide)) };
MultiByteToWideChar(
code_page,
0,
bytes.as_ptr() as *const i8,
len as i32,
wide.as_mut_ptr(),
required,
)
};
if written == 0 { if written <= 0 {
return None; return None;
} }
+60 -54
View File
@@ -1,8 +1,7 @@
use crate::{core::handle, utils::resolve::window::build_new_window}; use crate::{core::handle, utils::resolve::window::build_new_window};
use clash_verge_limiter::Limiter;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use std::num::NonZeroU32;
use std::pin::Pin; use std::pin::Pin;
use std::time::Duration; use std::time::Duration;
use tauri::{Manager as _, WebviewWindow, Wry}; use tauri::{Manager as _, WebviewWindow, Wry};
@@ -40,28 +39,48 @@ pub enum WindowState {
} }
// 窗口操作防抖机制 // 窗口操作防抖机制
const WINDOW_OPERATION_DEBOUNCE_MS: u64 = 1_275; const WINDOW_OPERATION_DEBOUNCE_MS: u64 = 625;
static WINDOW_OPERATION_LIMITER: Lazy<DefaultDirectRateLimiter> = Lazy::new(|| { static WINDOW_OPERATION_LIMITER: Lazy<Limiter> = Lazy::new(|| {
#[allow(clippy::unwrap_used)] Limiter::new(
RateLimiter::direct( Duration::from_millis(WINDOW_OPERATION_DEBOUNCE_MS),
Quota::with_period(Duration::from_millis(WINDOW_OPERATION_DEBOUNCE_MS)) clash_verge_limiter::SystemClock,
.unwrap()
.allow_burst(NonZeroU32::new(1).unwrap()),
) )
}); });
fn should_handle_window_operation() -> bool { fn should_handle_window_operation() -> bool {
let res = WINDOW_OPERATION_LIMITER.check().is_ok(); let allow = WINDOW_OPERATION_LIMITER.check();
if !res { if !allow {
logging!(debug, Type::Window, "window operation rate limited"); logging!(debug, Type::Window, "window operation rate limited");
} }
res allow
} }
/// 统一的窗口管理器 /// 统一的窗口管理器
pub struct WindowManager; pub struct WindowManager;
impl WindowManager { impl WindowManager {
pub fn get_main_window_with_state() -> (Option<WebviewWindow<Wry>>, WindowState) {
let Some(window) = Self::get_main_window() else {
return (None, WindowState::NotExist);
};
let is_minimized = window.is_minimized().unwrap_or(false);
let is_visible = window.is_visible().unwrap_or(false);
let is_focused = window.is_focused().unwrap_or(false);
let state = if is_minimized {
WindowState::Minimized
} else if !is_visible {
WindowState::Hidden
} else if is_focused {
WindowState::VisibleFocused
} else {
WindowState::VisibleUnfocused
};
(Some(window), state)
}
pub fn get_main_window_state() -> WindowState { pub fn get_main_window_state() -> WindowState {
match Self::get_main_window() { match Self::get_main_window() {
Some(window) => { Some(window) => {
@@ -122,12 +141,12 @@ impl WindowManager {
WindowOperationResult::NoAction WindowOperationResult::NoAction
} }
WindowState::VisibleUnfocused | WindowState::Minimized | WindowState::Hidden => { WindowState::VisibleUnfocused | WindowState::Minimized | WindowState::Hidden => {
if let Some(window) = Self::get_main_window() { let (window, state_after_check) = Self::get_main_window_with_state();
let state_after_check = Self::get_main_window_state(); if state_after_check == WindowState::VisibleFocused {
if state_after_check == WindowState::VisibleFocused { logging!(info, Type::Window, "窗口在检查期间已变为可见和有焦点状态");
logging!(info, Type::Window, "窗口在检查期间已变为可见和有焦点状态"); return WindowOperationResult::NoAction;
return WindowOperationResult::NoAction; }
} if let Some(window) = window {
Self::activate_window(&window) Self::activate_window(&window)
} else { } else {
WindowOperationResult::Failed WindowOperationResult::Failed
@@ -138,25 +157,18 @@ impl WindowManager {
/// 切换主窗口显示状态(显示/隐藏) /// 切换主窗口显示状态(显示/隐藏)
pub async fn toggle_main_window() -> WindowOperationResult { pub async fn toggle_main_window() -> WindowOperationResult {
// 防抖检查
if !should_handle_window_operation() { if !should_handle_window_operation() {
return WindowOperationResult::NoAction; return WindowOperationResult::NoAction;
}; }
logging!(info, Type::Window, "开始切换主窗口显示状态");
let current_state = Self::get_main_window_state(); let (window, state) = Self::get_main_window_with_state();
logging!(
info,
Type::Window,
"当前窗口状态: {:?} | 详细状态: {}",
current_state,
Self::get_window_status_info()
);
match current_state { logging!(debug, Type::Window, "当前状态: {:?}", state);
match state {
WindowState::NotExist => Self::handle_not_exist_toggle().await, WindowState::NotExist => Self::handle_not_exist_toggle().await,
WindowState::VisibleFocused | WindowState::VisibleUnfocused => Self::hide_main_window(), WindowState::VisibleFocused | WindowState::VisibleUnfocused => Self::hide_main_window(window.as_ref()),
WindowState::Minimized | WindowState::Hidden => Self::activate_existing_main_window(), WindowState::Minimized | WindowState::Hidden => Self::activate_existing_main_window(window.as_ref()),
} }
} }
@@ -172,9 +184,9 @@ impl WindowManager {
} }
// 隐藏主窗口 // 隐藏主窗口
fn hide_main_window() -> WindowOperationResult { fn hide_main_window(window: Option<&WebviewWindow<Wry>>) -> WindowOperationResult {
logging!(info, Type::Window, "窗口可见,将隐藏窗口"); logging!(info, Type::Window, "窗口可见,将隐藏窗口");
if let Some(window) = Self::get_main_window() { if let Some(window) = window {
match window.hide() { match window.hide() {
Ok(_) => { Ok(_) => {
logging!(info, Type::Window, "窗口已成功隐藏"); logging!(info, Type::Window, "窗口已成功隐藏");
@@ -192,10 +204,10 @@ impl WindowManager {
} }
// 激活已存在的主窗口 // 激活已存在的主窗口
fn activate_existing_main_window() -> WindowOperationResult { fn activate_existing_main_window(window: Option<&WebviewWindow<Wry>>) -> WindowOperationResult {
logging!(info, Type::Window, "窗口存在但被隐藏或最小化,将激活窗口"); logging!(info, Type::Window, "窗口存在但被隐藏或最小化,将激活窗口");
if let Some(window) = Self::get_main_window() { if let Some(window) = window {
Self::activate_window(&window) Self::activate_window(window)
} else { } else {
logging!(warn, Type::Window, "无法获取窗口实例"); logging!(warn, Type::Window, "无法获取窗口实例");
WindowOperationResult::Failed WindowOperationResult::Failed
@@ -258,24 +270,18 @@ impl WindowManager {
} }
/// 检查窗口是否可见 /// 检查窗口是否可见
pub fn is_main_window_visible() -> bool { pub fn is_main_window_visible(window: Option<&WebviewWindow<Wry>>) -> bool {
Self::get_main_window() window.map(|w| w.is_visible().unwrap_or(false)).unwrap_or(false)
.map(|window| window.is_visible().unwrap_or(false))
.unwrap_or(false)
} }
/// 检查窗口是否有焦点 /// 检查窗口是否有焦点
pub fn is_main_window_focused() -> bool { pub fn is_main_window_focused(window: Option<&WebviewWindow<Wry>>) -> bool {
Self::get_main_window() window.map(|w| w.is_focused().unwrap_or(false)).unwrap_or(false)
.map(|window| window.is_focused().unwrap_or(false))
.unwrap_or(false)
} }
/// 检查窗口是否最小化 /// 检查窗口是否最小化
pub fn is_main_window_minimized() -> bool { pub fn is_main_window_minimized(window: Option<&WebviewWindow<Wry>>) -> bool {
Self::get_main_window() window.map(|w| w.is_minimized().unwrap_or(false)).unwrap_or(false)
.map(|window| window.is_minimized().unwrap_or(false))
.unwrap_or(false)
} }
/// 创建新窗口,防抖避免重复调用 /// 创建新窗口,防抖避免重复调用
@@ -323,11 +329,11 @@ impl WindowManager {
} }
/// 获取详细的窗口状态信息 /// 获取详细的窗口状态信息
pub fn get_window_status_info() -> String { fn get_window_status_info() -> String {
let state = Self::get_main_window_state(); let (window, state) = Self::get_main_window_with_state();
let is_visible = Self::is_main_window_visible(); let is_visible = Self::is_main_window_visible(window.as_ref());
let is_focused = Self::is_main_window_focused(); let is_focused = Self::is_main_window_focused(window.as_ref());
let is_minimized = Self::is_main_window_minimized(); let is_minimized = Self::is_main_window_minimized(window.as_ref());
format!("窗口状态: {state:?} | 可见: {is_visible} | 有焦点: {is_focused} | 最小化: {is_minimized}") format!("窗口状态: {state:?} | 可见: {is_visible} | 有焦点: {is_focused} | 最小化: {is_minimized}")
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"version": "2.4.5-rc.1", "version": "2.4.7",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": { "bundle": {
"active": true, "active": true,
+3 -3
View File
@@ -24,9 +24,9 @@
} }
}, },
"externalBin": [ "externalBin": [
"./resources/clash-verge-service", "./sidecar/clash-verge-service",
"./resources/clash-verge-service-install", "./sidecar/clash-verge-service-install",
"./resources/clash-verge-service-uninstall", "./sidecar/clash-verge-service-uninstall",
"./sidecar/verge-mihomo", "./sidecar/verge-mihomo",
"./sidecar/verge-mihomo-alpha" "./sidecar/verge-mihomo-alpha"
] ]
+5 -2
View File
@@ -2,15 +2,18 @@ import { ReactNode } from "react";
import { ErrorBoundary, FallbackProps } from "react-error-boundary"; import { ErrorBoundary, FallbackProps } from "react-error-boundary";
function ErrorFallback({ error }: FallbackProps) { function ErrorFallback({ error }: FallbackProps) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
return ( return (
<div role="alert" style={{ padding: 16 }}> <div role="alert" style={{ padding: 16 }}>
<h4>Something went wrong:(</h4> <h4>Something went wrong:(</h4>
<pre>{error.message}</pre> <pre>{errorMessage}</pre>
<details title="Error Stack"> <details title="Error Stack">
<summary>Error Stack</summary> <summary>Error Stack</summary>
<pre>{error.stack}</pre> <pre>{errorStack}</pre>
</details> </details>
</div> </div>
); );
+36 -25
View File
@@ -1,4 +1,5 @@
import { Box, SvgIcon, TextField, styled } from "@mui/material"; import { ClearRounded } from "@mui/icons-material";
import { Box, SvgIcon, TextField, styled, IconButton } from "@mui/material";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import { import {
ChangeEvent, ChangeEvent,
@@ -168,6 +169,11 @@ export const BaseSearchBox = ({
}); });
}; };
const handleClearInput = () => {
setText("");
emitSearch({ text: "", matchCase, matchWholeWord, useRegularExpression });
};
const handleToggleMatchCase = () => { const handleToggleMatchCase = () => {
const next = !matchCase; const next = !matchCase;
setMatchCase(next); setMatchCase(next);
@@ -200,35 +206,40 @@ export const BaseSearchBox = ({
sx: { pr: 1 }, sx: { pr: 1 },
endAdornment: ( endAdornment: (
<Box display="flex"> <Box display="flex">
<Tooltip title={t("shared.placeholders.matchCase")}> {!!text && (
<div> <Tooltip title={t("shared.placeholders.resetInput")}>
<SvgIcon <IconButton
component={matchCaseIcon} size="small"
{...iconStyle} {...iconStyle}
aria-label={matchCase ? "active" : "inactive"} onClick={handleClearInput}
onClick={handleToggleMatchCase} >
/> <ClearRounded fontSize="inherit" />
</div> </IconButton>
</Tooltip>
)}
<Tooltip title={t("shared.placeholders.matchCase")}>
<SvgIcon
component={matchCaseIcon}
{...iconStyle}
aria-label={matchCase ? "active" : "inactive"}
onClick={handleToggleMatchCase}
/>
</Tooltip> </Tooltip>
<Tooltip title={t("shared.placeholders.matchWholeWord")}> <Tooltip title={t("shared.placeholders.matchWholeWord")}>
<div> <SvgIcon
<SvgIcon component={matchWholeWordIcon}
component={matchWholeWordIcon} {...iconStyle}
{...iconStyle} aria-label={matchWholeWord ? "active" : "inactive"}
aria-label={matchWholeWord ? "active" : "inactive"} onClick={handleToggleMatchWholeWord}
onClick={handleToggleMatchWholeWord} />
/>
</div>
</Tooltip> </Tooltip>
<Tooltip title={t("shared.placeholders.useRegex")}> <Tooltip title={t("shared.placeholders.useRegex")}>
<div> <SvgIcon
<SvgIcon component={UseRegularExpressionIcon}
component={UseRegularExpressionIcon} aria-label={useRegularExpression ? "active" : "inactive"}
aria-label={useRegularExpression ? "active" : "inactive"} {...iconStyle}
{...iconStyle} onClick={handleToggleUseRegularExpression}
onClick={handleToggleUseRegularExpression} />
/>
</div>
</Tooltip> </Tooltip>
</Box> </Box>
), ),
@@ -21,20 +21,14 @@ import {
ListItem, ListItem,
ListItemText, ListItemText,
} from "@mui/material"; } from "@mui/material";
import type { Column } from "@tanstack/react-table";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface ColumnOption {
field: string;
label: string;
visible: boolean;
}
interface Props { interface Props {
open: boolean; open: boolean;
columns: ColumnOption[]; columns: Column<IConnectionsItem, unknown>[];
onClose: () => void; onClose: () => void;
onToggle: (field: string, visible: boolean) => void;
onOrderChange: (order: string[]) => void; onOrderChange: (order: string[]) => void;
onReset: () => void; onReset: () => void;
} }
@@ -43,7 +37,6 @@ export const ConnectionColumnManager = ({
open, open,
columns, columns,
onClose, onClose,
onToggle,
onOrderChange, onOrderChange,
onReset, onReset,
}: Props) => { }: Props) => {
@@ -54,9 +47,9 @@ export const ConnectionColumnManager = ({
); );
const { t } = useTranslation(); const { t } = useTranslation();
const items = useMemo(() => columns.map((column) => column.field), [columns]); const items = useMemo(() => columns.map((column) => column.id), [columns]);
const visibleCount = useMemo( const visibleCount = useMemo(
() => columns.filter((column) => column.visible).length, () => columns.filter((column) => column.getIsVisible()).length,
[columns], [columns],
); );
@@ -65,7 +58,7 @@ export const ConnectionColumnManager = ({
const { active, over } = event; const { active, over } = event;
if (!over || active.id === over.id) return; if (!over || active.id === over.id) return;
const order = columns.map((column) => column.field); const order = columns.map((column) => column.id);
const oldIndex = order.indexOf(active.id as string); const oldIndex = order.indexOf(active.id as string);
const newIndex = order.indexOf(over.id as string); const newIndex = order.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return; if (oldIndex === -1 || newIndex === -1) return;
@@ -94,13 +87,16 @@ export const ConnectionColumnManager = ({
> >
{columns.map((column) => ( {columns.map((column) => (
<SortableColumnItem <SortableColumnItem
key={column.field} key={column.id}
column={column} column={column}
onToggle={onToggle} label={getColumnLabel(column)}
dragHandleLabel={t( dragHandleLabel={t(
"connections.components.columnManager.dragHandle", "connections.components.columnManager.dragHandle",
)} )}
disableToggle={column.visible && visibleCount <= 1} disableToggle={
!column.getCanHide() ||
(column.getIsVisible() && visibleCount <= 1)
}
/> />
))} ))}
</List> </List>
@@ -120,15 +116,15 @@ export const ConnectionColumnManager = ({
}; };
interface SortableColumnItemProps { interface SortableColumnItemProps {
column: ColumnOption; column: Column<IConnectionsItem, unknown>;
onToggle: (field: string, visible: boolean) => void; label: string;
dragHandleLabel: string; dragHandleLabel: string;
disableToggle?: boolean; disableToggle?: boolean;
} }
const SortableColumnItem = ({ const SortableColumnItem = ({
column, column,
onToggle, label,
dragHandleLabel, dragHandleLabel,
disableToggle = false, disableToggle = false,
}: SortableColumnItemProps) => { }: SortableColumnItemProps) => {
@@ -139,7 +135,7 @@ const SortableColumnItem = ({
transform, transform,
transition, transition,
isDragging, isDragging,
} = useSortable({ id: column.field }); } = useSortable({ id: column.id });
const style = useMemo( const style = useMemo(
() => ({ () => ({
@@ -167,12 +163,12 @@ const SortableColumnItem = ({
> >
<Checkbox <Checkbox
edge="start" edge="start"
checked={column.visible} checked={column.getIsVisible()}
disabled={disableToggle} disabled={disableToggle}
onChange={(event) => onToggle(column.field, event.target.checked)} onChange={(event) => column.toggleVisibility(event.target.checked)}
/> />
<ListItemText <ListItemText
primary={column.label} primary={label}
slotProps={{ primary: { variant: "body2" } }} slotProps={{ primary: { variant: "body2" } }}
sx={{ mr: 1 }} sx={{ mr: 1 }}
/> />
@@ -189,3 +185,11 @@ const SortableColumnItem = ({
</ListItem> </ListItem>
); );
}; };
const getColumnLabel = (column: Column<IConnectionsItem, unknown>) => {
const meta = column.columnDef.meta as { label?: string } | undefined;
if (meta?.label) return meta.label;
const header = column.columnDef.header;
return typeof header === "string" ? header : column.id;
};
+136 -211
View File
@@ -2,6 +2,7 @@ import { ViewColumnRounded } from "@mui/icons-material";
import { Box, IconButton, Tooltip } from "@mui/material"; import { Box, IconButton, Tooltip } from "@mui/material";
import { import {
ColumnDef, ColumnDef,
ColumnOrderState,
ColumnSizingState, ColumnSizingState,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
@@ -43,50 +44,57 @@ const reconcileColumnOrder = (
return [...filtered, ...missing]; return [...filtered, ...missing];
}; };
const createConnectionRow = (each: IConnectionsItem) => { type ColumnField =
| "host"
| "download"
| "upload"
| "dlSpeed"
| "ulSpeed"
| "chains"
| "rule"
| "process"
| "time"
| "source"
| "remoteDestination"
| "type";
const getConnectionCellValue = (field: ColumnField, each: IConnectionsItem) => {
const { metadata, rulePayload } = each; const { metadata, rulePayload } = each;
const chains = [...each.chains].reverse().join(" / ");
const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
const destination = metadata.destinationIP
? `${metadata.destinationIP}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
return { switch (field) {
id: each.id, case "host":
host: metadata.host return metadata.host
? `${metadata.host}:${metadata.destinationPort}` ? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`, : `${metadata.remoteDestination}:${metadata.destinationPort}`;
download: each.download, case "download":
upload: each.upload, return each.download;
dlSpeed: each.curDownload, case "upload":
ulSpeed: each.curUpload, return each.upload;
chains, case "dlSpeed":
rule, return each.curDownload;
process: truncateStr(metadata.process || metadata.processPath), case "ulSpeed":
time: each.start, return each.curUpload;
source: `${metadata.sourceIP}:${metadata.sourcePort}`, case "chains":
remoteDestination: destination, return [...each.chains].reverse().join(" / ");
type: `${metadata.type}(${metadata.network})`, case "rule":
connectionData: each, return rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
}; case "process":
return truncateStr(metadata.process || metadata.processPath);
case "time":
return each.start;
case "source":
return `${metadata.sourceIP}:${metadata.sourcePort}`;
case "remoteDestination":
return metadata.destinationIP
? `${metadata.destinationIP}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
case "type":
return `${metadata.type}(${metadata.network})`;
default:
return "";
}
}; };
type ConnectionRow = ReturnType<typeof createConnectionRow>;
const areRowsEqual = (a: ConnectionRow, b: ConnectionRow) =>
a.host === b.host &&
a.download === b.download &&
a.upload === b.upload &&
a.dlSpeed === b.dlSpeed &&
a.ulSpeed === b.ulSpeed &&
a.chains === b.chains &&
a.rule === b.rule &&
a.process === b.process &&
a.time === b.time &&
a.source === b.source &&
a.remoteDestination === b.remoteDestination &&
a.type === b.type;
interface Props { interface Props {
connections: IConnectionsItem[]; connections: IConnectionsItem[];
onShowDetail: (data: IConnectionsItem) => void; onShowDetail: (data: IConnectionsItem) => void;
@@ -104,33 +112,30 @@ export const ConnectionTable = (props: Props) => {
onCloseColumnManager, onCloseColumnManager,
} = props; } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const [columnWidths, setColumnWidths] = useLocalStorage< const [columnWidths, setColumnWidths] = useLocalStorage<ColumnSizingState>(
Record<string, number>
>(
"connection-table-widths", "connection-table-widths",
// server-side value, this is the default value used by server-side rendering (if any) // server-side value, this is the default value used by server-side rendering (if any)
// Do not omit (otherwise a Suspense boundary will be triggered) // Do not omit (otherwise a Suspense boundary will be triggered)
{}, {},
); );
const [columnVisibilityModel, setColumnVisibilityModel] = useLocalStorage< const [columnVisibilityModel, setColumnVisibilityModel] =
Partial<Record<string, boolean>> useLocalStorage<VisibilityState>(
>( "connection-table-visibility",
"connection-table-visibility", {},
{}, {
{ serializer: JSON.stringify,
serializer: JSON.stringify, deserializer: (value) => {
deserializer: (value) => { try {
try { const parsed = JSON.parse(value);
const parsed = JSON.parse(value); if (parsed && typeof parsed === "object") return parsed;
if (parsed && typeof parsed === "object") return parsed; } catch (err) {
} catch (err) { console.warn("Failed to parse connection-table-visibility", err);
console.warn("Failed to parse connection-table-visibility", err); }
} return {};
return {}; },
}, },
}, );
);
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>( const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
"connection-table-order", "connection-table-order",
@@ -149,15 +154,13 @@ export const ConnectionTable = (props: Props) => {
}, },
); );
type ColumnField = Exclude<keyof ConnectionRow, "connectionData">;
interface BaseColumn { interface BaseColumn {
field: ColumnField; field: ColumnField;
headerName: string; headerName: string;
width?: number; width?: number;
minWidth?: number; minWidth?: number;
align?: "left" | "right"; align?: "left" | "right";
cell?: (row: ConnectionRow) => ReactNode; cell?: (row: IConnectionsItem) => ReactNode;
} }
const baseColumns = useMemo<BaseColumn[]>(() => { const baseColumns = useMemo<BaseColumn[]>(() => {
@@ -190,7 +193,7 @@ export const ConnectionTable = (props: Props) => {
width: 76, width: 76,
minWidth: 60, minWidth: 60,
align: "right", align: "right",
cell: (row) => `${parseTraffic(row.dlSpeed).join(" ")}/s`, cell: (row) => `${parseTraffic(row.curDownload).join(" ")}/s`,
}, },
{ {
field: "ulSpeed", field: "ulSpeed",
@@ -198,7 +201,7 @@ export const ConnectionTable = (props: Props) => {
width: 76, width: 76,
minWidth: 60, minWidth: 60,
align: "right", align: "right",
cell: (row) => `${parseTraffic(row.ulSpeed).join(" ")}/s`, cell: (row) => `${parseTraffic(row.curUpload).join(" ")}/s`,
}, },
{ {
field: "chains", field: "chains",
@@ -262,177 +265,76 @@ export const ConnectionTable = (props: Props) => {
}); });
}, [baseColumns, setColumnOrder]); }, [baseColumns, setColumnOrder]);
const columns = useMemo<BaseColumn[]>(() => { const handleColumnVisibilityChange = useCallback(
const order = Array.isArray(columnOrder) ? columnOrder : []; (update: Updater<VisibilityState>) => {
const orderMap = new Map(order.map((field, index) => [field, index]));
return [...baseColumns].sort((a, b) => {
const aIndex = orderMap.has(a.field)
? (orderMap.get(a.field) as number)
: Number.MAX_SAFE_INTEGER;
const bIndex = orderMap.has(b.field)
? (orderMap.get(b.field) as number)
: Number.MAX_SAFE_INTEGER;
if (aIndex === bIndex) {
return order.indexOf(a.field) - order.indexOf(b.field);
}
return aIndex - bIndex;
});
}, [baseColumns, columnOrder]);
const visibleColumnsCount = useMemo(() => {
return columns.reduce((count, column) => {
return (columnVisibilityModel?.[column.field] ?? true) !== false
? count + 1
: count;
}, 0);
}, [columns, columnVisibilityModel]);
const handleToggleColumn = useCallback(
(field: string, visible: boolean) => {
if (!visible && visibleColumnsCount <= 1) {
return;
}
setColumnVisibilityModel((prev) => { setColumnVisibilityModel((prev) => {
const next = { ...(prev ?? {}) }; const current = prev ?? {};
if (visible) { const nextState =
delete next[field]; typeof update === "function" ? update(current) : update;
} else {
next[field] = false; const visibleCount = baseColumns.reduce((count, column) => {
const isVisible = (nextState[column.field] ?? true) !== false;
return count + (isVisible ? 1 : 0);
}, 0);
if (visibleCount === 0) {
return current;
} }
return next;
const sanitized: VisibilityState = {};
baseColumns.forEach((column) => {
if (nextState[column.field] === false) {
sanitized[column.field] = false;
}
});
return sanitized;
}); });
}, },
[setColumnVisibilityModel, visibleColumnsCount], [baseColumns, setColumnVisibilityModel],
); );
const handleManagerOrderChange = useCallback( const handleColumnOrderChange = useCallback(
(order: string[]) => { (update: Updater<ColumnOrderState>) => {
setColumnOrder(() => { setColumnOrder((prev) => {
const current = Array.isArray(prev) ? prev : [];
const nextState =
typeof update === "function" ? update(current) : update;
const baseFields = baseColumns.map((col) => col.field); const baseFields = baseColumns.map((col) => col.field);
return reconcileColumnOrder(order, baseFields); return reconcileColumnOrder(nextState, baseFields);
}); });
}, },
[baseColumns, setColumnOrder], [baseColumns, setColumnOrder],
); );
const handleResetColumns = useCallback(() => {
setColumnVisibilityModel({});
setColumnOrder(baseColumns.map((col) => col.field));
}, [baseColumns, setColumnOrder, setColumnVisibilityModel]);
const handleColumnVisibilityChange = useCallback(
(update: Updater<VisibilityState>) => {
setColumnVisibilityModel((prev) => {
const current = prev ?? {};
const baseState: VisibilityState = {};
columns.forEach((column) => {
baseState[column.field] = (current[column.field] ?? true) !== false;
});
const mergedState =
typeof update === "function"
? update(baseState)
: { ...baseState, ...update };
const hiddenFields = columns
.filter((column) => mergedState[column.field] === false)
.map((column) => column.field);
if (columns.length - hiddenFields.length === 0) {
return current;
}
const sanitized: Partial<Record<string, boolean>> = {};
hiddenFields.forEach((field) => {
sanitized[field] = false;
});
return sanitized;
});
},
[columns, setColumnVisibilityModel],
);
const columnVisibilityState = useMemo<VisibilityState>(() => {
const result: VisibilityState = {};
if (!columnVisibilityModel) {
columns.forEach((column) => {
result[column.field] = true;
});
return result;
}
columns.forEach((column) => {
result[column.field] =
(columnVisibilityModel?.[column.field] ?? true) !== false;
});
return result;
}, [columnVisibilityModel, columns]);
const columnOptions = useMemo(() => {
return columns.map((column) => ({
field: column.field,
label: column.headerName ?? column.field,
visible: (columnVisibilityModel?.[column.field] ?? true) !== false,
}));
}, [columns, columnVisibilityModel]);
const prevRowsRef = useRef<Map<string, ConnectionRow>>(new Map());
const connRows = useMemo<ConnectionRow[]>(() => {
const prevMap = prevRowsRef.current;
const nextMap = new Map<string, ConnectionRow>();
const nextRows = connections.map((each) => {
const nextRow = createConnectionRow(each);
const prevRow = prevMap.get(each.id);
if (prevRow && areRowsEqual(prevRow, nextRow)) {
nextMap.set(each.id, prevRow);
return prevRow;
}
nextMap.set(each.id, nextRow);
return nextRow;
});
prevRowsRef.current = nextMap;
return nextRows;
}, [connections]);
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [relativeNow, setRelativeNow] = useState(() => Date.now()); const [relativeNow, setRelativeNow] = useState(() => Date.now());
const columnDefs = useMemo<ColumnDef<ConnectionRow>[]>(() => { const columnDefs = useMemo<ColumnDef<IConnectionsItem>[]>(() => {
return columns.map((column) => { return baseColumns.map((column) => {
const baseCell: ColumnDef<ConnectionRow>["cell"] = column.cell const baseCell: ColumnDef<IConnectionsItem>["cell"] = column.cell
? (ctx) => column.cell?.(ctx.row.original) ? (ctx) => column.cell?.(ctx.row.original)
: (ctx) => ctx.getValue() as ReactNode; : (ctx) => ctx.getValue() as ReactNode;
const cell: ColumnDef<ConnectionRow>["cell"] = const cell: ColumnDef<IConnectionsItem>["cell"] =
column.field === "time" column.field === "time"
? (ctx) => dayjs(ctx.row.original.time).from(relativeNow) ? (ctx) => dayjs(ctx.getValue() as string).from(relativeNow)
: baseCell; : baseCell;
return { return {
id: column.field, id: column.field,
accessorKey: column.field, accessorFn: (row) => getConnectionCellValue(column.field, row),
header: column.headerName, header: column.headerName,
size: column.width, size: column.width,
minSize: column.minWidth ?? 80, minSize: column.minWidth,
enableResizing: true,
meta: { meta: {
align: column.align ?? "left", align: column.align ?? "left",
field: column.field, field: column.field,
label: column.headerName,
}, },
cell, cell,
} satisfies ColumnDef<ConnectionRow>; } satisfies ColumnDef<IConnectionsItem>;
}); });
}, [columns, relativeNow]); }, [baseColumns, relativeNow]);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return undefined; if (typeof window === "undefined") return undefined;
@@ -450,7 +352,7 @@ export const ConnectionTable = (props: Props) => {
const prevState = prev ?? {}; const prevState = prev ?? {};
const nextState = const nextState =
typeof updater === "function" ? updater(prevState) : updater; typeof updater === "function" ? updater(prevState) : updater;
const sanitized: Record<string, number> = {}; const sanitized: ColumnSizingState = {};
Object.entries(nextState).forEach(([key, size]) => { Object.entries(nextState).forEach(([key, size]) => {
if (typeof size === "number" && Number.isFinite(size)) { if (typeof size === "number" && Number.isFinite(size)) {
sanitized[key] = size; sanitized[key] = size;
@@ -463,22 +365,45 @@ export const ConnectionTable = (props: Props) => {
); );
const table = useReactTable({ const table = useReactTable({
data: connRows, data: connections,
state: { state: {
columnVisibility: columnVisibilityState, columnVisibility: columnVisibilityModel ?? {},
columnSizing: columnWidths, columnSizing: columnWidths,
columnOrder,
sorting, sorting,
}, },
initialState: {
columnOrder: baseColumns.map((col) => col.field),
},
defaultColumn: {
minSize: 80,
enableResizing: true,
},
columnResizeMode: "onChange", columnResizeMode: "onChange",
enableSortingRemoval: true, enableSortingRemoval: true,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: sorting.length ? getSortedRowModel() : undefined, getSortedRowModel: sorting.length ? getSortedRowModel() : undefined,
onSortingChange: setSorting, onSortingChange: setSorting,
onColumnSizingChange: handleColumnSizingChange, onColumnSizingChange: handleColumnSizingChange,
onColumnVisibilityChange: handleColumnVisibilityChange, onColumnVisibilityChange: handleColumnVisibilityChange,
onColumnOrderChange: handleColumnOrderChange,
columns: columnDefs, columns: columnDefs,
}); });
const handleManagerOrderChange = useCallback(
(order: string[]) => {
const baseFields = baseColumns.map((col) => col.field);
table.setColumnOrder(reconcileColumnOrder(order, baseFields));
},
[baseColumns, table],
);
const handleResetColumns = useCallback(() => {
table.resetColumnVisibility();
table.resetColumnOrder();
}, [table]);
const rows = table.getRowModel().rows; const rows = table.getRowModel().rows;
const tableContainerRef = useRef<HTMLDivElement | null>(null); const tableContainerRef = useRef<HTMLDivElement | null>(null);
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
@@ -491,6 +416,7 @@ export const ConnectionTable = (props: Props) => {
const virtualRows = rowVirtualizer.getVirtualItems(); const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize(); const totalSize = rowVirtualizer.getTotalSize();
const tableWidth = table.getTotalSize(); const tableWidth = table.getTotalSize();
const managerColumns = table.getAllLeafColumns();
return ( return (
<> <>
@@ -669,7 +595,7 @@ export const ConnectionTable = (props: Props) => {
return ( return (
<Box <Box
key={row.id} key={row.id}
onClick={() => onShowDetail(row.original.connectionData)} onClick={() => onShowDetail(row.original)}
sx={{ sx={{
display: "flex", display: "flex",
position: "absolute", position: "absolute",
@@ -726,9 +652,8 @@ export const ConnectionTable = (props: Props) => {
</Box> </Box>
<ConnectionColumnManager <ConnectionColumnManager
open={columnManagerOpen} open={columnManagerOpen}
columns={columnOptions} columns={managerColumns}
onClose={onCloseColumnManager} onClose={onCloseColumnManager}
onToggle={handleToggleColumn}
onOrderChange={handleManagerOrderChange} onOrderChange={handleManagerOrderChange}
onReset={handleResetColumns} onReset={handleResetColumns}
/> />
+2 -15
View File
@@ -4,13 +4,7 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useClash } from "@/hooks/use-clash"; import { useClash } from "@/hooks/use-clash";
import { import { useAppData } from "@/providers/app-data-context";
useAppUptime,
useClashConfig,
useRulesData,
useSystemProxyAddress,
useSystemProxyData,
} from "@/hooks/use-clash-data";
import { EnhancedCard } from "./enhanced-card"; import { EnhancedCard } from "./enhanced-card";
@@ -25,14 +19,7 @@ const formatUptime = (uptimeMs: number) => {
export const ClashInfoCard = () => { export const ClashInfoCard = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { version: clashVersion } = useClash(); const { version: clashVersion } = useClash();
const { clashConfig } = useClashConfig(); const { clashConfig, rules, uptime, systemProxyAddress } = useAppData();
const { sysproxy } = useSystemProxyData();
const { rules } = useRulesData();
const { uptime } = useAppUptime();
const systemProxyAddress = useSystemProxyAddress({
clashConfig,
sysproxy,
});
// 使用useMemo缓存格式化后的uptime,避免频繁计算 // 使用useMemo缓存格式化后的uptime,避免频繁计算
const formattedUptime = useMemo(() => formatUptime(uptime), [uptime]); const formattedUptime = useMemo(() => formatUptime(uptime), [uptime]);
+2 -2
View File
@@ -9,8 +9,8 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { closeAllConnections } from "tauri-plugin-mihomo-api"; import { closeAllConnections } from "tauri-plugin-mihomo-api";
import { useClashConfig } from "@/hooks/use-clash-data";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-context";
import { patchClashMode } from "@/services/cmds"; import { patchClashMode } from "@/services/cmds";
import type { TranslationKey } from "@/types/generated/i18n-keys"; import type { TranslationKey } from "@/types/generated/i18n-keys";
@@ -41,7 +41,7 @@ const MODE_META: Record<
export const ClashModeCard = () => { export const ClashModeCard = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { verge } = useVerge(); const { verge } = useVerge();
const { clashConfig, refreshClashConfig } = useClashConfig(); const { clashConfig, refreshClashConfig } = useAppData();
// 支持的模式列表 // 支持的模式列表
const modeList = CLASH_MODES; const modeList = CLASH_MODES;
+28 -16
View File
@@ -27,21 +27,22 @@ import {
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import React from "react"; import React, {
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { delayGroup, healthcheckProxyProvider } from "tauri-plugin-mihomo-api"; import { delayGroup, healthcheckProxyProvider } from "tauri-plugin-mihomo-api";
import { EnhancedCard } from "@/components/home/enhanced-card"; import { EnhancedCard } from "@/components/home/enhanced-card";
import {
useClashConfig,
useProxiesData,
useRulesData,
} from "@/hooks/use-clash-data";
import { useProfiles } from "@/hooks/use-profiles"; import { useProfiles } from "@/hooks/use-profiles";
import { useProxySelection } from "@/hooks/use-proxy-selection"; import { useProxySelection } from "@/hooks/use-proxy-selection";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-context";
import delayManager from "@/services/delay"; import delayManager from "@/services/delay";
import { debugLog } from "@/utils/debug"; import { debugLog } from "@/utils/debug";
@@ -50,8 +51,8 @@ const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group";
const STORAGE_KEY_PROXY = "clash-verge-selected-proxy"; const STORAGE_KEY_PROXY = "clash-verge-selected-proxy";
const STORAGE_KEY_SORT_TYPE = "clash-verge-proxy-sort-type"; const STORAGE_KEY_SORT_TYPE = "clash-verge-proxy-sort-type";
const AUTO_CHECK_INITIAL_DELAY_MS = 1500; const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5;
const AUTO_CHECK_INTERVAL_MS = 5 * 60 * 1000; const AUTO_CHECK_INITIAL_DELAY_MS = 100;
// 代理节点信息接口 // 代理节点信息接口
interface ProxyOption { interface ProxyOption {
@@ -88,9 +89,13 @@ function getSignalIcon(delay: number): {
text: string; text: string;
color: string; color: string;
} { } {
if (delay < 0) if (delay === -2)
return { icon: <SignalNone />, text: "测试中", color: "text.secondary" };
if (delay === -1)
return { icon: <SignalNone />, text: "未测试", color: "text.secondary" }; return { icon: <SignalNone />, text: "未测试", color: "text.secondary" };
if (delay >= 10000) if (delay > 1e5)
return { icon: <SignalError />, text: "错误", color: "error.main" };
if (delay === 0 || delay >= 10000)
return { icon: <SignalError />, text: "超时", color: "error.main" }; return { icon: <SignalError />, text: "超时", color: "error.main" };
if (delay >= 500) if (delay >= 500)
return { icon: <SignalWeak />, text: "延迟较高", color: "error.main" }; return { icon: <SignalWeak />, text: "延迟较高", color: "error.main" };
@@ -105,13 +110,19 @@ export const CurrentProxyCard = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
const { proxies, refreshProxy } = useProxiesData(); const { proxies, clashConfig, refreshProxy, rules } = useAppData();
const { clashConfig } = useClashConfig();
const { rules } = useRulesData();
const { verge } = useVerge(); const { verge } = useVerge();
const { current: currentProfile } = useProfiles(); const { current: currentProfile } = useProfiles();
const autoDelayEnabled = verge?.enable_auto_delay_detection ?? false; const autoDelayEnabled = verge?.enable_auto_delay_detection ?? false;
const defaultLatencyTimeout = verge?.default_latency_timeout; const defaultLatencyTimeout = verge?.default_latency_timeout;
const autoDelayIntervalMs = useMemo(() => {
const rawInterval = verge?.auto_delay_detection_interval_minutes;
const intervalMinutes =
typeof rawInterval === "number" && rawInterval > 0
? rawInterval
: AUTO_CHECK_DEFAULT_INTERVAL_MINUTES;
return Math.max(1, Math.round(intervalMinutes)) * 60 * 1000;
}, [verge?.auto_delay_detection_interval_minutes]);
const currentProfileId = currentProfile?.uid || null; const currentProfileId = currentProfile?.uid || null;
const getProfileStorageKey = useCallback( const getProfileStorageKey = useCallback(
@@ -598,13 +609,13 @@ export const CurrentProxyCard = () => {
if (disposed) return; if (disposed) return;
await checkCurrentProxyDelay(); await checkCurrentProxyDelay();
if (disposed) return; if (disposed) return;
intervalTimer = setTimeout(runAndSchedule, AUTO_CHECK_INTERVAL_MS); intervalTimer = setTimeout(runAndSchedule, autoDelayIntervalMs);
}; };
initialTimer = setTimeout(async () => { initialTimer = setTimeout(async () => {
await checkCurrentProxyDelay(); await checkCurrentProxyDelay();
if (disposed) return; if (disposed) return;
intervalTimer = setTimeout(runAndSchedule, AUTO_CHECK_INTERVAL_MS); intervalTimer = setTimeout(runAndSchedule, autoDelayIntervalMs);
}, AUTO_CHECK_INITIAL_DELAY_MS); }, AUTO_CHECK_INITIAL_DELAY_MS);
return () => { return () => {
@@ -614,6 +625,7 @@ export const CurrentProxyCard = () => {
}; };
}, [ }, [
checkCurrentProxyDelay, checkCurrentProxyDelay,
autoDelayIntervalMs,
isDirectMode, isDirectMode,
state.selection.group, state.selection.group,
state.selection.proxy, state.selection.proxy,
+85 -79
View File
@@ -1,5 +1,5 @@
import { Box, Typography, alpha, useTheme } from "@mui/material"; import { Box, Typography, alpha, useTheme } from "@mui/material";
import { ReactNode } from "react"; import React, { forwardRef, ReactNode } from "react";
// 自定义卡片组件接口 // 自定义卡片组件接口
interface EnhancedCardProps { interface EnhancedCardProps {
@@ -19,103 +19,109 @@ interface EnhancedCardProps {
} }
// 自定义卡片组件 // 自定义卡片组件
export const EnhancedCard = ({ export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
title, (
icon, {
action, title,
children, icon,
iconColor = "primary", action,
minHeight, children,
noContentPadding = false, iconColor = "primary",
}: EnhancedCardProps) => { minHeight,
const theme = useTheme(); noContentPadding = false,
const isDark = theme.palette.mode === "dark"; },
ref,
) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
// 统一的标题截断样式 // 统一的标题截断样式
const titleTruncateStyle = { const titleTruncateStyle = {
minWidth: 0, minWidth: 0,
maxWidth: "100%", maxWidth: "100%",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", whiteSpace: "nowrap",
display: "block", display: "block",
}; };
return ( return (
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
borderRadius: 2,
backgroundColor: isDark ? "#282a36" : "#ffffff",
}}
>
<Box <Box
sx={{ sx={{
px: 2, height: "100%",
py: 1,
display: "flex", display: "flex",
alignItems: "center", flexDirection: "column",
justifyContent: "space-between", borderRadius: 2,
borderBottom: 1, backgroundColor: isDark ? "#282a36" : "#ffffff",
borderColor: "divider",
}} }}
ref={ref}
> >
<Box <Box
sx={{ sx={{
px: 2,
py: 1,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
minWidth: 0, justifyContent: "space-between",
flex: 1, borderBottom: 1,
overflow: "hidden", borderColor: "divider",
}} }}
> >
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", minWidth: 0,
borderRadius: 1.5, flex: 1,
width: 38, overflow: "hidden",
height: 38,
mr: 1.5,
flexShrink: 0,
backgroundColor: alpha(theme.palette[iconColor].main, 0.12),
color: theme.palette[iconColor].main,
}} }}
> >
{icon} <Box
</Box> sx={{
<Box sx={{ minWidth: 0, flex: 1 }}> display: "flex",
{typeof title === "string" ? ( alignItems: "center",
<Typography justifyContent: "center",
variant="h6" borderRadius: 1.5,
fontWeight="medium" width: 38,
fontSize={18} height: 38,
sx={titleTruncateStyle} mr: 1.5,
title={title} flexShrink: 0,
> backgroundColor: alpha(theme.palette[iconColor].main, 0.12),
{title} color: theme.palette[iconColor].main,
</Typography> }}
) : ( >
<Box sx={titleTruncateStyle}>{title}</Box> {icon}
)} </Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
{typeof title === "string" ? (
<Typography
variant="h6"
fontWeight="medium"
fontSize={18}
sx={titleTruncateStyle}
title={title}
>
{title}
</Typography>
) : (
<Box sx={titleTruncateStyle}>{title}</Box>
)}
</Box>
</Box> </Box>
{action && <Box sx={{ ml: 2, flexShrink: 0 }}>{action}</Box>}
</Box>
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
p: noContentPadding ? 0 : 2,
...(minHeight && { minHeight }),
}}
>
{children}
</Box> </Box>
{action && <Box sx={{ ml: 2, flexShrink: 0 }}>{action}</Box>}
</Box> </Box>
<Box );
sx={{ },
flex: 1, );
display: "flex",
flexDirection: "column",
p: noContentPadding ? 0 : 2,
...(minHeight && { minHeight }),
}}
>
{children}
</Box>
</Box>
);
};
+2 -2
View File
@@ -24,7 +24,7 @@ import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useRefreshAll } from "@/hooks/use-clash-data"; import { useAppData } from "@/providers/app-data-context";
import { openWebUrl, updateProfile } from "@/services/cmds"; import { openWebUrl, updateProfile } from "@/services/cmds";
import { showNotice } from "@/services/notice-service"; import { showNotice } from "@/services/notice-service";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
@@ -281,7 +281,7 @@ export const HomeProfileCard = ({
}: HomeProfileCardProps) => { }: HomeProfileCardProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const refreshAll = useRefreshAll(); const { refreshAll } = useAppData();
// 更新当前订阅 // 更新当前订阅
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false);
+321 -283
View File
@@ -5,19 +5,32 @@ import {
VisibilityOutlined, VisibilityOutlined,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material"; import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
import { memo, useCallback, useEffect, useRef, useState } from "react"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useEffect } from "foxact/use-abortable-effect";
import { useIntersection } from "foxact/use-intersection";
import type { XOR } from "foxts/ts-xor";
import {
memo,
useCallback,
useState,
useEffectEvent,
useMemo,
forwardRef,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWRImmutable from "swr/immutable";
import { getIpInfo } from "@/services/api"; import { getIpInfo } from "@/services/api";
import { SWR_EXTERNAL_API } from "@/services/config";
import { EnhancedCard } from "./enhanced-card"; import { EnhancedCard } from "./enhanced-card";
// 定义刷新时间(秒) // 定义刷新时间(秒)
const IP_REFRESH_SECONDS = 300; const IP_REFRESH_SECONDS = 300;
const COUNTDOWN_TICK_INTERVAL = 5_000;
const IP_INFO_CACHE_KEY = "cv_ip_info_cache"; const IP_INFO_CACHE_KEY = "cv_ip_info_cache";
// 提取InfoItem子组件并使用memo优化 const InfoItem = memo(({ label, value }: { label: string; value?: string }) => (
const InfoItem = memo(({ label, value }: { label: string; value: string }) => (
<Box sx={{ mb: 0.7, display: "flex", alignItems: "flex-start" }}> <Box sx={{ mb: 0.7, display: "flex", alignItems: "flex-start" }}>
<Typography <Typography
variant="body2" variant="body2"
@@ -43,7 +56,7 @@ const InfoItem = memo(({ label, value }: { label: string; value: string }) => (
)); ));
// 获取国旗表情 // 获取国旗表情
const getCountryFlag = (countryCode: string) => { const getCountryFlag = (countryCode: string | undefined) => {
if (!countryCode) return ""; if (!countryCode) return "";
const codePoints = countryCode const codePoints = countryCode
.toUpperCase() .toUpperCase()
@@ -52,162 +65,184 @@ const getCountryFlag = (countryCode: string) => {
return String.fromCodePoint(...codePoints); return String.fromCodePoint(...codePoints);
}; };
// IP信息卡片组件 type CountDownState = XOR<
export const IpInfoCard = () => { {
const { t } = useTranslation(); type: "countdown";
const [ipInfo, setIpInfo] = useState<any>(null); remainingSeconds: number;
const [loading, setLoading] = useState(true); },
const [error, setError] = useState(""); {
const [showIp, setShowIp] = useState(false); type: "revalidating";
const [countdown, setCountdown] = useState(IP_REFRESH_SECONDS); }
const lastFetchRef = useRef<number | null>(null); >;
const fetchIpInfo = useCallback( const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
async (force = false) => { ({ children }, ref) => {
setError(""); const { t } = useTranslation();
const { mutate } = useIPInfo();
try {
if (!force && typeof window !== "undefined" && window.sessionStorage) {
const raw = window.sessionStorage.getItem(IP_INFO_CACHE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
const now = Date.now();
if (
parsed?.ts &&
parsed?.data &&
now - parsed.ts < IP_REFRESH_SECONDS * 1000
) {
setIpInfo(parsed.data);
lastFetchRef.current = parsed.ts;
const elapsed = Math.floor((now - parsed.ts) / 1000);
setCountdown(Math.max(IP_REFRESH_SECONDS - elapsed, 0));
setLoading(false);
return;
}
}
}
} catch (e) {
console.warn("Failed to read IP info from sessionStorage:", e);
}
try {
setLoading(true);
const data = await getIpInfo();
setIpInfo(data);
const ts = Date.now();
lastFetchRef.current = ts;
try {
if (typeof window !== "undefined" && window.sessionStorage) {
window.sessionStorage.setItem(
IP_INFO_CACHE_KEY,
JSON.stringify({ data, ts }),
);
}
} catch (e) {
console.warn("Failed to write IP info to sessionStorage:", e);
}
setCountdown(IP_REFRESH_SECONDS);
} catch (err) {
setError(
err instanceof Error
? err.message
: t("home.components.ipInfo.errors.load"),
);
} finally {
setLoading(false);
}
},
[t],
);
// 组件加载时获取IP信息并启动基于上次请求时间的倒计时
useEffect(() => {
fetchIpInfo();
let timer: number | null = null;
const startCountdown = () => {
timer = window.setInterval(() => {
const now = Date.now();
let ts = lastFetchRef.current;
try {
if (!ts && typeof window !== "undefined" && window.sessionStorage) {
const raw = window.sessionStorage.getItem(IP_INFO_CACHE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
ts = parsed?.ts || null;
}
}
} catch (e) {
console.warn("Failed to read IP info from sessionStorage:", e);
ts = ts || null;
}
const elapsed = ts ? Math.floor((now - ts) / 1000) : 0;
let remaining = IP_REFRESH_SECONDS - elapsed;
if (remaining <= 0) {
fetchIpInfo();
remaining = IP_REFRESH_SECONDS;
}
// 每5秒或倒计时结束时才更新UI
if (remaining % 5 === 0 || remaining <= 0) {
setCountdown(remaining);
}
}, 1000);
};
startCountdown();
return () => {
if (timer) clearInterval(timer);
};
}, [fetchIpInfo]);
const toggleShowIp = useCallback(() => {
setShowIp((prev) => !prev);
}, []);
// 渲染加载状态
if (loading) {
return ( return (
<EnhancedCard <EnhancedCard
title={t("home.components.ipInfo.title")} title={t("home.components.ipInfo.title")}
icon={<LocationOnOutlined />} icon={<LocationOnOutlined />}
iconColor="info" iconColor="info"
ref={ref}
action={ action={
<IconButton <IconButton size="small" onClick={() => mutate()}>
size="small"
onClick={() => fetchIpInfo(true)}
disabled={true}
>
<RefreshOutlined /> <RefreshOutlined />
</IconButton> </IconButton>
} }
> >
{children}
</EnhancedCard>
);
},
);
// IP信息卡片组件
export const IpInfoCard = () => {
const { t } = useTranslation();
const [showIp, setShowIp] = useState(false);
const appWindow = useMemo(() => getCurrentWebviewWindow(), []);
// track ip info card has been in viewport or not
// hasIntersected default to false, and will be true once the card is in viewport
// and will never be false again afterwards (unless resetIntersected is called or
// the component is unmounted)
const [containerRef, hasIntersected, _resetIntersected] = useIntersection({
rootMargin: "0px",
});
const [countdown, setCountdown] = useState<CountDownState>({
type: "countdown",
remainingSeconds: IP_REFRESH_SECONDS,
});
const { data: ipInfo, error, isLoading, mutate } = useIPInfo();
// function useEffectEvent
const onCountdownTick = useEffectEvent(async () => {
const now = Date.now();
const ts = ipInfo?.lastFetchTs;
if (!ts) {
return;
}
const elapsed = Math.floor((now - ts) / 1000);
const remaining = IP_REFRESH_SECONDS - elapsed;
if (remaining <= 0) {
if (
// has intersected at least once
// this avoids unncessary revalidation if user never scrolls down,
// then we will only load initially once.
hasIntersected &&
// is online
navigator.onLine &&
// there is no ongoing revalidation already scheduled
countdown.type !== "revalidating" &&
// window is visible
(await appWindow.isVisible())
) {
setCountdown({ type: "revalidating" });
// we do not care about the result of mutate here. after mutate is done,
// simply wait for next interval tick with `setCountdown({ type: "countdown", ... })`
try {
await mutate();
} finally {
// in case mutate throws error, we still need to reset the countdown state
setCountdown({
type: "countdown",
remainingSeconds: IP_REFRESH_SECONDS,
});
}
} else {
// do nothing. we even skip "setCountdown" to reduce re-renders
//
// but the remaining time still <= 0, and setInterval is not stopped, this
// callback will still be regularly triggered, as soon as the window is visible
// or network online again, we mutate() immediately in the following tick.
}
} else {
setCountdown({
type: "countdown",
remainingSeconds: remaining,
});
}
});
// Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired
useEffect(() => {
let timer: number | null = null;
// Do not add document.hidden check here as it is not reliable in Tauri.
//
// Thank god IntersectionObserver is a DOM API that relies on DOM/webview
// instead of Tauri, which is reliable enough.
if (hasIntersected) {
console.debug(
"IP info card has entered the viewport, starting the countdown interval.",
);
timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL);
} else {
console.debug(
"IP info card has not yet entered the viewport, no counting down.",
);
}
// This will fire when the window is minimized or restored
document.addEventListener("visibilitychange", onVisibilityChange);
// Tauri's visibility change detection is actually broken on some platforms:
// https://github.com/tauri-apps/tauri/issues/10592
//
// It is working on macOS though (tested).
// So at least we should try to pause countdown on supported platforms to
// reduce power consumption.
function onVisibilityChange() {
if (document.hidden) {
console.debug("Document hidden, pause the interval");
// Pause the timer
if (timer != null) {
clearInterval(timer);
timer = null;
}
} else if (hasIntersected) {
console.debug("Document visible, resume the interval");
// Resume the timer only when previous one is cleared
if (timer == null) {
timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL);
}
} else {
console.debug(
"Document visible, but IP info card has never entered the viewport, not even once, not starting the interval.",
);
}
}
return () => {
if (timer != null) clearInterval(timer);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [hasIntersected]);
const toggleShowIp = useCallback(() => {
setShowIp((prev) => !prev);
}, []);
let mainElement: React.ReactElement;
switch (true) {
case isLoading:
mainElement = (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Skeleton variant="text" width="60%" height={30} /> <Skeleton variant="text" width="60%" height={30} />
<Skeleton variant="text" width="80%" height={24} /> <Skeleton variant="text" width="80%" height={24} />
<Skeleton variant="text" width="70%" height={24} /> <Skeleton variant="text" width="70%" height={24} />
<Skeleton variant="text" width="50%" height={24} /> <Skeleton variant="text" width="50%" height={24} />
</Box> </Box>
</EnhancedCard> );
); break;
} case !!error:
mainElement = (
// 渲染错误状态
if (error) {
return (
<EnhancedCard
title={t("home.components.ipInfo.title")}
icon={<LocationOnOutlined />}
iconColor="info"
action={
<IconButton size="small" onClick={() => fetchIpInfo(true)}>
<RefreshOutlined />
</IconButton>
}
>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@@ -219,170 +254,173 @@ export const IpInfoCard = () => {
}} }}
> >
<Typography variant="body1" color="error"> <Typography variant="body1" color="error">
{error} {error instanceof Error
? error.message
: t("home.components.ipInfo.errors.load")}
</Typography> </Typography>
<Button onClick={() => fetchIpInfo(true)} sx={{ mt: 2 }}> <Button onClick={() => mutate()} sx={{ mt: 2 }}>
{t("shared.actions.retry")} {t("shared.actions.retry")}
</Button> </Button>
</Box> </Box>
</EnhancedCard> );
); break;
} default: // Normal render
mainElement = (
// 渲染正常数据 <Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
return ( <Box
<EnhancedCard sx={{
title={t("home.components.ipInfo.title")} display: "flex",
icon={<LocationOnOutlined />} flexDirection: "row",
iconColor="info" flex: 1,
action={ overflow: "hidden",
<IconButton size="small" onClick={() => fetchIpInfo(true)}> }}
<RefreshOutlined /> >
</IconButton> {/* 左侧:国家和IP地址 */}
} <Box sx={{ width: "40%", overflow: "hidden" }}>
>
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Box
sx={{
display: "flex",
flexDirection: "row",
flex: 1,
overflow: "hidden",
}}
>
{/* 左侧:国家和IP地址 */}
<Box sx={{ width: "40%", overflow: "hidden" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
mb: 1,
overflow: "hidden",
}}
>
<Box
component="span"
sx={{
fontSize: "1.5rem",
mr: 1,
display: "inline-block",
width: 28,
textAlign: "center",
flexShrink: 0,
fontFamily: '"twemoji mozilla", sans-serif',
}}
>
{getCountryFlag(ipInfo?.country_code)}
</Box>
<Typography
variant="subtitle1"
sx={{
fontWeight: "medium",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "100%",
}}
>
{ipInfo?.country || t("home.components.ipInfo.labels.unknown")}
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ flexShrink: 0 }}
>
{t("home.components.ipInfo.labels.ip")}:
</Typography>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
ml: 1, mb: 1,
overflow: "hidden", overflow: "hidden",
maxWidth: "calc(100% - 30px)",
}} }}
> >
<Typography <Box
variant="body2" component="span"
sx={{ sx={{
fontFamily: "monospace", fontSize: "1.5rem",
fontSize: "0.75rem", mr: 1,
overflow: "hidden", display: "inline-block",
textOverflow: "ellipsis", width: 28,
wordBreak: "break-all", textAlign: "center",
flexShrink: 0,
fontFamily: '"twemoji mozilla", sans-serif',
}} }}
> >
{showIp ? ipInfo?.ip : "••••••••••"} {getCountryFlag(ipInfo?.country_code)}
</Box>
<Typography
variant="subtitle1"
sx={{
fontWeight: "medium",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "100%",
}}
>
{ipInfo?.country ||
t("home.components.ipInfo.labels.unknown")}
</Typography> </Typography>
<IconButton size="small" onClick={toggleShowIp}>
{showIp ? (
<VisibilityOffOutlined fontSize="small" />
) : (
<VisibilityOutlined fontSize="small" />
)}
</IconButton>
</Box> </Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ flexShrink: 0 }}
>
{t("home.components.ipInfo.labels.ip")}:
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
ml: 1,
overflow: "hidden",
maxWidth: "calc(100% - 30px)",
}}
>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
fontSize: "0.75rem",
overflow: "hidden",
textOverflow: "ellipsis",
wordBreak: "break-all",
}}
>
{showIp ? ipInfo?.ip : "••••••••••"}
</Typography>
<IconButton size="small" onClick={toggleShowIp}>
{showIp ? (
<VisibilityOffOutlined fontSize="small" />
) : (
<VisibilityOutlined fontSize="small" />
)}
</IconButton>
</Box>
</Box>
<InfoItem
label={t("home.components.ipInfo.labels.asn")}
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"}
/>
</Box> </Box>
<InfoItem {/* 右侧:组织、ISP和位置信息 */}
label={t("home.components.ipInfo.labels.asn")} <Box sx={{ width: "60%", overflow: "auto" }}>
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"} <InfoItem
/> label={t("home.components.ipInfo.labels.isp")}
value={ipInfo?.organization}
/>
<InfoItem
label={t("home.components.ipInfo.labels.org")}
value={ipInfo?.asn_organization}
/>
<InfoItem
label={t("home.components.ipInfo.labels.location")}
value={[ipInfo?.city, ipInfo?.region]
.filter(Boolean)
.join(", ")}
/>
<InfoItem
label={t("home.components.ipInfo.labels.timezone")}
value={ipInfo?.timezone}
/>
</Box>
</Box> </Box>
{/* 右侧:组织、ISP和位置信息 */} <Box
<Box sx={{ width: "60%", overflow: "auto" }}>
<InfoItem
label={t("home.components.ipInfo.labels.isp")}
value={ipInfo?.isp}
/>
<InfoItem
label={t("home.components.ipInfo.labels.org")}
value={ipInfo?.asn_organization}
/>
<InfoItem
label={t("home.components.ipInfo.labels.location")}
value={[ipInfo?.city, ipInfo?.region].filter(Boolean).join(", ")}
/>
<InfoItem
label={t("home.components.ipInfo.labels.timezone")}
value={ipInfo?.timezone}
/>
</Box>
</Box>
<Box
sx={{
mt: "auto",
pt: 0.5,
borderTop: 1,
borderColor: "divider",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
opacity: 0.7,
fontSize: "0.7rem",
}}
>
<Typography variant="caption">
{t("home.components.ipInfo.labels.autoRefresh")}: {countdown}s
</Typography>
<Typography
variant="caption"
sx={{ sx={{
textOverflow: "ellipsis", mt: "auto",
overflow: "hidden", pt: 0.5,
whiteSpace: "nowrap", borderTop: 1,
borderColor: "divider",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
opacity: 0.7,
fontSize: "0.7rem",
}} }}
> >
{ipInfo?.country_code}, {ipInfo?.longitude?.toFixed(2)},{" "} <Typography variant="caption">
{ipInfo?.latitude?.toFixed(2)} {t("home.components.ipInfo.labels.autoRefresh")}
</Typography> {countdown.type === "countdown"
? `: ${countdown.remainingSeconds}s`
: "..."}
</Typography>
<Typography
variant="caption"
sx={{
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{`${ipInfo?.country_code ?? "N/A"}, ${ipInfo?.longitude?.toFixed(2) ?? "N/A"}, ${ipInfo?.latitude?.toFixed(2) ?? "N/A"}`}
</Typography>
</Box>
</Box> </Box>
</Box> );
</EnhancedCard> }
return (
<IPInfoCardContainer ref={containerRef}>{mainElement}</IPInfoCardContainer>
); );
}; };
function useIPInfo() {
return useSWRImmutable(IP_INFO_CACHE_KEY, getIpInfo, SWR_EXTERNAL_API);
}
+16 -24
View File
@@ -10,14 +10,13 @@ import { useLockFn } from "ahooks";
import { useCallback, useEffect, useMemo, useReducer } from "react"; import { useCallback, useEffect, useMemo, useReducer } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import useSWR from "swr";
import { useServiceInstaller } from "@/hooks/use-service-installer"; import { useServiceInstaller } from "@/hooks/use-service-installer";
import { useSystemState } from "@/hooks/use-system-state"; import { useSystemState } from "@/hooks/use-system-state";
import { useUpdate } from "@/hooks/use-update";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { getSystemInfo } from "@/services/cmds"; import { getSystemInfo } from "@/services/cmds";
import { showNotice } from "@/services/notice-service"; import { showNotice } from "@/services/notice-service";
import { checkUpdateSafe as checkUpdate } from "@/services/update";
import { version as appVersion } from "@root/package.json"; import { version as appVersion } from "@root/package.json";
import { EnhancedCard } from "./enhanced-card"; import { EnhancedCard } from "./enhanced-card";
@@ -52,6 +51,18 @@ export const SystemInfoCard = () => {
const { isAdminMode, isSidecarMode } = useSystemState(); const { isAdminMode, isSidecarMode } = useSystemState();
const { installServiceAndRestartCore } = useServiceInstaller(); const { installServiceAndRestartCore } = useServiceInstaller();
// 自动检查更新逻辑
const { checkUpdate: triggerCheckUpdate } = useUpdate(true, {
onSuccess: () => {
const now = Date.now();
localStorage.setItem("last_check_update", now.toString());
dispatchSystemState({
type: "set-last-check-update",
payload: new Date(now).toLocaleString(),
});
},
});
// 系统信息状态 // 系统信息状态
const [systemState, dispatchSystemState] = useReducer(systemStateReducer, { const [systemState, dispatchSystemState] = useReducer(systemStateReducer, {
osInfo: "", osInfo: "",
@@ -109,7 +120,7 @@ export const SystemInfoCard = () => {
timeoutId = window.setTimeout(() => { timeoutId = window.setTimeout(() => {
if (verge?.auto_check_update) { if (verge?.auto_check_update) {
checkUpdate().catch(console.error); triggerCheckUpdate().catch(console.error);
} }
}, 5000); }, 5000);
} }
@@ -118,26 +129,7 @@ export const SystemInfoCard = () => {
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
} }
}; };
}, [verge?.auto_check_update, dispatchSystemState]); }, [verge?.auto_check_update, dispatchSystemState, triggerCheckUpdate]);
// 自动检查更新逻辑
useSWR(
verge?.auto_check_update ? "checkUpdate" : null,
async () => {
const now = Date.now();
localStorage.setItem("last_check_update", now.toString());
dispatchSystemState({
type: "set-last-check-update",
payload: new Date(now).toLocaleString(),
});
return await checkUpdate();
},
{
revalidateOnFocus: false,
refreshInterval: 24 * 60 * 60 * 1000, // 每天检查一次
dedupingInterval: 60 * 60 * 1000, // 1小时内不重复检查
},
);
// 导航到设置页面 // 导航到设置页面
const goToSettings = useCallback(() => { const goToSettings = useCallback(() => {
@@ -164,7 +156,7 @@ export const SystemInfoCard = () => {
// 检查更新 // 检查更新
const onCheckUpdate = useLockFn(async () => { const onCheckUpdate = useLockFn(async () => {
try { try {
const info = await checkUpdate(); const info = await triggerCheckUpdate();
if (!info?.available) { if (!info?.available) {
showNotice.success( showNotice.success(
"settings.components.verge.advanced.notifications.latestVersion", "settings.components.verge.advanced.notifications.latestVersion",
+1 -6
View File
@@ -107,10 +107,7 @@ export function TrafficGraph({ ref }: { ref?: Ref<TrafficRef> }) {
countY(y), countY(y),
]); ]);
let x = points[0][0]; context.moveTo(points[0][0], points[0][1]);
let y = points[0][1];
context.moveTo(x, y);
for (let i = 1; i < points.length; i++) { for (let i = 1; i < points.length; i++) {
const p1 = points[i]; const p1 = points[i];
@@ -120,8 +117,6 @@ export function TrafficGraph({ ref }: { ref?: Ref<TrafficRef> }) {
const y1 = (p1[1] + p2[1]) / 2; const y1 = (p1[1] + p2[1]) / 2;
context.quadraticCurveTo(p1[0], p1[1], x1, y1); context.quadraticCurveTo(p1[0], p1[1], x1, y1);
x = x1;
y = y1;
} }
}; };
+2 -15
View File
@@ -1,10 +1,8 @@
import { Button } from "@mui/material"; import { Button } from "@mui/material";
import { useRef } from "react"; import { useRef } from "react";
import useSWR from "swr";
import { DialogRef } from "@/components/base"; import { DialogRef } from "@/components/base";
import { useVerge } from "@/hooks/use-verge"; import { useUpdate } from "@/hooks/use-update";
import { checkUpdateSafe } from "@/services/update";
import { UpdateViewer } from "../setting/mods/update-viewer"; import { UpdateViewer } from "../setting/mods/update-viewer";
@@ -14,20 +12,9 @@ interface Props {
export const UpdateButton = (props: Props) => { export const UpdateButton = (props: Props) => {
const { className } = props; const { className } = props;
const { verge } = useVerge();
const { auto_check_update } = verge || {};
const viewerRef = useRef<DialogRef>(null); const viewerRef = useRef<DialogRef>(null);
const { data: updateInfo } = useSWR( const { updateInfo } = useUpdate();
auto_check_update || auto_check_update === null ? "checkUpdate" : null,
checkUpdateSafe,
{
errorRetryCount: 2,
revalidateIfStale: false,
focusThrottleInterval: 36e5, // 1 hour
},
);
if (!updateInfo?.available) return null; if (!updateInfo?.available) return null;
+4
View File
@@ -86,6 +86,10 @@ export const GroupItem = (props: Props) => {
<img <img
src={`data:image/svg+xml;base64,${btoa(group.icon ?? "")}`} src={`data:image/svg+xml;base64,${btoa(group.icon ?? "")}`}
width="32px" width="32px"
style={{
marginRight: "12px",
borderRadius: "6px",
}}
/> />
)} )}
<ListItemText <ListItemText
+26 -11
View File
@@ -490,11 +490,18 @@ export const GroupsEditorViewer = (props: Props) => {
]} ]}
value={field.value} value={field.value}
getOptionLabel={translateStrategy} getOptionLabel={translateStrategy}
renderOption={(props, option) => ( renderOption={(props, option) => {
<li {...props} title={translateStrategy(option)}> const { key, ...optionProps } = props;
{translateStrategy(option)} return (
</li> <li
)} key={key}
{...optionProps}
title={translateStrategy(option)}
>
{translateStrategy(option)}
</li>
);
}}
onChange={(_, value) => value && field.onChange(value)} onChange={(_, value) => value && field.onChange(value)}
renderInput={(params) => <TextField {...params} />} renderInput={(params) => <TextField {...params} />}
/> />
@@ -557,11 +564,18 @@ export const GroupsEditorViewer = (props: Props) => {
disableCloseOnSelect disableCloseOnSelect
onChange={(_, value) => value && field.onChange(value)} onChange={(_, value) => value && field.onChange(value)}
renderInput={(params) => <TextField {...params} />} renderInput={(params) => <TextField {...params} />}
renderOption={(props, option) => ( renderOption={(props, option) => {
<li {...props} title={translatePolicy(option)}> const { key, ...optionProps } = props;
{translatePolicy(option)} return (
</li> <li
)} key={key}
{...optionProps}
title={translatePolicy(option)}
>
{translatePolicy(option)}
</li>
);
}}
getOptionLabel={translatePolicy} getOptionLabel={translatePolicy}
/> />
</Item> </Item>
@@ -601,7 +615,7 @@ export const GroupsEditorViewer = (props: Props) => {
/> />
<TextField <TextField
autoComplete="new-password" autoComplete="new-password"
placeholder="https://cp.cloudflare.com/generate_204" placeholder="http://cp.cloudflare.com"
size="small" size="small"
sx={{ width: "calc(100% - 150px)" }} sx={{ width: "calc(100% - 150px)" }}
{...field} {...field}
@@ -827,6 +841,7 @@ export const GroupsEditorViewer = (props: Props) => {
"WireGuard", "WireGuard",
"Tuic", "Tuic",
"Mieru", "Mieru",
"Masque",
"AnyTLS", "AnyTLS",
"Sudoku", "Sudoku",
"Relay", "Relay",
@@ -141,7 +141,7 @@ export const ProxiesEditorViewer = (props: Props) => {
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => { const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
const proxies: IProxyConfig[] = []; const proxies: IProxyConfig[] = [];
const names: string[] = []; const names: string[] = [];
let uris = ""; let uris: string;
try { try {
uris = atob(proxyUri); uris = atob(proxyUri);
} catch { } catch {
@@ -557,11 +557,12 @@ export const RulesEditorViewer = (props: Props) => {
t(RULE_TYPE_LABEL_KEYS[option.name] ?? option.name) t(RULE_TYPE_LABEL_KEYS[option.name] ?? option.name)
} }
renderOption={(props, option) => { renderOption={(props, option) => {
const { key, ...optionProps } = props;
const label = t( const label = t(
RULE_TYPE_LABEL_KEYS[option.name] ?? option.name, RULE_TYPE_LABEL_KEYS[option.name] ?? option.name,
); );
return ( return (
<li {...props} title={label}> <li key={key} {...optionProps} title={label}>
{label} {label}
</li> </li>
); );
@@ -624,9 +625,10 @@ export const RulesEditorViewer = (props: Props) => {
t(PROXY_POLICY_LABEL_KEYS[option] ?? option) t(PROXY_POLICY_LABEL_KEYS[option] ?? option)
} }
renderOption={(props, option) => { renderOption={(props, option) => {
const { key, ...optionProps } = props;
const label = t(PROXY_POLICY_LABEL_KEYS[option] ?? option); const label = t(PROXY_POLICY_LABEL_KEYS[option] ?? option);
return ( return (
<li {...props} title={label}> <li key={key} {...optionProps} title={label}>
{label} {label}
</li> </li>
); );

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