Compare commits

..
198 Commits
Author SHA1 Message Date
Tunglies aff1bffcf0 fix: improve alpha tag filtering by fetching commit dates for accurate sorting 2025-06-02 18:42:26 +08:00
Tunglies 5f743438f5 fix: streamline old alpha asset deletion process with improved error handling and logging 2025-06-02 18:40:56 +08:00
Tunglies e9fa869547 fix: enhance old asset deletion process by retaining the latest alpha tag and improving error handling 2025-06-02 18:33:48 +08:00
Tunglies fdb09c8dbf fix: remove conditional execution for deleting old assets in alpha workflow 2025-06-02 18:25:01 +08:00
Tunglies aedb0d02e1 fix: comment out release version steps in alpha workflow and update version to 2.3.0-alpha.0 in package.json 2025-06-02 18:17:26 +08:00
Tunglies 49f399a9b2 feat: update release naming conventions and add TAG_CHANNEL environment variable for autobuild workflow 2025-06-02 17:59:51 +08:00
Tunglies 76cec7aa54 feat: add localization for "Already Using Latest Core Version" in English and Chinese 2025-06-02 16:09:05 +08:00
Tunglies 2223a99ed1 fix: remove old asset deletion step from autobuild workflow 2025-06-02 15:03:05 +08:00
Tunglies 4926d33849 fix: enable scheduled builds and improve asset deletion logic in autobuild workflow 2025-06-02 14:58:32 +08:00
Tunglies 9978182cae chore: update version to 2.3.0 and revise release notes 2025-06-02 14:24:02 +08:00
Tunglies 1d77bc57df feat: rename job and update release tag for autobuild process 2025-06-02 14:16:43 +08:00
Tunglies 1bcb6646c4 fix: unify default latency test URL and update workflows/backend logic
- All delay test logic, Rust MihomoManager, API, cmds, delay.ts, settings, and group editor now use the Cloudflare 204 test URL
- Fixed outdated URLs in docs and comments
- Fix Github Actions workflow: unexpected neeeds run
2025-06-02 14:07:54 +08:00
Tunglies 06130b95d2 chore: remove unused i18n-check workflow and comment out schedule in autobuild 2025-06-02 13:59:42 +08:00
Tunglies 0536a45959 feat: add autobuild version support and unify version update scripts
- Support autobuild version with timestamp (e.g. 2.3.0+autobuild.2506021530) via `pnpm release-version autobuild`
- Sync version to package.json, Cargo.toml, tauri.conf.json
- Improve version regex to support build metadata
- Remove old release_version.mjs and release-alpha_version.mjs, use release-version.mjs for all
- Fix script path issues
2025-06-02 13:57:18 +08:00
Tunglies b150eb7e64 chore: update CHANGELOG formatting and improve clarity of known issues, fixes, features, optimizations, and removals 2025-06-02 12:50:03 +08:00
Tunglies d5c0b09a2f fix: update default latency test URL to use HTTPS 2025-06-02 12:41:41 +08:00
wonfen a36d2633c9 add promotion 2025-06-02 11:48:28 +08:00
wonfen 72783e3ff5 feat: add global mutex to prevent concurrent config updates 2025-06-01 20:54:14 +08:00
Tunglies 929abb3c04 feat: add "Update via proxy" translation to English and Chinese locales 2025-06-01 19:40:50 +08:00
wonfen 6810b1f221 fix: remove getProxyProviders refreshInterval 2025-06-01 19:33:49 +08:00
Tunglies a96678caea fix: update Mihomo(Meta) kernel version to 1.19.10 and remove default window size setting 2025-05-31 17:05:55 +08:00
Tunglies 2d59177256 fix: update default window width to 940 pixels 2025-05-31 17:05:53 +08:00
Tunglies 62b743fa20 fix: add missing token parameter for create-pull-request action in i18n-check workflow 2025-05-31 00:12:42 +08:00
Tunglies b85aaa816e feat: add workflow to check for missing i18n keys in translation files 2025-05-31 00:05:28 +08:00
Tunglies 74fc3dea33 fix: refine descriptions for Rule, Global, and Direct modes in Chinese locale 2025-05-31 00:00:37 +08:00
Tunglies 55167e75af feat: add mode descriptions for Rule, Global, and Direct modes in English and Chinese locales 2025-05-30 23:59:42 +08:00
Tunglies 63d39c24dc feat: add theme translation keys for light, dark, and system modes in multiple locales 2025-05-30 23:44:12 +08:00
Tunglies 1d4d8aeb2b chore: update UPDATELOG 2025-05-30 20:57:02 +08:00
Ahao c9589fc75d Added controller translation for TCP concurrency 2025-05-30 19:05:46 +08:00
Ahao d0c3a306bc close the Default DNS Enable button 2025-05-30 18:33:10 +08:00
Tunglies 1e3566ed7d fix: optimize asynchronous handling to prevent UI blocking in various components
fix: add missing showNotice error handling and improve async UI feedback

- Add showNotice error notifications to unlock page async error branches
- Restore showNotice for YAML serialization errors in rules/groups/proxies editor
- Ensure all user-facing async errors are surfaced via showNotice
- Add fade-in animation to layout for smoother theme transition and reduce white screen
- Use requestIdleCallback/setTimeout for heavy UI state updates to avoid UI blocking
- Minor: remove window.showNotice usage, use direct import instead
2025-05-30 17:53:40 +08:00
Tunglies 756d303f6a Remove unused translations from multiple locale files 2025-05-30 17:20:51 +08:00
Tunglies a5b948a41c fix(locales): update success message translation for clarity 2025-05-30 16:32:04 +08:00
Ahao 6440f856a2 add:tcp concurrency 2025-05-30 14:05:59 +08:00
Tunglies 25acb8121f docs: contributing for ubuntu 2025-05-29 16:07:55 +08:00
Tunglies c05bd31f6b fix(sysinfo): update verge_version retrieval to use package_info instead of config 2025-05-28 18:50:03 +08:00
Tunglies 9416de6442 fix(config): auto complete and patch secret field when loading config
- Ensure the 'secret' field is present and non-empty during config loading
- Auto-fill with default value if missing to improve compatibility and security
- Update config guard logic for robustness
2025-05-28 17:26:26 +08:00
Tunglies c507c483fb refactor: update tauri-plugin-autostart source to use git repository and set macos launchagent name 2025-05-26 22:26:52 +08:00
Tunglies 32ee1b36d2 refactor: replace isServiceAvailable with getRunningMode for service status checks 2025-05-26 16:08:16 +08:00
Tunglies 2b89f07fe5 refactor: remove old window state file deletion and add compatibility for legacy shortcuts 2025-05-25 22:39:28 +08:00
Shuqiao ZhangandTunglies 0621a17d17 feat: update Windows shortcut name (#2973)
* feat: update Windows shortcut name

* chore: UPDATELOG

---------

Co-authored-by: Tunglies <tunglies.dev@outlook.com>
2025-05-25 22:17:06 +08:00
wonfen 4840e07da8 refactor: remove redundant logic 2025-05-25 21:56:04 +08:00
wonfen af1689ee07 refactor: add debounce to optimize config updates and provider refresh handling 2025-05-25 21:34:48 +08:00
AhaoandAhao d3dbc11b1b modify the port detection range to prevent the use of system ports (#3587)
Co-authored-by: Ahao <108321411+xuanyuan0408@users.noreply.github.com>
2025-05-25 17:05:08 +08:00
Tunglies 10479b0936 chore: update UPDATELOG 2025-05-25 10:34:30 +08:00
Ahao fa1f9875d9 fix dependency issues 2025-05-24 19:58:37 +08:00
Tunglies 7466139320 fix(ControllerViewer): remove isRestarting condition from button and input disabled states 2025-05-24 17:04:32 +08:00
Tunglies 7a5bcf67c5 refactor(ControllerViewer): replace manual core restart logic with service call to restartCore 2025-05-24 17:00:25 +08:00
Tunglies 10e3010324 feat(config): add new allowed origins for CORS in clash.rs (#3569)
Include "https://yacd.metacubex.one", "https://metacubex.github.io",
and "https://board.zash.run.place" to the list of allowed origins for
both HTTP and WebSocket configurations.
2025-05-24 16:56:35 +08:00
Tunglies 464f8095ab fix: update CORS allow-origins to support tauri local development
- Replaced 'http://localhost' and 'http://127.0.0.1' with 'tauri://localhost' and 'http://tauri.localhost' for enhanced compatibility with Tauri development environment.
- Ensures proper CORS configuration for local testing.
2025-05-22 19:18:48 +08:00
Tunglies 8b800f679b optimize and enforce external-controller-cors config for all users
- Refactored config guard logic to always inject secure defaults for external-controller-cors (allow-origins: ['http://localhost', 'http://127.0.0.1'], allow-private-network: true).
- Ensures both new and existing user configs are automatically upgraded for enhanced CORS security.
- Improves configuration migration and future maintainability.

fix: add http://localhost:3000 to CORS allow-origins for tauri dev server compatibility
2025-05-22 14:36:21 +08:00
Tunglies 5b2f946828 feat: restrict proxy group selection to Selector type in home node card
- Only Selector-type groups are available for selection in the group dropdown on the home page node card.
- Node (proxy) selection remains unrestricted.
- Improves user experience and avoids confusion with non-selector groups.
2025-05-22 11:38:26 +08:00
Tunglies b1c31f7a6f Revert "fix: ensure app state is saved on exit and window events for better state management"
This reverts commit 9af0803e9b.

chore: update UPDATELOG
2025-05-22 11:19:38 +08:00
Tunglies d49618786e chore: temporarily deprecate tauri-plugin-window-state integration
- Remove or disable usage of tauri-plugin-window-state due to compatibility or stability concerns.
- Will revisit and re-enable after further evaluation or upstream fixes.
2025-05-22 10:22:16 +08:00
Tunglies 866ec2720d Revert "feat: integrate window state management for improved window restoration and logging"
This reverts commit c9a9e934f4.
2025-05-22 10:17:42 +08:00
Tunglies b4595d7886 fix: add fallback to first proxy in group if 'now' is missing and prioritize selector-type groups
- When the current group or its 'now' field is missing, fallback to the first proxy in the group's list to ensure a valid active node is always selected.
- When no current group is found, prioritize groups of type 'Selector' for selection, improving compatibility with custom or extended proxy groups.
2025-05-22 10:10:26 +08:00
Tunglies 9af0803e9b fix: ensure app state is saved on exit and window events for better state management 2025-05-22 09:54:25 +08:00
Tunglies ebecabc0cc rm: deno.lock 2025-05-22 03:04:33 +08:00
Tunglies c9a9e934f4 feat: integrate window state management for improved window restoration and logging 2025-05-22 03:02:02 +08:00
Ahao 544f63a2ff update log port settings 2025-05-21 15:29:46 +08:00
Ahao 1dbfc96ebf optimize port setting exit and save mechanism 2025-05-21 15:27:43 +08:00
Ahao 250dcf569c beradaptasi dengan bahasa terjemahan terbaru 2025-05-21 10:58:49 +08:00
Ahao 0ef552d384 optimize the icon of API controller 2025-05-21 10:31:13 +08:00
Tunglies 998ded5e0b refactor: remove unused imports and adjust button height in logs and rules pages 2025-05-21 10:25:50 +08:00
Tunglies d60c3b4d64 feat: improve initialization logic to prevent duplicated initialization and improve logging in Layout component 2025-05-21 00:22:47 +08:00
Tunglies b6db209670 docs: update CONTRIBUTING.md to include guidelines for Rust code formatting and quality checks 2025-05-20 23:09:50 +08:00
Ahao 29fd97e402 remove redundant fields 2025-05-20 23:07:50 +08:00
Tunglies 32ebc8d174 refactor: simplify event handling in CoreManager by removing unnecessary match statement 2025-05-20 23:01:32 +08:00
Tunglies 420b0a254a feat: add formatting command to package.json and update pre-commit hook for cargo fmt 2025-05-20 22:58:25 +08:00
Ahao 400efa00ec optimized the random port and key mechanism to prevent random freezing! optimized the save mechanism, using smooth functions to prevent the client from freezing! Optimized the translation problem! 2025-05-20 22:51:11 +08:00
Tunglies 4024b72954 feat: enhance auto log cleaning options with 1-day retention and update related configurations 2025-05-20 22:34:02 +08:00
Ahao 7cd1816866 optimized port settings and added one-click random API port and key/separate refresh button 2025-05-20 18:49:16 +08:00
Ahao 5983ac5449 Fix getDelayFix hook issue 2025-05-20 18:15:05 +08:00
wonfen b70e202201 refactor: simplify frontend event dispatch by restructuring notification system 2025-05-18 20:58:59 +08:00
wonfen cc5ebec0cb feat: add state management for core switching, upgrading and restart functionality 2025-05-18 18:37:09 +08:00
AhaoandAhao 1d2fd06507 synchronous Languag (#3534)
* fixed the issue of IP field assignment conflict

* updated the shuffling algorithm and added a deduplication detection mechanism

* fixed an issue with .window-state.json files not being deleted via the NSIS installer

* adaptation translation

* update language

* standardized the format

---------

Co-authored-by: Ahao <108321411+xuanyuan0408@users.noreply.github.com>
2025-05-18 17:57:41 +08:00
wonfen 8d99af4c15 chore: simplify service logging 2025-05-18 17:53:57 +08:00
wonfen 861428d3bd fix: sidecar core log path 2025-05-18 09:58:33 +08:00
Tunglies 00e3f13bc9 fix: profile-view wrong type of http request timeout value 2025-05-18 00:52:59 +08:00
AhaoandAhao 2a897ecf8a adaptation translation (#3532)
* fixed the issue of IP field assignment conflict

* updated the shuffling algorithm and added a deduplication detection mechanism

* fixed an issue with .window-state.json files not being deleted via the NSIS installer

* adaptation translation

---------

Co-authored-by: Ahao <108321411+xuanyuan0408@users.noreply.github.com>
2025-05-17 19:48:44 +08:00
wonfen 8448217bd4 refactor: move event dispatch logic into notifier thread to improve efficiency 2025-05-17 19:42:43 +08:00
wonfen 305a64c3e3 feat: optimize service initialization logic to fallback to Sidecar mode on installation failure 2025-05-17 19:13:24 +08:00
wonfen e1d6c74e4f refactor: extract service installation logic to custom hook and optimize related component handling 2025-05-17 19:11:17 +08:00
Tunglies 7556758284 refactor: remove macOS window decoration setting during window creation 2025-05-17 14:28:34 +08:00
Ahao 3926288b7f fixed an issue with .window-state.json files not being deleted via the NSIS installer (#3530) 2025-05-17 13:15:04 +08:00
Tunglies 4d56a5cd8c Revert "feat: add application name and icon to macOS entitlements plist"
This reverts commit 6fe7aabe14.
2025-05-17 01:42:11 +08:00
Tunglies d554679ee2 feat: remove system-in name plist 2025-05-17 01:21:08 +08:00
Tunglies 6fe7aabe14 feat: add application name and icon to macOS entitlements plist 2025-05-17 01:05:21 +08:00
wonfen f4e57a2831 fix: retain traffic data when switching chart time range 2025-05-16 18:03:02 +08:00
wonfen 32008b9364 fix: update main window creation logic with improved logging and UI readiness handling 2025-05-16 17:22:53 +08:00
wonfen 9d89eeb974 perf: optimize event listeners in update viewer and test item components with proper cleanup on unmount
(cherry picked from commit 2e4e939e0b05040c8f38248f3663978a0212cd50)
2025-05-16 16:10:37 +08:00
wonfen 95c23730ef perf: async config handling with optimized port lookup and save logic 2025-05-16 13:47:12 +08:00
Ahao 7acbb5da4f updated the shuffling algorithm and added a deduplication detection mechanism (#3521) 2025-05-16 12:26:50 +08:00
wonfen ddd85d4d87 perf & fix: optimize window state initialization & add missing permissions 2025-05-16 09:50:53 +08:00
Tunglies 53a46d0dc6 refactor: simplify proxy type handling and improve error handling in network commands 2025-05-16 00:09:47 +08:00
Tunglies 92ae277e3a chore: remove incorrect type comment 2025-05-15 22:34:09 +08:00
wonfen dd190659ef fix: error typo 2025-05-15 18:12:50 +08:00
wonfen 6c5488be70 fix: theme color not following system preference 2025-05-15 16:51:35 +08:00
AhaoandAhao 316e1d4268 fixed the issue of IP field assignment conflict (#3513)
Co-authored-by: Ahao <108321411+xuanyuan0408@users.noreply.github.com>
2025-05-14 23:13:52 +08:00
wonfen c3c13ba6e6 chore: update lock file 2025-05-14 22:26:19 +08:00
wonfen b0a82bc7ad feat: remove custom window button 2025-05-14 22:15:02 +08:00
wonfen 7f0ebbd83d feat: use OS default window manager 2025-05-14 21:32:26 +08:00
AhaoandAhao 4f86f3c0ec refactored IP detection and added zashboard redirect URL (#3510)
* Fixed the language display size issue and updated the translation synchronously!

* Fixed Russian language display issue

* refactored IP detection and added zashboard redirect URL

---------

Co-authored-by: Ahao <108321411+xuanyuan0408@users.noreply.github.com>
2025-05-14 20:32:45 +08:00
Tunglies c100b9c54d feat: log levels to 'warn' in mihomo default settings and verge default settings; 2025-05-14 16:06:02 +08:00
THR-hub ebc9fc5eba Fix issue when reinstalled the DNS overwrite button may show a wrong status. (#3505) 2025-05-14 12:57:37 +08:00
AhaoandAhao 29ae70bbf6 Fixed the language display size issue and updated the translation synchronously! (#3507)
* Fixed the language display size issue and updated the translation synchronously!

* Fixed Russian language display issue

---------

Co-authored-by: Ahao <108321411+xuanyuan0408@users.noreply.github.com>
2025-05-14 12:20:45 +08:00
wonfen becc51bcd2 feat: replace traffic chart rendering component for performance improvement and React 19 compatibility 2025-05-14 12:16:59 +08:00
wonfen 1993e5dd51 chore: upgrade to react 19 2025-05-14 10:47:33 +08:00
wonfen d1a2bd78d7 perf: improve install status detection to ensure frontend consistency 2025-05-13 23:34:30 +08:00
AhaoandAhao a5521404b6 Added support for the Japanese language and improved multilingual translation in the Rev module. Note: This update affects various labels, descriptions, and messages throughout the entire file. (#3502)
* May 13, 2025, 15:30 – Added support for the Japanese language and improved multilingual translation in the Rev module. Note: This update affects various labels, descriptions, and messages throughout the entire file. If you encounter any issues, please report them via GitHub Issues. By: Ahaohaohao

* update

* bug fixes

---------

Co-authored-by: Ahao <108321411+xuanyuan0408@users.noreply.github.com>
2025-05-13 18:19:43 +08:00
Taiizor 8aa7b34197 feat(i18n): Add Turkish (tr-TR) localization (#3498)
* Add Turkish language support

Added Turkish (tr) translations to the application by creating a new locale file `tr.json` and updating the language options in `setting-verge-basic.tsx` and `i18n.ts` to include Turkish.

* Update Turkish translations for proxy terminology

Revised Turkish translations in `tr.json` to replace 'Proxy' with 'Vekil' for consistency and clarity. This change affects various labels, descriptions, and messages throughout the file.
2025-05-13 07:32:37 +08:00
Tunglies 1ddbe7c2cc feat: add service uninstall functionality and improve service operation flow 2025-05-13 01:56:19 +08:00
Tunglies 6578cd8c51 fix: TUN switch button logical bug 2025-05-13 01:11:20 +08:00
Tunglies c2c46d1cae chore: update UPDATELOG 2025-05-13 00:27:13 +08:00
wonfen 95231e7cd3 fix: http request error 2025-05-12 22:10:46 +08:00
Tunglies 5b6c9be99f feat: better service status and TUN mode usable checks in Setting Page 2025-05-12 19:04:21 +08:00
wonfen d587ed09a5 fix: # 3479, set all fontLigatures to false 2025-05-11 23:05:47 +08:00
wonfen 10576780ed feat: add hosts settings to DNS settings and enhance DNS config validation 2025-05-11 22:55:31 +08:00
wonfen b37b121afb chore: disable tray speed by default 2025-05-11 19:25:32 +08:00
wonfen 2b69ed4915 fix: window tiitle bar 2025-05-11 13:27:42 +08:00
wonfen 9a666d807c fix: restore window on tray click after close on Windows 2025-05-10 17:25:42 +08:00
wonfen 050b363066 fix: resolve Windows build error 2025-05-10 01:45:39 +08:00
wonfen 8cae9d4e0a refactor: use async approach to restructure UI startup logic and resolve various freeze issues during launch 2025-05-10 01:26:03 +08:00
wonfen b5b65ac8c5 fix: windows ipc errors 2025-05-09 01:29:10 +08:00
wonfen 7370f00857 feat: add authenticated service IPC communication 2025-05-08 00:14:58 +08:00
wonfen dc798fe2dd chore: update deps & replace deprecated elements 2025-05-05 00:26:04 +08:00
derozan eda8fc125f Add Korean language support (#3457)
thx
2025-05-04 22:51:23 +08:00
wonfen 8296675574 refactor: notification system 2025-05-04 22:17:08 +08:00
wonfen e2ad2d23f8 feat: enhance WebSocket management with connection status logging, error handling, and improved data parsing 2025-05-04 20:47:50 +08:00
wonfen d8df283e93 feat: make get system and auto proxy functions async, use spawn_blocking for blocking tasks 2025-05-04 20:35:04 +08:00
wonfen ce42ca77a9 fix: timeout mechanism for sending notice messages 2025-05-04 16:47:10 +08:00
wonfen cfe8328f9e feat: add sorting functionality to current node list on homepage 2025-05-04 16:27:44 +08:00
wonfen ff5a2c6ca4 feat: add backend config change listener to update frontend state 2025-05-04 13:28:08 +08:00
wonfen 23b0493d0b perf: optimize network requests and update logic to support aborting 2025-05-04 11:25:48 +08:00
wonfen 779291151e fix: network request failure termination logic and add connection pool optimization 2025-05-04 10:14:52 +08:00
wonfen ba0f4cdde0 refactor: separate notification thread in frontend to prevent UI freeze 2025-05-04 09:21:32 +08:00
wonfen 3983762202 feat: add more network control methods 2025-05-03 23:17:51 +08:00
wonfen c72413cbe6 feat: enhance traffic monitoring, optimize data handling logic, and add error retry mechanism and timeout control 2025-05-03 09:53:44 +08:00
wonfen 73b9a71c84 feat: add retry mechanism to optimize network requests, improve async message handling and timeout for scheduled tasks 2025-05-03 09:38:25 +08:00
wonfen e7bf997f8c chore: update deps 2025-05-01 10:00:05 +08:00
wonfen dbfcc80afe perf: optimize system proxy settings update logic 2025-04-30 23:09:19 +08:00
wonfen 32b6821b8a feat: add network manager to optimize network request handling 2025-04-30 21:30:28 +08:00
Tunglies 3ce5d3bf2d fix: update required service version to 1.0.6 2025-04-28 06:16:14 +08:00
Tunglies f6023dc618 fix: macOS-specific TUN device name on macOS 2025-04-27 16:33:15 +08:00
Tunglies 9d9dd73790 chore: remove fast-dev profile and enhance dev profile settings 2025-04-27 15:13:34 +08:00
tiegen 7383459b9a feat: clean proxy settings before exit (#3404) 2025-04-26 18:09:53 +08:00
wonfen 55cde38562 feat: improve core functionality to prevent main process blocking, enhance MihomoManager, and optimize window creation process 2025-04-25 18:24:16 +08:00
wonfen d6a79316a6 feat: toggle next auto-update time on subscription card click and show update result feedback 2025-04-25 17:17:34 +08:00
wonfen bd3231bfa8 perf: avoid reloading active config when editing inactive subscription 2025-04-25 14:37:50 +08:00
wonfen 8d62c0d521 feat: auto-fallback to Clash proxy on scheduled subscription updates; refactor fallback logic and add request timeout 2025-04-24 22:51:45 +08:00
wonfen 4d37e6870c feat: auto-refresh rule data when switching to rules page 2025-04-24 10:24:29 +08:00
wonfen 0bb042e085 feat: sysproxy guard can detect and recover from unexpected changes 2025-04-24 09:09:23 +08:00
Tunglies 65ed9cb5b2 chore: update UPDATELOG 2025-04-24 01:14:41 +08:00
逐雁南飛andTunglies ffc0693afc [修复]修改代理设置后,如果修改之前代理是开启状态,自动重新应用代理设置。 (#3391)
* [修复]修改代理设置后,如果修改之前代理是开启状态,自动重新应用代理设置。

* chore: update sysproxy version

---------

Co-authored-by: Tunglies <tunglies.dev@outlook.com>
2025-04-24 01:10:01 +08:00
wonfen 2622cc06eb feat: enhance Webdav backup directory check and file upload retry mechanism 2025-04-23 01:10:01 +08:00
wonfen 15b117dc15 feat: remove cross-platform restriction on WebDAV backup restore 2025-04-23 00:48:40 +08:00
wonfen 16846aefd3 feat: allow webdav directory redirect 2025-04-23 00:42:57 +08:00
wonfen babc2c8f9b feat: add UA to webdav 2025-04-23 00:29:29 +08:00
Tunglies a0b70f9c34 2.2.4-alpha.2 2025-04-22 08:04:24 +08:00
wonfen dc46eb9d21 fix: add recovery lock to prevent concurrency issues during lightweight mode exit 2025-04-21 12:20:55 +08:00
Tunglies 824ad9fa29 feat: add tray menu indicator for lightweight mode #3386 2025-04-21 04:34:25 +08:00
Christine. 0646fa96a6 Fix:VLESS URL decoding with network type error, #3388 (#3390) 2025-04-21 00:50:32 +08:00
wonfen d05952e945 fix: resolve lightweight mode recovery failure caused by white screen prevention 2025-04-21 00:15:02 +08:00
Tunglies abe5cf1b84 2.2.4-alpha.1 2025-04-21 00:12:14 +08:00
Tunglies 07b424cb09 fix: cargo clippy errors 2025-04-21 00:06:37 +08:00
Tunglies c7494de0a7 chore: update UPDATELOG 2025-04-20 23:51:05 +08:00
Tunglies 1a9a2ff9e0 fix: unexpected global hotkey regestion for windows 'Ctrl+Q' #3389 2025-04-20 23:49:06 +08:00
wonfen 4f15bdf74d feat: add IP info request retry mechanism to reduce network errors 2025-04-20 10:34:43 +08:00
wonfen 541b78ba6f fix: restart clash core after change it 2025-04-19 22:23:58 +08:00
wonfen 05b910dc17 feat: close all connections when disable sysporxy 2025-04-19 15:10:49 +08:00
wonfen 41629df189 feat: autolaunch via Startup folder on windows 2025-04-19 13:51:11 +08:00
wonfen c718ef3058 chore: disable admin mode autolaunch limitation 2025-04-19 12:37:01 +08:00
Mozi 2b1d02f0cc fix(logs): make button's tooltip correspond to log setting (#3345) 2025-04-19 00:07:55 +08:00
Mozi 272a0c916d fix(layout): make app icon and name draggable (#3346)
The "the-newbtn" UpdateButton is still NOT draggable as expected.
2025-04-19 00:06:30 +08:00
StarHeart 16187858a3 fix: disable underline of startup script (#3357) 2025-04-19 00:00:39 +08:00
Tunglies f6ea01238a chore: update UPDATELOG 2025-04-17 17:14:18 +08:00
逐雁南飛 ae4067aee6 [feat]取消使用默认绕过且当绕过设置为空的时候使用默认绕过填充绕过配置以便用户基于默认配置修改。 (#3365) 2025-04-17 17:12:58 +08:00
wonfen d401a83c75 feat: check window render size and wait for completion to prevent white screen 2025-04-17 16:52:38 +08:00
Tunglies 41a662f784 chore: fix ci for alpha-for-linux-arm 2025-04-17 16:47:21 +08:00
Tunglies 1df9fff0a7 2.2.4-alpha.0 2025-04-17 16:47:21 +08:00
Tunglies 40de99e65d fix: wrong pnpm package version of plugin-window-state 2025-04-17 16:47:21 +08:00
Tunglies 76227770a4 chore: update UPDATELOG 2025-04-17 16:47:21 +08:00
逐雁南飛 b70cad537c 添加代理主机的设置,允许代理设置为其他IP(非127.0.0.1) (#2963)
允许下拉选择ip地址(支持IPv6)、localhost、以及当前系统的主机名,同时兼容手工输入
2025-04-17 16:47:21 +08:00
希亚的西红柿 1282cc56bf fix: auto enable light mode config does not take effect when silent s… (#3344)
* fix: auto enable light mode config does not take effect when silent start is enabled

* refactor: extract run_once_lightweight logic into lightweight.rs as a fn
2025-04-17 16:47:21 +08:00
wonfen 85d08fadd9 feat: check window render size and wait for completion to prevent white screen 2025-04-17 16:47:21 +08:00
wonfen fad73a281a feat: manage windows with tauri window-state to address various window issues 2025-04-17 16:47:21 +08:00
wonfen 18d24d5952 chore: enable ipv6 by default 2025-04-12 23:16:31 +08:00
Tunglies 6e6462742c fix: resolve unexpected behavior during silent startup #3320, #3187 2025-04-12 18:09:27 +08:00
Tunglies 16381d1895 chore: patch alpha hash version 2025-04-12 17:54:58 +08:00
Tunglies 1970155c51 fix: macos use hotkey closing window can not entry auto-lightweight-mode #3326 2025-04-12 03:29:57 +08:00
Tunglies 51ef1329be Refactor async runtime usage with AsyncHandler wrapper 2025-04-11 18:00:35 +08:00
Tunglies b6a6f5f434 Add AsyncHandler for wrapping task spawning 2025-04-11 17:30:30 +08:00
Tunglies 644fdc071f rm: unused tauri-plugin-window-state 2025-04-10 15:52:37 +08:00
Tunglies 25d66a4eee Update autostart capability permissions
Enable more precise control over autostart functionality and clean up
logging code by removing unnecessary boolean parameters.
2025-04-10 15:43:45 +08:00
Tunglies 9070ef1dbe v2.2.4-alpha begin 2025-04-10 11:40:38 +08:00
Tunglies f700dc124e chore: update UPDATELOG 2025-04-10 11:37:58 +08:00
wonfen 5fcea4c684 fix: resolve freeze issue caused by rapid "Proxy Mode" switching on homepage 2025-04-10 11:34:41 +08:00
Tunglies 843f40d7d5 fix: unlock test error message to "Unsupported Country/Region" 2025-04-10 11:30:52 +08:00
154 changed files with 16307 additions and 7488 deletions
+146 -136
View File
@@ -2,122 +2,132 @@ name: Alpha Build
on:
workflow_dispatch:
schedule:
# UTC+8 0,6,12,18
- cron: "0 16,22,4,10 * * *"
push:
tags:
- "v*.*.*-alpha*"
permissions: write-all
env:
TAG_NAME: alpha
TAG_CHANNEL: Alpha
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
concurrency:
# only allow per workflow per commit (and not pr) to run at a time
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
check_commit:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check if version changed or src changed
id: check
run: |
# For manual workflow_dispatch, always run
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "should_run=true" >> $GITHUB_OUTPUT
exit 0
fi
# Store current version from package.json
CURRENT_VERSION=$(cat package.json | jq -r '.version')
echo "Current version: $CURRENT_VERSION"
# Get the previous commit's package.json version
git checkout HEAD~1 package.json
PREVIOUS_VERSION=$(cat package.json | jq -r '.version')
echo "Previous version: $PREVIOUS_VERSION"
# Reset back to current commit
git checkout HEAD package.json
# Check if version changed
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
echo "Version changed from $PREVIOUS_VERSION to $CURRENT_VERSION"
echo "should_run=true" >> $GITHUB_OUTPUT
exit 0
fi
# Check if src or src-tauri directories changed
CURRENT_SRC_HASH=$(git rev-parse HEAD:src)
PREVIOUS_SRC_HASH=$(git rev-parse HEAD~1:src 2>/dev/null || echo "")
CURRENT_TAURI_HASH=$(git rev-parse HEAD:src-tauri 2>/dev/null || echo "")
PREVIOUS_TAURI_HASH=$(git rev-parse HEAD~1:src-tauri 2>/dev/null || echo "")
echo "Current src hash: $CURRENT_SRC_HASH"
echo "Previous src hash: $PREVIOUS_SRC_HASH"
echo "Current tauri hash: $CURRENT_TAURI_HASH"
echo "Previous tauri hash: $PREVIOUS_TAURI_HASH"
if [ "$CURRENT_SRC_HASH" != "$PREVIOUS_SRC_HASH" ] || [ "$CURRENT_TAURI_HASH" != "$PREVIOUS_TAURI_HASH" ]; then
echo "Source directories changed"
echo "should_run=true" >> $GITHUB_OUTPUT
else
echo "Version and source directories unchanged"
echo "should_run=false" >> $GITHUB_OUTPUT
fi
delete_old_assets:
needs: check_commit
if: ${{ needs.check_commit.outputs.should_run == 'true' }}
name: Delete Old Alpha Release Assets and Tags
runs-on: ubuntu-latest
steps:
- name: Delete Old Alpha Release Assets
- name: Delete Old Alpha Tags Except Latest
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const releaseTag = 'alpha';
const tagPattern = /-alpha.*/; // 匹配带有 -alpha 的 tag
const owner = context.repo.owner;
const repo = context.repo.repo;
try {
// Get the release by tag name
const { data: release } = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag: releaseTag
// 获取所有 tag
const { data: tags } = await github.rest.repos.listTags({
owner,
repo,
per_page: 100 // 调整 per_page 以获取更多 tag
});
console.log(`Found release with ID: ${release.id}`);
// 过滤出包含 -alpha 的 tag
const alphaTags = (await Promise.all(
tags
.filter(tag => tagPattern.test(tag.name))
.map(async tag => {
// 获取每个 tag 的 commit 信息以获得日期
const { data: commit } = await github.rest.repos.getCommit({
owner,
repo,
ref: tag.commit.sha
});
return {
...tag,
commitDate: commit.committer && commit.committer.date ? commit.committer.date : commit.commit.author.date
};
})
)).sort((a, b) => {
// 按 commit 日期降序排序(最新的在前面)
return new Date(b.commitDate) - new Date(a.commitDate);
});
// Delete each asset
if (release.assets && release.assets.length > 0) {
console.log(`Deleting ${release.assets.length} assets`);
console.log(`Found ${alphaTags.length} alpha tags`);
for (const asset of release.assets) {
console.log(`Deleting asset: ${asset.name} (${asset.id})`);
await github.rest.repos.deleteReleaseAsset({
owner: context.repo.owner,
repo: context.repo.repo,
asset_id: asset.id
if (alphaTags.length === 0) {
console.log('No alpha tags found');
return;
}
// 保留最新的 tag
const latestTag = alphaTags[0];
console.log(`Keeping latest alpha tag: ${latestTag.name}`);
// 处理其他旧的 alpha tag
for (const tag of alphaTags.slice(1)) {
console.log(`Processing tag: ${tag.name}`);
// 获取与 tag 关联的 release
try {
const { data: release } = await github.rest.repos.getReleaseByTag({
owner,
repo,
tag: tag.name
});
}
console.log('All assets deleted successfully');
} else {
console.log('No assets found to delete');
// 删除 release 下的所有资产
if (release.assets && release.assets.length > 0) {
console.log(`Deleting ${release.assets.length} assets for release ${tag.name}`);
for (const asset of release.assets) {
console.log(`Deleting asset: ${asset.name} (${asset.id})`);
await github.rest.repos.deleteReleaseAsset({
owner,
repo,
asset_id: asset.id
});
}
}
// 删除 release
console.log(`Deleting release for tag: ${tag.name}`);
await github.rest.repos.deleteRelease({
owner,
repo,
release_id: release.id
});
// 删除 tag
console.log(`Deleting tag: ${tag.name}`);
await github.rest.git.deleteRef({
owner,
repo,
ref: `tags/${tag.name}`
});
} catch (error) {
if (error.status === 404) {
console.log(`No release found for tag ${tag.name}, deleting tag directly`);
await github.rest.git.deleteRef({
owner,
repo,
ref: `tags/${tag.name}`
});
} else {
console.error(`Error processing tag ${tag.name}:`, error);
throw error;
}
}
}
console.log('Old alpha tags and releases deleted successfully');
} catch (error) {
if (error.status === 404) {
console.log('Release not found, nothing to delete');
} else {
console.error('Error:', error);
throw error;
}
console.error('Error:', error);
throw error;
}
update_tag:
@@ -128,22 +138,18 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Fetch Alpha update logs
id: fetch_alpha_logs
- name: Fetch UPDATE logs
id: fetch_update_logs
run: |
# Check if UPDATELOG.md exists
if [ -f "UPDATELOG.md" ]; then
# Extract the section starting with ## and containing -alpha until the next ## or end of file
# ALPHA_LOGS=$(awk '/^## .*-alpha/{flag=1; print; next} /^## /{flag=0} flag' UPDATELOG.md)
ALPHA_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md)
if [ -n "$ALPHA_LOGS" ]; then
echo "Found alpha update logs"
echo "ALPHA_LOGS<<EOF" >> $GITHUB_ENV
echo "$ALPHA_LOGS" >> $GITHUB_ENV
UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md)
if [ -n "$UPDATE_LOGS" ]; then
echo "Found update logs"
echo "UPDATE_LOGS<<EOF" >> $GITHUB_ENV
echo "$UPDATE_LOGS" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
else
echo "No alpha sections found in UPDATELOG.md"
echo "No update sections found in UPDATELOG.md"
fi
else
echo "UPDATELOG.md file not found"
@@ -156,17 +162,15 @@ jobs:
shell: bash
- run: |
# 检查 ALPHA_LOGS 是否存在,如果不存在则使用默认消息
if [ -z "$ALPHA_LOGS" ]; then
echo "No alpha logs found, using default message"
ALPHA_LOGS="More new features are now supported. Check for detailed changelog soon."
if [ -z "$UPDATE_LOGS" ]; then
echo "No update logs found, using default message"
UPDATE_LOGS="More new features are now supported. Check for detailed changelog soon."
else
echo "Using found alpha logs"
echo "Using found update logs"
fi
# 生成 release.txt 文件
cat > release.txt << EOF
$ALPHA_LOGS
$UPDATE_LOGS
## 我应该下载哪个版本?
@@ -189,7 +193,6 @@ jobs:
- arm64架构: arm64_fixed_webview2-setup.exe
### FAQ
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
@@ -201,14 +204,15 @@ jobs:
- name: Upload Release
uses: softprops/action-gh-release@v2
with:
tag_name: alpha
name: "Clash Verge Rev Alpha"
tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
body_path: release.txt
prerelease: true
token: ${{ secrets.GITHUB_TOKEN }}
generate_release_notes: true
alpha:
alpha-x86-windows-macos-linux:
name: Alpha x86 Windows, MacOS and Linux
needs: update_tag
strategy:
fail-fast: false
@@ -224,7 +228,6 @@ jobs:
target: x86_64-apple-darwin
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Repository
@@ -264,8 +267,8 @@ jobs:
pnpm i
pnpm check ${{ matrix.target }}
- name: Release Alpha Version
run: pnpm release-alpha-version
# - name: Release ${{ env.TAG_CHANNEL }} Version
# run: pnpm release-version ${{ env.TAG_NAME }}
- name: Tauri build
uses: tauri-apps/tauri-action@v0
@@ -281,15 +284,16 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: alpha
releaseName: "Clash Verge Rev Alpha"
tagName: ${{ env.TAG_NAME }}
releaseName: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
releaseBody: "More new features are now supported."
releaseDraft: false
prerelease: true
tauriScript: pnpm
args: --target ${{ matrix.target }}
alpha-for-linux-arm:
alpha-arm-linux:
name: Alpha ARM Linux
needs: update_tag
strategy:
fail-fast: false
@@ -333,11 +337,11 @@ jobs:
pnpm i
pnpm check ${{ matrix.target }}
- name: Release Alpha Version
run: pnpm release-alpha-version
# - name: Release ${{ env.TAG_CHANNEL }} Version
# run: pnpm release-version ${{ env.TAG_NAME }}
- name: "Setup for linux"
run: |-
- name: Setup for linux
run: |
sudo ls -lR /etc/apt/
cat > /tmp/sources.list << EOF
@@ -356,9 +360,14 @@ jobs:
sudo mv /tmp/sources.list /etc/apt/sources.list
sudo dpkg --add-architecture ${{ matrix.arch }}
sudo apt update
sudo apt-get update -y
sudo apt-get -f install -y
sudo apt install -y \
sudo apt-get install -y \
linux-libc-dev:${{ matrix.arch }} \
libc6-dev:${{ matrix.arch }}
sudo apt-get install -y \
libxslt1.1:${{ matrix.arch }} \
libwebkit2gtk-4.1-dev:${{ matrix.arch }} \
libayatana-appindicator3-dev:${{ matrix.arch }} \
@@ -366,14 +375,14 @@ jobs:
patchelf:${{ matrix.arch }} \
librsvg2-dev:${{ matrix.arch }}
- name: "Install aarch64 tools"
- name: Install aarch64 tools
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt install -y \
gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu
- name: "Install armv7 tools"
- name: Install armv7 tools
if: matrix.target == 'armv7-unknown-linux-gnueabihf'
run: |
sudo apt install -y \
@@ -406,15 +415,16 @@ jobs:
- name: Upload Release
uses: softprops/action-gh-release@v2
with:
tag_name: alpha
name: "Clash Verge Rev Alpha"
tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
prerelease: true
token: ${{ secrets.GITHUB_TOKEN }}
files: |
src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
alpha-for-fixed-webview2:
alpha-x86-arm-windows_webview2:
name: Alpha x86 and ARM Windows with WebView2
needs: update_tag
strategy:
fail-fast: false
@@ -456,8 +466,8 @@ jobs:
pnpm i
pnpm check ${{ matrix.target }}
- name: Release Alpha Version
run: pnpm release-alpha-version
# - name: Release ${{ env.TAG_CHANNEL }} Version
# run: pnpm release-version ${{ env.TAG_NAME }}
- name: Download WebView2 Runtime
run: |
@@ -501,13 +511,13 @@ jobs:
- name: Upload Release
uses: softprops/action-gh-release@v2
with:
tag_name: alpha
name: "Clash Verge Rev Alpha"
tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
prerelease: true
token: ${{ secrets.GITHUB_TOKEN }}
files: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*setup*
- name: Portable Bundle
run: pnpm portable-fixed-webview2 ${{ matrix.target }} --alpha
run: pnpm portable-fixed-webview2 ${{ matrix.target }} --${{ env.TAG_NAME }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+462
View File
@@ -0,0 +1,462 @@
name: Auto Build
on:
workflow_dispatch:
schedule:
# UTC+8 0,6,12,18
- cron: "0 16,22,4,10 * * *"
permissions: write-all
env:
TAG_NAME: autobuild
TAG_CHANNEL: AutoBuild
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
concurrency:
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
check_commit:
name: Check Commit Needs Build
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check if version changed or src changed
id: check
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "should_run=true" >> $GITHUB_OUTPUT
exit 0
fi
CURRENT_VERSION=$(cat package.json | jq -r '.version')
echo "Current version: $CURRENT_VERSION"
git checkout HEAD~1 package.json
PREVIOUS_VERSION=$(cat package.json | jq -r '.version')
echo "Previous version: $PREVIOUS_VERSION"
git checkout HEAD package.json
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
echo "Version changed from $PREVIOUS_VERSION to $CURRENT_VERSION"
echo "should_run=true" >> $GITHUB_OUTPUT
exit 0
fi
CURRENT_SRC_HASH=$(git rev-parse HEAD:src)
PREVIOUS_SRC_HASH=$(git rev-parse HEAD~1:src 2>/dev/null || echo "")
CURRENT_TAURI_HASH=$(git rev-parse HEAD:src-tauri 2>/dev/null || echo "")
PREVIOUS_TAURI_HASH=$(git rev-parse HEAD~1:src-tauri 2>/dev/null || echo "")
echo "Current src hash: $CURRENT_SRC_HASH"
echo "Previous src hash: $PREVIOUS_SRC_HASH"
echo "Current tauri hash: $CURRENT_TAURI_HASH"
echo "Previous tauri hash: $PREVIOUS_TAURI_HASH"
if [ "$CURRENT_SRC_HASH" != "$PREVIOUS_SRC_HASH" ] || [ "$CURRENT_TAURI_HASH" != "$PREVIOUS_TAURI_HASH" ]; then
echo "Source directories changed"
echo "should_run=true" >> $GITHUB_OUTPUT
else
echo "Version and source directories unchanged"
echo "should_run=false" >> $GITHUB_OUTPUT
fi
update_tag:
name: Update tag
runs-on: ubuntu-latest
needs: check_commit
if: ${{ needs.check_commit.outputs.should_run == 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Fetch UPDATE logs
id: fetch_update_logs
run: |
if [ -f "UPDATELOG.md" ]; then
UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md)
if [ -n "$UPDATE_LOGS" ]; then
echo "Found update logs"
echo "UPDATE_LOGS<<EOF" >> $GITHUB_ENV
echo "$UPDATE_LOGS" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
else
echo "No update sections found in UPDATELOG.md"
fi
else
echo "UPDATELOG.md file not found"
fi
shell: bash
- name: Set Env
run: |
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
shell: bash
- run: |
if [ -z "$UPDATE_LOGS" ]; then
echo "No update logs found, using default message"
UPDATE_LOGS="More new features are now supported. Check for detailed changelog soon."
else
echo "Using found update logs"
fi
cat > release.txt << EOF
$UPDATE_LOGS
## 我应该下载哪个版本?
### MacOS
- MacOS intel芯片: x64.dmg
- MacOS apple M芯片: aarch64.dmg
### Linux
- Linux 64位: amd64.deb/amd64.rpm
- Linux arm64 architecture: arm64.deb/aarch64.rpm
- Linux armv7架构: armhf.deb/armhfp.rpm
### Windows (不再支持Win7)
#### 正常版本(推荐)
- 64位: x64-setup.exe
- arm64架构: arm64-setup.exe
#### 便携版问题很多不再提供
#### 内置Webview2版(体积较大,仅在企业版系统或无法安装webview2时使用)
- 64位: x64_fixed_webview2-setup.exe
- arm64架构: arm64_fixed_webview2-setup.exe
### FAQ
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
- name: Upload Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
body_path: release.txt
prerelease: true
token: ${{ secrets.GITHUB_TOKEN }}
generate_release_notes: true
autobuild-x86-windows-macos-linux:
name: Autobuild x86 Windows, MacOS and Linux
needs: update_tag
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: windows-latest
target: aarch64-pc-windows-msvc
- os: macos-latest
target: aarch64-apple-darwin
- os: macos-latest
target: x86_64-apple-darwin
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@stable
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
cache-all-crates: true
cache-on-failure: true
- name: Install dependencies (ubuntu only)
if: matrix.os == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: "22"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Pnpm install and check
run: |
pnpm i
pnpm check ${{ matrix.target }}
- name: Release ${{ env.TAG_CHANNEL }} Version
run: pnpm release-version ${{ env.TAG_NAME }}
- name: Tauri build
uses: tauri-apps/tauri-action@v0
env:
NODE_OPTIONS: "--max_old_space_size=4096"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: ${{ env.TAG_NAME }}
releaseName: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
releaseBody: "More new features are now supported."
releaseDraft: false
prerelease: true
tauriScript: pnpm
args: --target ${{ matrix.target }}
autobuild-arm-linux:
name: Autobuild ARM Linux
needs: update_tag
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
target: aarch64-unknown-linux-gnu
arch: arm64
- os: ubuntu-22.04
target: armv7-unknown-linux-gnueabihf
arch: armhf
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@stable
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
cache-all-crates: true
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Pnpm install and check
run: |
pnpm i
pnpm check ${{ matrix.target }}
- name: Release ${{ env.TAG_CHANNEL }} Version
run: pnpm release-version ${{ env.TAG_NAME }}
- name: Setup for linux
run: |
sudo ls -lR /etc/apt/
cat > /tmp/sources.list << EOF
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-security main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-updates main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-backports main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main multiverse universe restricted
EOF
sudo mv /etc/apt/sources.list /etc/apt/sources.list.default
sudo mv /tmp/sources.list /etc/apt/sources.list
sudo dpkg --add-architecture ${{ matrix.arch }}
sudo apt-get update -y
sudo apt-get -f install -y
sudo apt-get install -y \
linux-libc-dev:${{ matrix.arch }} \
libc6-dev:${{ matrix.arch }}
sudo apt-get install -y \
libxslt1.1:${{ matrix.arch }} \
libwebkit2gtk-4.1-dev:${{ matrix.arch }} \
libayatana-appindicator3-dev:${{ matrix.arch }} \
libssl-dev:${{ matrix.arch }} \
patchelf:${{ matrix.arch }} \
librsvg2-dev:${{ matrix.arch }}
- name: Install aarch64 tools
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt install -y \
gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu
- name: Install armv7 tools
if: matrix.target == 'armv7-unknown-linux-gnueabihf'
run: |
sudo apt install -y \
gcc-arm-linux-gnueabihf \
g++-arm-linux-gnueabihf
- name: Build for Linux
run: |
export PKG_CONFIG_ALLOW_CROSS=1
if [ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]; then
export PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig/:$PKG_CONFIG_PATH
export PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu/
elif [ "${{ matrix.target }}" == "armv7-unknown-linux-gnueabihf" ]; then
export PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig/:$PKG_CONFIG_PATH
export PKG_CONFIG_SYSROOT_DIR=/usr/arm-linux-gnueabihf/
fi
pnpm build --target ${{ matrix.target }}
env:
NODE_OPTIONS: "--max_old_space_size=4096"
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: Get Version
run: |
sudo apt-get update
sudo apt-get install jq
echo "VERSION=$(cat package.json | jq '.version' | tr -d '"')" >> $GITHUB_ENV
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
- name: Upload Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
prerelease: true
token: ${{ secrets.GITHUB_TOKEN }}
files: |
src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
autobuild-x86-arm-windows_webview2:
name: Autobuild x86 and ARM Windows with WebView2
needs: update_tag
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
arch: x64
- os: windows-latest
target: aarch64-pc-windows-msvc
arch: arm64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
cache-all-crates: true
cache-on-failure: true
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: "22"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Pnpm install and check
run: |
pnpm i
pnpm check ${{ matrix.target }}
- name: Release ${{ env.TAG_CHANNEL }} Version
run: pnpm release-version ${{ env.TAG_NAME }}
- name: Download WebView2 Runtime
run: |
invoke-webrequest -uri https://github.com/westinyang/WebView2RuntimeArchive/releases/download/109.0.1518.78/Microsoft.WebView2.FixedVersionRuntime.109.0.1518.78.${{ matrix.arch }}.cab -outfile Microsoft.WebView2.FixedVersionRuntime.109.0.1518.78.${{ matrix.arch }}.cab
Expand .\Microsoft.WebView2.FixedVersionRuntime.109.0.1518.78.${{ matrix.arch }}.cab -F:* ./src-tauri
Remove-Item .\src-tauri\tauri.windows.conf.json
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json tauri.windows.conf.json
- name: Tauri build
id: build
uses: tauri-apps/tauri-action@v0
env:
NODE_OPTIONS: "--max_old_space_size=4096"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
with:
tauriScript: pnpm
args: --target ${{ matrix.target }}
- name: Rename
run: |
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.exe$", "_fixed_webview2-setup.exe"
Rename-Item $file.FullName $newName
}
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*.nsis.zip"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.nsis\.zip$", "_fixed_webview2-setup.nsis.zip"
Rename-Item $file.FullName $newName
}
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe.sig"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.exe\.sig$", "_fixed_webview2-setup.exe.sig"
Rename-Item $file.FullName $newName
}
- name: Upload Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
prerelease: true
token: ${{ secrets.GITHUB_TOKEN }}
files: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*setup*
- name: Portable Bundle
run: pnpm portable-fixed-webview2 ${{ matrix.target }} --${{ env.TAG_NAME }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -9,3 +9,4 @@ scripts/_env.sh
.vscode
.tool-versions
.idea
.old
+4 -2
View File
@@ -3,14 +3,16 @@
#pnpm pretty-quick --staged
# 运行 clippy fmt
cargo fmt --manifest-path ./src-tauri/Cargo.toml
cd src-tauri
cargo fmt
if [ $? -ne 0 ]; then
echo "rustfmt failed to format the code. Please fix the issues and try again."
exit 1
fi
cd ..
#git add .
git add .
# 允许提交
exit 0
+24 -1
View File
@@ -33,11 +33,16 @@ npm install pnpm -g
```
### Install Dependencies
Install node packages
```shell
pnpm install
```
Install apt packages ONLY for Ubuntu
```shell
apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
```
### Download the Mihomo Core Binary
You have two options for downloading the clash binary:
@@ -96,6 +101,24 @@ pnpm portable
## Contributing Your Changes
#### Before commit your changes
If you changed the rust code, it's recommanded to execute code style formatting and quailty checks.
1. Code style formatting
```bash
$ clash-verge-rev: cd src-tauri
$ clash-verge-rev/src-tauri: cargo fmt
```
2. Code quailty checks
```bash
$ clash-verge-rev: pnpm clippy
```
Once you have made your changes:
1. Fork the repository.
+13 -1
View File
@@ -29,7 +29,7 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple).
## Promotion
[狗狗加速 —— 技术流机场 Doggygo VPN](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### [狗狗加速 —— 技术流机场 Doggygo VPN](https://verge.dginv.click/#/register?code=oaxsAGo6)
- 高性能海外机场,免费试用,优惠套餐,解锁流媒体,全球首家支持 Hysteria 协议。
- 使用 Clash Verge 专属邀请链接注册送 3 天,每天 1G 流量免费试用:[点此注册](https://verge.dginv.click/#/register?code=oaxsAGo6)
@@ -41,6 +41,18 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple).
- 解锁流媒体及 ChatGPT
- 官网:[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
- 基于性能强劲的 Rust 和 Tauri 2 框架
+96
View File
@@ -1,3 +1,99 @@
## v2.3.0
尽管外部控制密钥已自动补全默认值且不允许为空。仍然推荐自行修改外部控制密钥。
#### ⚠️ 已知问题
- 仅在Ubuntu 22.04/24.04,Fedora 41 **Gnome桌面环境** 做过简单测试,不保证其他其他Linux发行版可用,将在未来做进一步适配和调优
- MacOS 下 墙贴主要为浅色,Tray 图标深色时图标闪烁;彩色 Tray 速率颜色淡
- 窗口状态管理器已确定上游存在缺陷,暂时移除。当前不再内置窗口大小和位置记忆。
- MacOS 下卸载服务后需手动重启软件才能与内核通信。
### 2.3.0 相对于 2.2.3
#### 🐞 修复问题
- 首页"代理模式"快速切换导致的卡死问题
- 解锁测试报错信息
- Macos 快捷键关闭窗口无法启用自动轻量模式
- 静默启动异常窗口创建和关闭流程
- Windows 错误的全局快捷键 `Ctrl+Q` 注册
- Vless URL 解码时网络类型错误
- 切换自定义代理地址导致系统代理状态异常
- Macos TUN 默认无效网卡名称
- 托盘更改订阅后 UI 不同步的问题
- 修复提权漏洞,改用带认证的 IPC 通信
- 编辑器中连字符问题
- 安装服务模式后无法立即开启 TUN 模式
- 同步更新多语言翻译
- 修复 .window-state.json 无法删除的问题
- 无法修改配置更新 HTTP 请求超时
- 修复 getDelayFix 钩子问题
- 使用外部扩展脚本覆写代理组时首页无法显示代理组
- 导出诊断 Verge 版本与设置页面不同步
#### ✨ 新增功能
- Mihomo(Meta)内核升级至 1.19.10
- 允许代理主机地址设置为非 127.0.0.1 对 WSL 代理友好
- 关闭系统代理时关闭已建立的网络连接
- 托盘显示当前轻量模式状态
- Webdav 请求加入 UA
- Webdav 支持目录重定向
- Webdav 备份目录检查和文件上传重试机制
- 系统代理守卫可检查意外设置变更并恢复
- 定时自动订阅更新也能自动回退使用代理
- 订阅请求超时机制,防止订阅更新的时候卡死
- 订阅卡片点击时间可切换下次自动更新时间,自动更新触发后页面有明确的成功与否提示
- 添加网络管理器以优化网络请求处理,防止资源竞争导致的启动时 UI 卡死
- 更新依赖,替换弃用元素
- 首页当前节点增加排序功能
- DNS 覆写下增加 Hosts 设置功能
- 修复服务模式安装后无法立即开启 TUN 模式的问题
- 支持手动卸载服务模式,回退到 Sidecar 模式
- 添加了土耳其语,日本语,德语,西班牙语,繁体中文的支持
- 卸载服务的按钮
- 添加了Zashboard的一键跳转URL
- 使用操作系统默认的窗口管理器
- 切换、升级、重启内核的状态管理
- 更精细化控制自动日志清理,新增1天选项
- Winodws 快捷键名称改为 `Clash Verge`
- 配置加载阶段自动补全 external-controller secret 字段。
- 添加 TCP Concurrency (TCP并发)
#### 🚀 优化改进
- 系统代理 Bypass 设置
- Windows 下使用 Startup 文件夹的方式实现开机自启,解决管理员模式下开机自启的各种问题
- 切换到规则页面时自动刷新规则数据
- 重构更新失败回退机制,使用后端完成更新失败后回退到使用 Clash 代理再次尝试更新
- 编辑非激活订阅的时候不在触发当前订阅配置重载
- 改进核心功能防止主进程阻塞、改进MihomoManager实现,以及优化窗口创建流程
- 优化系统代理设置更新逻辑
- 重构前端通知系统分离通知线程防止前端卡死
- 优化网络请求和错误处理
- 重构通知系统
- 使用异步方法重构 UI 启动逻辑,解决启动软件过程中的各种卡死问题
- MacOS 下默认关闭托盘速率显示
- 优化服务操作流程,提升系统服务相关操作的稳定性和用户体验
- 优化了其他语言的翻译问题
- Mihomo 内核默认日志等级为 warn
- Clash Verge Rev 应用默认日志等级为 warn
- 重构了原来的 IP 信息请求重试机制,采用轮询检测,解决了 Network Error 和超时问题
- 对轮询检测机制进行了优化,引入洗牌算法来增强随机性
- 对获取系统信息的流程进行了优化,并添加了去重检测机制,确保剔除重复的信息
- 优化窗口状态初始化逻辑和添加缺失的权限设置
- 异步化配置:优化端口查找和配置保存逻辑
- 重构事件通知机制到独立线程,避免前端卡死
- 优化端口设置,每个端口可随机设置端口号
- 优化了保存机制,使用平滑函数,防止客户端卡死
- 优化端口设置退出和保存机制
- 强制为 Mihomo 配置补全并覆盖 external-controller-cors 字段,默认不允许跨域和仅本地请求,提升 cors 安全性,升级配置时自动覆盖
- 修改 端口检测范围 (1111-65536)
- 配置文件缺失 secret 字段时自动填充默认值 set-your-secret
- 优化异步处理,防止部分组件 UI 阻塞
- 关闭 DNS 启用
- 延迟测试链接更换为 Https 协议 https://cp.cloudflare.com/generate_204
#### 🗑️ 移除内容
- 窗口状态管理器
- Webdav 跨平台备份恢复限制
## v2.2.3
#### 已知问题
+33 -31
View File
@@ -1,10 +1,10 @@
{
"name": "clash-verge",
"version": "2.2.3",
"version": "2.3.0-alpha.0",
"license": "GPL-3.0-only",
"scripts": {
"dev": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev -- --profile fast-dev",
"dev:diff": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev -- --profile fast-dev",
"dev": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev",
"dev:diff": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build",
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
"tauri": "tauri",
@@ -17,9 +17,10 @@
"portable": "node scripts/portable.mjs",
"portable-fixed-webview2": "node scripts/portable-fixed-webview2.mjs",
"fix-alpha-version": "node scripts/fix-alpha_version.mjs",
"release-version": "node scripts/release_version.mjs",
"release-version": "node scripts/release-version.mjs",
"release-alpha-version": "node scripts/release-alpha_version.mjs",
"prepare": "husky",
"fmt": "cargo fmt --manifest-path ./src-tauri/Cargo.toml",
"clippy": "cargo clippy --manifest-path ./src-tauri/Cargo.toml"
},
"dependencies": {
@@ -29,47 +30,48 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@juggle/resize-observer": "^3.4.0",
"@mui/icons-material": "^6.4.8",
"@mui/lab": "6.0.0-beta.25",
"@mui/material": "^6.4.8",
"@mui/x-data-grid": "^7.28.0",
"@tauri-apps/api": "2.2.0",
"@mui/icons-material": "^7.0.2",
"@mui/lab": "7.0.0-beta.12",
"@mui/material": "^7.0.2",
"@mui/x-data-grid": "^8.2.0",
"@tauri-apps/api": "2.5.0",
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-fs": "^2.2.1",
"@tauri-apps/plugin-global-shortcut": "^2.2.0",
"@tauri-apps/plugin-notification": "^2.2.2",
"@tauri-apps/plugin-process": "^2.2.0",
"@tauri-apps/plugin-shell": "2.2.0",
"@tauri-apps/plugin-updater": "2.3.0",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "2.2.1",
"@tauri-apps/plugin-updater": "2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@types/d3-shape": "^3.1.7",
"@types/json-schema": "^7.0.15",
"ahooks": "^3.8.4",
"axios": "^1.8.3",
"chart.js": "^4.4.9",
"cli-color": "^2.0.4",
"d3-shape": "^3.2.0",
"dayjs": "1.11.13",
"foxact": "^0.2.44",
"glob": "^11.0.1",
"i18next": "^24.2.3",
"i18next": "^25.0.2",
"js-base64": "^3.7.7",
"js-yaml": "^4.1.0",
"lodash-es": "^4.17.21",
"monaco-editor": "^0.52.2",
"monaco-yaml": "^5.3.1",
"nanoid": "^5.1.5",
"peggy": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2",
"peggy": "^5.0.0",
"react": "19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "19.1.0",
"react-error-boundary": "6.0.0",
"react-hook-form": "^7.54.2",
"react-i18next": "^13.5.0",
"react-markdown": "^9.1.0",
"react-monaco-editor": "^0.56.2",
"react-router-dom": "^6.30.0",
"react-transition-group": "^4.4.5",
"react-virtuoso": "^4.12.5",
"recharts": "^2.15.1",
"react-i18next": "15.5.1",
"react-markdown": "10.1.0",
"react-monaco-editor": "0.58.0",
"react-router-dom": "7.6.0",
"react-virtuoso": "^4.12.7",
"sockette": "^2.0.6",
"swr": "^2.3.3",
"tar": "^7.4.3",
@@ -82,12 +84,12 @@
"@types/js-cookie": "^3.0.6",
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-transition-group": "^4.4.12",
"@types/react": "19.1.4",
"@types/react-dom": "19.1.5",
"@vitejs/plugin-legacy": "^6.0.2",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "4.4.1",
"adm-zip": "^0.5.16",
"commander": "^14.0.0",
"cross-env": "^7.0.3",
"https-proxy-agent": "^7.0.6",
"husky": "^9.1.7",
@@ -110,4 +112,4 @@
},
"type": "module",
"packageManager": "pnpm@9.13.2"
}
}
+1404 -1660
View File
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const LOCALES_DIR = path.resolve(__dirname, '../src/locales');
const SRC_DIRS = [
path.resolve(__dirname, '../src'),
path.resolve(__dirname, '../src-tauri')
];
const exts = ['.js', '.ts', '.tsx', '.jsx', '.vue', '.rs'];
// 递归获取所有文件
function getAllFiles(dir, exts) {
let files = [];
fs.readdirSync(dir).forEach(file => {
const full = path.join(dir, file);
if (fs.statSync(full).isDirectory()) {
files = files.concat(getAllFiles(full, exts));
} else if (exts.includes(path.extname(full))) {
files.push(full);
}
});
return files;
}
// 读取所有源码内容为一个大字符串
function getAllSourceContent() {
const files = SRC_DIRS.flatMap(dir => getAllFiles(dir, exts));
return files.map(f => fs.readFileSync(f, 'utf8')).join('\n');
}
// 白名单 key,不检查这些 key 是否被使用
const WHITELIST_KEYS = [
'theme.light',
'theme.dark',
'theme.system',
"Already Using Latest Core Version"
];
// 主流程
function processI18nFile(i18nPath, lang, allSource) {
const i18n = JSON.parse(fs.readFileSync(i18nPath, 'utf8'));
const keys = Object.keys(i18n);
const used = {};
const unused = [];
let checked = 0;
const total = keys.length;
keys.forEach(key => {
if (WHITELIST_KEYS.includes(key)) {
used[key] = i18n[key];
} else {
// 只查找一次
const regex = new RegExp(`["'\`]${key}["'\`]`);
if (regex.test(allSource)) {
used[key] = i18n[key];
} else {
unused.push(key);
}
}
checked++;
if (checked % 20 === 0 || checked === total) {
const percent = ((checked / total) * 100).toFixed(1);
process.stdout.write(`\r[${lang}] Progress: ${checked}/${total} (${percent}%)`);
if (checked === total) process.stdout.write('\n');
}
});
// 输出未使用的 key
console.log(`\n[${lang}] Unused keys:`, unused);
// 备份原文件
const oldPath = i18nPath + '.old';
fs.renameSync(i18nPath, oldPath);
// 写入精简后的 i18n 文件(保留原文件名)
fs.writeFileSync(i18nPath, JSON.stringify(used, null, 2), 'utf8');
console.log(`[${lang}] Cleaned i18n file written to src/locales/${path.basename(i18nPath)}`);
console.log(`[${lang}] Original file backed up as ${path.basename(oldPath)}`);
}
function main() {
// 支持 zhtw.json、zh-tw.json、zh_CN.json 等
const files = fs.readdirSync(LOCALES_DIR).filter(f =>
/^[a-z0-9\-_]+\.json$/i.test(f) && !f.endsWith('.old')
);
const allSource = getAllSourceContent();
files.forEach(file => {
const lang = path.basename(file, '.json');
processI18nFile(path.join(LOCALES_DIR, file), lang, allSource);
});
}
main();
+11
View File
@@ -38,6 +38,17 @@ async function updatePackageVersion(newVersion) {
const packageJson = JSON.parse(data);
// 获取键值替换
let result = packageJson.version.replace("alpha", newVersion);
// 检查当前版本号是否已经包含了 alpha- 后缀
if (!packageJson.version.includes(`alpha-`)) {
// 如果只有 alpha 而没有 alpha-,则替换为 alpha-newVersion
result = packageJson.version.replace("alpha", `alpha-${newVersion}`);
} else {
// 如果已经是 alpha-xxx 格式,则更新 xxx 部分
result = packageJson.version.replace(
/alpha-[^-]*/,
`alpha-${newVersion}`,
);
}
console.log("[INFO]: Current version is: ", result);
packageJson.version = result;
// 写入版本号
-96
View File
@@ -1,96 +0,0 @@
import fs from "fs/promises";
import path from "path";
/**
* 更新 package.json 文件中的版本号
*/
async function updatePackageVersion() {
const _dirname = process.cwd();
const packageJsonPath = path.join(_dirname, "package.json");
try {
const data = await fs.readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(data);
let result = packageJson.version;
if (!result.includes("alpha")) {
result = `${result}-alpha`;
}
console.log("[INFO]: Current package.json version is: ", result);
packageJson.version = result;
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
"utf8",
);
console.log(`[INFO]: package.json version updated to: ${result}`);
} catch (error) {
console.error("Error updating package.json version:", error);
}
}
/**
* 更新 Cargo.toml 文件中的版本号
*/
async function updateCargoVersion() {
const _dirname = process.cwd();
const cargoTomlPath = path.join(_dirname, "src-tauri", "Cargo.toml");
try {
const data = await fs.readFile(cargoTomlPath, "utf8");
const lines = data.split("\n");
const updatedLines = lines.map((line) => {
if (line.startsWith("version =")) {
const versionMatch = line.match(/version\s*=\s*"([^"]+)"/);
if (versionMatch && !versionMatch[1].includes("alpha")) {
const newVersion = `${versionMatch[1]}-alpha`;
return line.replace(versionMatch[1], newVersion);
}
}
return line;
});
await fs.writeFile(cargoTomlPath, updatedLines.join("\n"), "utf8");
} catch (error) {
console.error("Error updating Cargo.toml version:", error);
}
}
/**
* 更新 tauri.conf.json 文件中的版本号
*/
async function updateTauriConfigVersion() {
const _dirname = process.cwd();
const tauriConfigPath = path.join(_dirname, "src-tauri", "tauri.conf.json");
try {
const data = await fs.readFile(tauriConfigPath, "utf8");
const tauriConfig = JSON.parse(data);
let version = tauriConfig.version;
if (!version.includes("alpha")) {
version = `${version}-alpha`;
}
console.log("[INFO]: Current tauri.conf.json version is: ", version);
tauriConfig.version = version;
await fs.writeFile(
tauriConfigPath,
JSON.stringify(tauriConfig, null, 2),
"utf8",
);
console.log(`[INFO]: tauri.conf.json version updated to: ${version}`);
} catch (error) {
console.error("Error updating tauri.conf.json version:", error);
}
}
/**
* 主函数,依次更新所有文件的版本号
*/
async function main() {
await updatePackageVersion();
await updateCargoVersion();
await updateTauriConfigVersion();
}
main().catch(console.error);
+213
View File
@@ -0,0 +1,213 @@
/**
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, and src-tauri/tauri.conf.json.
*
* Usage:
* pnpm release-version <version>
*
* <version> can be:
* - A full semver version (e.g., 1.2.3, v1.2.3, 1.2.3-beta, v1.2.3+build)
* - A tag: "alpha", "beta", "rc", or "autobuild"
* - "alpha", "beta", "rc": Appends the tag to the current base version (e.g., 1.2.3-beta)
* - "autobuild": Appends a timestamped autobuild tag (e.g., 1.2.3+autobuild.2406101530)
*
* Examples:
* pnpm release-version 1.2.3
* pnpm release-version v1.2.3-beta
* pnpm release-version beta
* pnpm release-version autobuild
*
* The script will:
* - Validate and normalize the version argument
* - Update the version field in package.json
* - Update the version field in src-tauri/Cargo.toml
* - Update the version field in src-tauri/tauri.conf.json
*
* Errors are logged and the process exits with code 1 on failure.
*/
import fs from "fs/promises";
import path from "path";
import { program } from "commander";
/**
* 生成短时间戳(格式:YYMMDDHHMM)
* @returns {string}
*/
function generateShortTimestamp() {
const now = new Date();
const year = String(now.getFullYear()).slice(-2);
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
return `${year}${month}${day}${hours}${minutes}`;
}
/**
* 验证版本号格式
* @param {string} version
* @returns {boolean}
*/
function isValidVersion(version) {
return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?$/i.test(version);
}
/**
* 标准化版本号
* @param {string} version
* @returns {string}
*/
function normalizeVersion(version) {
return version.startsWith("v") ? version : `v${version}`;
}
/**
* 提取基础版本号(去掉所有 -tag 和 +build 部分)
* @param {string} version
* @returns {string}
*/
function getBaseVersion(version) {
let base = version.replace(/-(alpha|beta|rc)(\.\d+)?/i, '');
base = base.replace(/\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*/g, '');
return base;
}
/**
* 更新 package.json 版本号
* @param {string} newVersion
*/
async function updatePackageVersion(newVersion) {
const _dirname = process.cwd();
const packageJsonPath = path.join(_dirname, "package.json");
try {
const data = await fs.readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(data);
console.log("[INFO]: Current package.json version is: ", packageJson.version);
packageJson.version = newVersion.startsWith("v") ? newVersion.slice(1) : newVersion;
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2), "utf8");
console.log(`[INFO]: package.json version updated to: ${packageJson.version}`);
} catch (error) {
console.error("Error updating package.json version:", error);
throw error;
}
}
/**
* 更新 Cargo.toml 版本号
* @param {string} newVersion
*/
async function updateCargoVersion(newVersion) {
const _dirname = process.cwd();
const cargoTomlPath = path.join(_dirname, "src-tauri", "Cargo.toml");
try {
const data = await fs.readFile(cargoTomlPath, "utf8");
const lines = data.split("\n");
const versionWithoutV = newVersion.startsWith("v") ? newVersion.slice(1) : newVersion;
const baseVersion = getBaseVersion(versionWithoutV);
const updatedLines = lines.map((line) => {
if (line.trim().startsWith("version =")) {
return line.replace(/version\s*=\s*"[^"]+"/, `version = "${baseVersion}"`);
}
return line;
});
await fs.writeFile(cargoTomlPath, updatedLines.join("\n"), "utf8");
console.log(`[INFO]: Cargo.toml version updated to: ${baseVersion}`);
} catch (error) {
console.error("Error updating Cargo.toml version:", error);
throw error;
}
}
/**
* 更新 tauri.conf.json 版本号
* @param {string} newVersion
*/
async function updateTauriConfigVersion(newVersion) {
const _dirname = process.cwd();
const tauriConfigPath = path.join(_dirname, "src-tauri", "tauri.conf.json");
try {
const data = await fs.readFile(tauriConfigPath, "utf8");
const tauriConfig = JSON.parse(data);
const versionWithoutV = newVersion.startsWith("v") ? newVersion.slice(1) : newVersion;
const baseVersion = getBaseVersion(versionWithoutV);
console.log("[INFO]: Current tauri.conf.json version is: ", tauriConfig.version);
tauriConfig.version = baseVersion;
await fs.writeFile(tauriConfigPath, JSON.stringify(tauriConfig, null, 2), "utf8");
console.log(`[INFO]: tauri.conf.json version updated to: ${baseVersion}`);
} catch (error) {
console.error("Error updating tauri.conf.json version:", error);
throw error;
}
}
/**
* 获取当前版本号
*/
async function getCurrentVersion() {
const _dirname = process.cwd();
const packageJsonPath = path.join(_dirname, "package.json");
try {
const data = await fs.readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(data);
return packageJson.version;
} catch (error) {
console.error("Error getting current version:", error);
throw error;
}
}
/**
* 主函数
*/
async function main(versionArg) {
if (!versionArg) {
console.error("Error: Version argument is required");
process.exit(1);
}
try {
let newVersion;
const validTags = ["alpha", "beta", "rc", "autobuild"];
if (validTags.includes(versionArg.toLowerCase())) {
const currentVersion = await getCurrentVersion();
const baseVersion = getBaseVersion(currentVersion);
if (versionArg.toLowerCase() === "autobuild") {
const timestamp = generateShortTimestamp();
newVersion = `${baseVersion}+autobuild.${timestamp}`;
} else {
newVersion = `${baseVersion}-${versionArg.toLowerCase()}`;
}
} else {
if (!isValidVersion(versionArg)) {
console.error("Error: Invalid version format");
process.exit(1);
}
newVersion = normalizeVersion(versionArg);
}
console.log(`[INFO]: Updating versions to: ${newVersion}`);
await updatePackageVersion(newVersion);
await updateCargoVersion(newVersion);
await updateTauriConfigVersion(newVersion);
console.log("[SUCCESS]: All version updates completed successfully!");
} catch (error) {
console.error("[ERROR]: Failed to update versions:", error);
process.exit(1);
}
}
program
.name("pnpm release-version")
.description("Update project version numbers")
.argument("<version>", "version tag or full version")
.action(main)
.parse(process.argv);
-197
View File
@@ -1,197 +0,0 @@
import fs from "fs/promises";
import path from "path";
import { program } from "commander";
/**
* 验证版本号格式
* @param {string} version
* @returns {boolean}
*/
function isValidVersion(version) {
return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?$/i.test(version);
}
/**
* 标准化版本号(确保v前缀可选)
* @param {string} version
* @returns {string}
*/
function normalizeVersion(version) {
return version.startsWith("v") ? version : `v${version}`;
}
/**
* 更新 package.json 文件中的版本号
* @param {string} newVersion 新版本号
*/
async function updatePackageVersion(newVersion) {
const _dirname = process.cwd();
const packageJsonPath = path.join(_dirname, "package.json");
try {
const data = await fs.readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(data);
console.log(
"[INFO]: Current package.json version is: ",
packageJson.version,
);
packageJson.version = newVersion.startsWith("v")
? newVersion.slice(1)
: newVersion;
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
"utf8",
);
console.log(
`[INFO]: package.json version updated to: ${packageJson.version}`,
);
return packageJson.version;
} catch (error) {
console.error("Error updating package.json version:", error);
throw error;
}
}
/**
* 更新 Cargo.toml 文件中的版本号
* @param {string} newVersion 新版本号
*/
async function updateCargoVersion(newVersion) {
const _dirname = process.cwd();
const cargoTomlPath = path.join(_dirname, "src-tauri", "Cargo.toml");
try {
const data = await fs.readFile(cargoTomlPath, "utf8");
const lines = data.split("\n");
const versionWithoutV = newVersion.startsWith("v")
? newVersion.slice(1)
: newVersion;
const updatedLines = lines.map((line) => {
if (line.trim().startsWith("version =")) {
return line.replace(
/version\s*=\s*"[^"]+"/,
`version = "${versionWithoutV}"`,
);
}
return line;
});
await fs.writeFile(cargoTomlPath, updatedLines.join("\n"), "utf8");
console.log(`[INFO]: Cargo.toml version updated to: ${versionWithoutV}`);
} catch (error) {
console.error("Error updating Cargo.toml version:", error);
throw error;
}
}
/**
* 更新 tauri.conf.json 文件中的版本号
* @param {string} newVersion 新版本号
*/
async function updateTauriConfigVersion(newVersion) {
const _dirname = process.cwd();
const tauriConfigPath = path.join(_dirname, "src-tauri", "tauri.conf.json");
try {
const data = await fs.readFile(tauriConfigPath, "utf8");
const tauriConfig = JSON.parse(data);
const versionWithoutV = newVersion.startsWith("v")
? newVersion.slice(1)
: newVersion;
console.log(
"[INFO]: Current tauri.conf.json version is: ",
tauriConfig.version,
);
tauriConfig.version = versionWithoutV;
await fs.writeFile(
tauriConfigPath,
JSON.stringify(tauriConfig, null, 2),
"utf8",
);
console.log(
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
);
} catch (error) {
console.error("Error updating tauri.conf.json version:", error);
throw error;
}
}
/**
* 获取当前版本号(从package.json)
*/
async function getCurrentVersion() {
const _dirname = process.cwd();
const packageJsonPath = path.join(_dirname, "package.json");
try {
const data = await fs.readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(data);
return packageJson.version;
} catch (error) {
console.error("Error getting current version:", error);
throw error;
}
}
/**
* 主函数,更新所有文件的版本号
* @param {string} versionArg 版本参数(可以是标签或完整版本号)
*/
async function main(versionArg) {
if (!versionArg) {
console.error("Error: Version argument is required");
process.exit(1);
}
try {
let newVersion;
const validTags = ["alpha", "beta", "rc"];
// 判断参数是标签还是完整版本号
if (validTags.includes(versionArg.toLowerCase())) {
// 标签模式:在当前版本基础上添加标签
const currentVersion = await getCurrentVersion();
const baseVersion = currentVersion.replace(
/-(alpha|beta|rc)(\.\d+)?$/i,
"",
);
newVersion = `${baseVersion}-${versionArg.toLowerCase()}`;
} else {
// 完整版本号模式
if (!isValidVersion(versionArg)) {
console.error(
"Error: Invalid version format. Expected format: vX.X.X or vX.X.X-tag (e.g. v2.2.3 or v2.2.3-alpha)",
);
process.exit(1);
}
newVersion = normalizeVersion(versionArg);
}
console.log(`[INFO]: Updating versions to: ${newVersion}`);
await updatePackageVersion(newVersion);
await updateCargoVersion(newVersion);
await updateTauriConfigVersion(newVersion);
console.log("[SUCCESS]: All version updates completed successfully!");
} catch (error) {
console.error("[ERROR]: Failed to update versions:", error);
process.exit(1);
}
}
// Example:
// pnpm release-version 2.2.3-alpha
// 设置命令行界面
program
.name("pnpm release-version")
.description(
"Update project version numbers. Can add tag (alpha/beta/rc) or set full version (e.g. v2.2.3 or v2.2.3-alpha)",
)
.argument(
"<version>",
"version tag (alpha/beta/rc) or full version (e.g. v2.2.3 or v2.2.3-alpha)",
)
.action(main)
.parse(process.argv);
+176 -188
View File
@@ -134,9 +134,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.97"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "approx"
@@ -644,7 +644,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d59b4c170e16f0405a2e95aff44432a0d41aa97675f3d52623effe95792a037"
dependencies = [
"objc2 0.6.0",
"objc2 0.6.1",
]
[[package]]
@@ -1017,9 +1017,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.40"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
@@ -1042,7 +1042,7 @@ dependencies = [
[[package]]
name = "clash-verge"
version = "2.2.3"
version = "2.3.0"
dependencies = [
"ab_glyph",
"aes-gcm",
@@ -1056,9 +1056,13 @@ dependencies = [
"dirs 6.0.0",
"dunce",
"futures",
"gethostname 1.0.2",
"getrandom 0.3.2",
"hex",
"hmac",
"image",
"imageproc",
"lazy_static",
"libc",
"log",
"log4rs",
@@ -1077,6 +1081,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"sha2 0.10.9",
"sys-locale",
"sysinfo",
"sysproxy",
@@ -1099,6 +1104,7 @@ dependencies = [
"tungstenite 0.26.2",
"users",
"warp",
"winapi",
"winreg 0.55.0",
"zip",
]
@@ -1710,7 +1716,7 @@ dependencies = [
"hex",
"md-5",
"rand 0.8.5",
"sha2 0.10.8",
"sha2 0.10.9",
]
[[package]]
@@ -1769,7 +1775,17 @@ dependencies = [
"bitflags 2.9.0",
"block2 0.6.0",
"libc",
"objc2 0.6.0",
"objc2 0.6.1",
]
[[package]]
name = "dispatch2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.1",
]
[[package]]
@@ -2410,6 +2426,16 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "gethostname"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55"
dependencies = [
"rustix 1.0.3",
"windows-targets 0.52.6",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@@ -2567,7 +2593,7 @@ checksum = "41fbb3a4e56c901ee66c190fdb3fa08344e6d09593cc6c61f8eb9add7144b271"
dependencies = [
"crossbeam-channel",
"keyboard-types",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit 0.3.0",
"once_cell",
"serde",
@@ -3557,9 +3583,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.171"
version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libfuzzer-sys"
@@ -3917,7 +3943,7 @@ dependencies = [
"dpi",
"gtk",
"keyboard-types",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit 0.3.0",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
@@ -4310,9 +4336,9 @@ dependencies = [
[[package]]
name = "objc2"
version = "0.6.0"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3531f65190d9cff863b77a99857e74c314dd16bf56c538c4b57c7cbc3f3a6e59"
checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551"
dependencies = [
"objc2-encode",
"objc2-exception-helper",
@@ -4343,7 +4369,7 @@ dependencies = [
"bitflags 2.9.0",
"block2 0.6.0",
"libc",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-cloud-kit",
"objc2-core-data 0.3.0",
"objc2-core-foundation",
@@ -4360,7 +4386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c1948a9be5f469deadbd6bcb86ad7ff9e47b4f632380139722f7d9840c0d42c"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-foundation 0.3.0",
]
@@ -4383,18 +4409,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f860f8e841f6d32f754836f51e6bc7777cd7e7053cf18528233f6811d3eceb4"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-foundation 0.3.0",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.0"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daeaf60f25471d26948a1c2f840e3f7d86f4109e3af4e8e4b5cd70c39690d925"
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"dispatch2 0.3.0",
"objc2 0.6.1",
]
[[package]]
@@ -4404,7 +4431,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dca602628b65356b6513290a21a6405b4d4027b8b250f0b98dddbb28b7de02"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-core-foundation",
"objc2-io-surface",
]
@@ -4427,7 +4454,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ffa6bea72bf42c78b0b34e89c0bafac877d5f80bf91e159a5d96ea7f693ca56"
dependencies = [
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-foundation 0.3.0",
]
@@ -4467,7 +4494,17 @@ dependencies = [
"bitflags 2.9.0",
"block2 0.6.0",
"libc",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a"
dependencies = [
"libc",
"objc2-core-foundation",
]
@@ -4478,7 +4515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "161a8b87e32610086e1a7a9e9ec39f84459db7b3a0881c1f16ca5a2605581c19"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-core-foundation",
]
@@ -4501,7 +4538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1ac59da3ceebc4a82179b35dc550431ad9458f9cc326e053f49ba371ce76c5a"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit 0.3.0",
"objc2-foundation 0.3.0",
]
@@ -4526,7 +4563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fb3794501bb1bee12f08dcad8c61f2a5875791ad1c6f47faa71a0f033f20071"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-foundation 0.3.0",
]
@@ -4537,7 +4574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "777a571be14a42a3990d4ebedaeb8b54cd17377ec21b92e8200ac03797b3bee1"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
]
@@ -4550,7 +4587,7 @@ checksum = "b717127e4014b0f9f3e8bba3d3f2acec81f1bde01f656823036e823ed2c94dce"
dependencies = [
"bitflags 2.9.0",
"block2 0.6.0",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit 0.3.0",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
@@ -4684,7 +4721,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-foundation 0.3.0",
"objc2-osa-kit",
"serde",
@@ -4841,7 +4878,7 @@ checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0"
dependencies = [
"once_cell",
"pest",
"sha2 0.10.8",
"sha2 0.10.9",
]
[[package]]
@@ -5743,9 +5780,9 @@ dependencies = [
[[package]]
name = "reqwest_dav"
version = "0.1.15"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fe99f71b7221005c532c30d3bf245103b66cea3c8eb29b78088f13e853b6708"
checksum = "280c1af39de7c1fb07ab9a036b37cc9b52a411c12a15bee0b520e07500d48d30"
dependencies = [
"async-trait",
"chrono",
@@ -5769,13 +5806,13 @@ checksum = "80c844748fdc82aae252ee4594a89b6e7ebef1063de7951545564cbc4e57075d"
dependencies = [
"ashpd",
"block2 0.6.0",
"dispatch2",
"dispatch2 0.2.0",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit 0.3.0",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
@@ -6337,9 +6374,9 @@ dependencies = [
[[package]]
name = "sha2"
version = "0.10.8"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
@@ -6671,26 +6708,28 @@ dependencies = [
[[package]]
name = "sysinfo"
version = "0.34.1"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "927fa32067cbb22b8a91987d84c0ff94c7441ccf3f767165a58d77a656d6267c"
checksum = "b897c8ea620e181c7955369a31be5f48d9a9121cb59fd33ecef9ff2a34323422"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"windows 0.57.0",
"objc2-io-kit",
"windows 0.61.1",
]
[[package]]
name = "sysproxy"
version = "0.3.0"
source = "git+https://github.com/clash-verge-rev/sysproxy-rs?rev=3d748b5#3d748b5743fcd954ced7a7fb45707bb1114494f2"
source = "git+https://github.com/clash-verge-rev/sysproxy-rs#28c3df9005694354cbaf725c23fe243854aac8a0"
dependencies = [
"interfaces",
"iptools",
"log",
"thiserror 1.0.69",
"url",
"windows 0.58.0",
"winreg 0.52.0",
"xdg",
@@ -6732,9 +6771,9 @@ dependencies = [
[[package]]
name = "tao"
version = "0.32.8"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63c8b1020610b9138dd7b1e06cf259ae91aa05c30f3bd0d6b42a03997b92dec1"
checksum = "1e59c1f38e657351a2e822eadf40d6a2ad4627b9c25557bc1180ec1b3295ef82"
dependencies = [
"bitflags 2.9.0",
"core-foundation 0.10.0",
@@ -6753,7 +6792,7 @@ dependencies = [
"ndk",
"ndk-context",
"ndk-sys",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit 0.3.0",
"objc2-foundation 0.3.0",
"once_cell",
@@ -6763,8 +6802,8 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows 0.60.0",
"windows-core 0.60.1",
"windows 0.61.1",
"windows-core 0.61.0",
"windows-version",
"x11-dl",
]
@@ -6805,9 +6844,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.4.0"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "511dd38065a5d3b36c33cdba4362b99a40a5103bebcd4aebb930717e7c8ba292"
checksum = "e7b0bc1aec81bda6bc455ea98fcaed26b3c98c1648c627ad6ff1c704e8bf8cbc"
dependencies = [
"anyhow",
"bytes",
@@ -6827,9 +6866,10 @@ dependencies = [
"log",
"mime",
"muda",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit 0.3.0",
"objc2-foundation 0.3.0",
"objc2-ui-kit",
"percent-encoding",
"plist",
"raw-window-handle",
@@ -6853,14 +6893,14 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows 0.60.0",
"windows 0.61.1",
]
[[package]]
name = "tauri-build"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ffa8732a66f90903f5a585215f3cf1e87988d0359bc88c18a502efe7572c1de"
checksum = "d7a0350f0df1db385ca5c02888a83e0e66655c245b7443db8b78a70da7d7f8fc"
dependencies = [
"anyhow",
"cargo_toml",
@@ -6880,9 +6920,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c266a247f14d63f40c6282c2653a8bac5cc3d482ca562a003a88513653ea817a"
checksum = "f93f035551bf7b11b3f51ad9bc231ebbe5e085565527991c16cf326aa38cdf47"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -6895,7 +6935,7 @@ dependencies = [
"semver 1.0.26",
"serde",
"serde_json",
"sha2 0.10.8",
"sha2 0.10.9",
"syn 2.0.100",
"tauri-utils",
"thiserror 2.0.12",
@@ -6907,9 +6947,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f47a1cf94b3bd6c4dc37dce1a43fc96120ff29a91757f0ab3cf713c7ad846e7c"
checksum = "8db4df25e2d9d45de0c4c910da61cd5500190da14ae4830749fee3466dddd112"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -6938,9 +6978,8 @@ dependencies = [
[[package]]
name = "tauri-plugin-autostart"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9c13f843e5e5df3eed270fc42b02923cc1a6b5c7e56b0f3ac1d858ab2c8b5fb"
version = "2.3.0"
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v2#9bc4b2230ebb32bd30a4c0c2a21077829a729193"
dependencies = [
"auto-launch",
"serde",
@@ -6967,9 +7006,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-deep-link"
version = "2.2.0"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35d51ffd286073414d26353bcfc9e83e3cd63f96fa7f7a912f92f2118e5de5a6"
checksum = "dba4412f30eaff6f5d210e20383c2d6835593977402092e95b72497a4f8632fa"
dependencies = [
"dunce",
"rust-ini",
@@ -6981,8 +7020,8 @@ dependencies = [
"thiserror 2.0.12",
"tracing",
"url",
"windows-registry 0.3.0",
"windows-result 0.2.0",
"windows-registry 0.5.1",
"windows-result 0.3.2",
]
[[package]]
@@ -7014,9 +7053,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
version = "2.2.0"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b59fd750551b1066744ab956a1cd6b1ea3e1b3763b0b9153ac27a044d596426"
checksum = "bcaf6e5d6062423a0f711a23c2a573ccba222b6a16a9322d8499928f27e41376"
dependencies = [
"log",
"raw-window-handle",
@@ -7032,9 +7071,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
version = "2.2.0"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1edf18000f02903a7c2e5997fb89aca455ecbc0acc15c6535afbb883be223"
checksum = "88371e340ad2f07409a3b68294abe73f20bc9c1bc1b631a31dc37a3d0161f682"
dependencies = [
"anyhow",
"dunce",
@@ -7070,9 +7109,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-process"
version = "2.2.0"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40cc553ab29581c8c43dfa5fb0c9d5aee8ba962ad3b42908eea26c79610441b7"
checksum = "57da5888533e802b6206b9685091f8714aa1f5266dc80051a82388449558b773"
dependencies = [
"tauri",
"tauri-plugin",
@@ -7080,9 +7119,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-shell"
version = "2.2.0"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb2c50a63e60fb8925956cc5b7569f4b750ac197a4d39f13b8dd46ea8e2bad79"
checksum = "69d5eb3368b959937ad2aeaf6ef9a8f5d11e01ffe03629d3530707bbcb27ff5d"
dependencies = [
"encoding_rs",
"log",
@@ -7101,9 +7140,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-updater"
version = "2.6.1"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31bfcfb4a8318008d2108ccfba439d8263cf48867baabf372cb0e9f24771896"
checksum = "73f05c38afd77a4b8fd98e8fb6f1cdbb5fbb8a46ba181eb2758b05321e3c6209"
dependencies = [
"base64 0.22.1",
"dirs 6.0.0",
@@ -7133,9 +7172,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-window-state"
version = "2.2.1"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35e344b512b0d99d9d06225f235d87d6c66d89496a3bf323d9b578d940596e6c"
checksum = "a27a3fe49de72adbe0d84aee33c89a0b059722cd0b42aaeab29eaaee7f7535cd"
dependencies = [
"bitflags 2.9.0",
"log",
@@ -7148,35 +7187,37 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.5.0"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9c7bce5153f1ca7bc45eba37349b31ba50e975e28edc8b5766c5ec02b0b63a"
checksum = "00f004905d549854069e6774533d742b03cacfd6f03deb08940a8677586cbe39"
dependencies = [
"cookie",
"dpi",
"gtk",
"http 1.3.1",
"jni",
"objc2 0.6.1",
"objc2-ui-kit",
"raw-window-handle",
"serde",
"serde_json",
"tauri-utils",
"thiserror 2.0.12",
"url",
"windows 0.60.0",
"windows 0.61.1",
]
[[package]]
name = "tauri-runtime-wry"
version = "2.5.0"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "087188020fd6facb8578fe9b38e81fa0fe5fb85744c73da51a299f94a530a1e3"
checksum = "f85d056f4d4b014fe874814034f3416d57114b617a493a4fe552580851a3f3a2"
dependencies = [
"gtk",
"http 1.3.1",
"jni",
"log",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit 0.3.0",
"objc2-foundation 0.3.0",
"once_cell",
@@ -7190,15 +7231,15 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows 0.60.0",
"windows 0.61.1",
"wry",
]
[[package]]
name = "tauri-utils"
version = "2.3.0"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82dcced4014e59af9790cc22f5d271df3be09ecd6728ec68861642553c8d01b7"
checksum = "b2900399c239a471bcff7f15c4399eb1a8c4fe511ba2853e07c996d771a5e0a4"
dependencies = [
"anyhow",
"brotli",
@@ -7473,9 +7514,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.44.1"
version = "1.44.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a"
checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48"
dependencies = [
"backtrace",
"bytes",
@@ -7832,7 +7873,7 @@ dependencies = [
"dirs 6.0.0",
"libappindicator",
"muda",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit 0.3.0",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -8475,15 +8516,15 @@ dependencies = [
[[package]]
name = "webview2-com"
version = "0.36.0"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0d606f600e5272b514dbb66539dd068211cc20155be8d3958201b4b5bd79ed3"
checksum = "b542b5cfbd9618c46c2784e4d41ba218c336ac70d44c55e47b251033e7d85601"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows 0.60.0",
"windows-core 0.60.1",
"windows-implement 0.59.0",
"windows 0.61.1",
"windows-core 0.61.0",
"windows-implement 0.60.0",
"windows-interface 0.59.1",
]
@@ -8500,13 +8541,13 @@ dependencies = [
[[package]]
name = "webview2-com-sys"
version = "0.36.0"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfb27fccd3c27f68e9a6af1bcf48c2d82534b8675b83608a4d81446d095a17ac"
checksum = "8ae2d11c4a686e4409659d7891791254cf9286d3cfe0eef54df1523533d22295"
dependencies = [
"thiserror 2.0.12",
"windows 0.60.0",
"windows-core 0.60.1",
"windows 0.61.1",
"windows-core 0.61.0",
]
[[package]]
@@ -8574,7 +8615,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
dependencies = [
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit 0.3.0",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
@@ -8583,16 +8624,6 @@ dependencies = [
"windows-version",
]
[[package]]
name = "windows"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core 0.57.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.58.0"
@@ -8605,12 +8636,12 @@ dependencies = [
[[package]]
name = "windows"
version = "0.60.0"
version = "0.61.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddf874e74c7a99773e62b1c671427abf01a425e77c3d3fb9fb1e4883ea934529"
checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419"
dependencies = [
"windows-collections",
"windows-core 0.60.1",
"windows-core 0.61.0",
"windows-future",
"windows-link",
"windows-numerics",
@@ -8618,11 +8649,11 @@ dependencies = [
[[package]]
name = "windows-collections"
version = "0.1.1"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5467f79cc1ba3f52ebb2ed41dbb459b8e7db636cc3429458d9a852e15bc24dec"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
"windows-core 0.60.1",
"windows-core 0.61.0",
]
[[package]]
@@ -8634,18 +8665,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement 0.57.0",
"windows-interface 0.57.0",
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.58.0"
@@ -8661,38 +8680,27 @@ dependencies = [
[[package]]
name = "windows-core"
version = "0.60.1"
version = "0.61.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247"
checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
dependencies = [
"windows-implement 0.59.0",
"windows-implement 0.60.0",
"windows-interface 0.59.1",
"windows-link",
"windows-result 0.3.2",
"windows-strings 0.3.1",
"windows-strings 0.4.0",
]
[[package]]
name = "windows-future"
version = "0.1.1"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a787db4595e7eb80239b74ce8babfb1363d8e343ab072f2ffe901400c03349f0"
checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32"
dependencies = [
"windows-core 0.60.1",
"windows-core 0.61.0",
"windows-link",
]
[[package]]
name = "windows-implement"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
@@ -8706,20 +8714,9 @@ dependencies = [
[[package]]
name = "windows-implement"
version = "0.59.0"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [
"proc-macro2",
"quote",
@@ -8756,25 +8753,14 @@ checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
[[package]]
name = "windows-numerics"
version = "0.1.1"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005dea54e2f6499f2cee279b8f703b3cf3b5734a2d8d21867c8f44003182eeed"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core 0.60.1",
"windows-core 0.61.0",
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bafa604f2104cf5ae2cc2db1dee84b7e6a5d11b05f737b60def0ffdc398cbc0a"
dependencies = [
"windows-result 0.2.0",
"windows-strings 0.2.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-registry"
version = "0.4.0"
@@ -8787,12 +8773,14 @@ dependencies = [
]
[[package]]
name = "windows-result"
version = "0.1.2"
name = "windows-registry"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
checksum = "ad1da3e436dc7653dfdf3da67332e22bff09bb0e28b0239e1624499c7830842e"
dependencies = [
"windows-targets 0.52.6",
"windows-link",
"windows-result 0.3.2",
"windows-strings 0.4.0",
]
[[package]]
@@ -8825,18 +8813,18 @@ dependencies = [
[[package]]
name = "windows-strings"
version = "0.2.0"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978d65aedf914c664c510d9de43c8fd85ca745eaff1ed53edf409b479e441663"
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
dependencies = [
"windows-targets 0.52.6",
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.3.1"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97"
dependencies = [
"windows-link",
]
@@ -9218,9 +9206,9 @@ checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
[[package]]
name = "wry"
version = "0.50.5"
version = "0.51.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b19b78efae8b853c6c817e8752fc1dbf9cab8a8ffe9c30f399bd750ccf0f0730"
checksum = "c886a0a9d2a94fd90cfa1d929629b79cfefb1546e2c7430c63a47f0664c0e4e2"
dependencies = [
"base64 0.22.1",
"block2 0.6.0",
@@ -9237,7 +9225,7 @@ dependencies = [
"kuchikiki",
"libc",
"ndk",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit 0.3.0",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
@@ -9246,7 +9234,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"raw-window-handle",
"sha2 0.10.8",
"sha2 0.10.9",
"soup3",
"tao-macros",
"thiserror 2.0.12",
@@ -9255,8 +9243,8 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows 0.60.0",
"windows-core 0.60.1",
"windows 0.61.1",
"windows-core 0.61.0",
"windows-version",
"x11-dl",
]
@@ -9288,7 +9276,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12"
dependencies = [
"gethostname",
"gethostname 0.4.3",
"rustix 0.38.44",
"x11rb-protocol",
]
@@ -9526,9 +9514,9 @@ dependencies = [
[[package]]
name = "zip"
version = "2.5.0"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c03817464f64e23f6f37574b4fdc8cf65925b5bfd2b0f2aedf959791941f88"
checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744"
dependencies = [
"aes",
"arbitrary",
+43 -33
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.2.3"
version = "2.3.0"
description = "clash verge"
authors = ["zzzgydi", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -13,40 +13,41 @@ build = "build.rs"
identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies]
tauri-build = { version = "2.1.0", features = [] }
tauri-build = { version = "2.2.0", features = [] }
[dependencies]
warp = "0.3"
anyhow = "1.0.97"
anyhow = "1.0.98"
dirs = "6.0"
open = "5.3"
log = "0.4"
dunce = "1.0"
log4rs = "1"
nanoid = "0.4"
chrono = "0.4.40"
sysinfo = "0.34"
chrono = "0.4.41"
sysinfo = "0.35.0"
boa_engine = "0.20.0"
serde_json = "1.0"
serde_yaml = "0.9"
serde_json = "1.0.140"
serde_yaml = "0.9.34"
once_cell = "1.21.3"
lazy_static = "1.5.0"
port_scanner = "0.1.5"
delay_timer = "0.11.6"
parking_lot = "0.12"
parking_lot = "0.12.3"
percent-encoding = "2.3.1"
tokio = { version = "1.44", features = [
tokio = { version = "1.44.2", features = [
"rt-multi-thread",
"macros",
"time",
"sync",
] }
serde = { version = "1.0", features = ["derive"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "cookies"] }
serde = { version = "1.0.219", features = ["derive"] }
reqwest = { version = "0.12.15", features = ["json", "rustls-tls", "cookies"] }
regex = "1.11.1"
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", rev = "3d748b5" }
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs" }
image = "0.25.6"
imageproc = "0.25.0"
tauri = { version = "2.4.0", features = [
tauri = { version = "2.5.1", features = [
"protocol-asset",
"devtools",
"tray-icon",
@@ -54,40 +55,53 @@ tauri = { version = "2.4.0", features = [
"image-png",
] }
network-interface = { version = "2.0.1", features = ["serde"] }
tauri-plugin-shell = "2.2.0"
tauri-plugin-dialog = "2.2.0"
tauri-plugin-fs = "2.2.0"
tauri-plugin-process = "2.2.0"
tauri-plugin-shell = "2.2.1"
tauri-plugin-dialog = "2.2.1"
tauri-plugin-fs = "2.2.1"
tauri-plugin-process = "2.2.1"
tauri-plugin-clipboard-manager = "2.2.2"
tauri-plugin-deep-link = "2.2.0"
tauri-plugin-deep-link = "2.2.1"
tauri-plugin-devtools = "2.0.0"
zip = "2.5.0"
reqwest_dav = "0.1.15"
tauri-plugin-window-state = "2.2.2"
zip = "2.6.1"
reqwest_dav = "0.2.1"
aes-gcm = { version = "0.10.3", features = ["std"] }
base64 = "0.22.1"
getrandom = "0.3.2"
tokio-tungstenite = "0.26.2"
futures = "0.3"
futures = "0.3.31"
sys-locale = "0.3.2"
async-trait = "0.1.88"
mihomo_api = { path = "src_crates/crate_mihomo_api" }
ab_glyph = "0.2.29"
tungstenite = "0.26.2"
libc = "0.2"
libc = "0.2.172"
gethostname = "1.0.2"
hmac = "0.12.1"
sha2 = "0.10.9"
hex = "0.4.3"
[target.'cfg(windows)'.dependencies]
runas = "=1.2.0"
deelevate = "0.2.0"
winreg = "0.55.0"
winapi = { version = "0.3", features = [
"winbase",
"fileapi",
"winnt",
"handleapi",
"errhandlingapi",
"minwindef",
"winerror",
] }
[target.'cfg(target_os = "linux")'.dependencies]
users = "0.11.0"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.2.0"
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-global-shortcut = "2.2.0"
tauri-plugin-updater = "2.6.1"
tauri-plugin-window-state = "2.2.1"
tauri-plugin-updater = "2.7.1"
[features]
default = ["custom-protocol"]
@@ -103,6 +117,10 @@ strip = true
[profile.dev]
incremental = true
codegen-units = 256 # 增加编译单元,提升编译速度
opt-level = 0 # 禁用优化,进一步提升编译速度
debug = true # 保留调试信息
strip = false # 不剥离符号,保留调试信息
[profile.fast-release]
inherits = "release" # 继承 release 的配置
@@ -113,14 +131,6 @@ opt-level = 0 # 禁用优化,大幅提升编译速度
debug = true # 保留调试信息
strip = false # 不剥离符号,保留调试信息
[profile.fast-dev]
inherits = "dev" # 继承 dev 的配置
codegen-units = 256 # 增加编译单元,提升编译速度
opt-level = 0 # 禁用优化,进一步提升编译速度
incremental = true # 启用增量编译
debug = true # 保留调试信息
strip = false # 不剥离符号,保留调试信息
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
+4 -3
View File
@@ -14,8 +14,9 @@
"updater:allow-download-and-install",
"process:allow-restart",
"deep-link:default",
"window-state:default",
"window-state:default",
"autostart:default"
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",
"core:window:allow-set-theme"
]
}
+23 -8
View File
@@ -916,6 +916,10 @@ FunctionEnd
!macroend
Section Uninstall
;删除 .window-state.json 文件
SetShellVarContext current
Delete "$APPDATA\io.github.clash-verge-rev.clash-verge-rev\.window-state.json"
!insertmacro CheckIfAppIsRunning
!insertmacro CheckAllVergeProcesses
!insertmacro RemoveVergeService
@@ -975,16 +979,23 @@ Section Uninstall
RMDir "$INSTDIR"
!insertmacro DeleteAppUserModelId
!insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk"
!insertmacro UnpinShortcut "$DESKTOP\${MAINBINARYNAME}.lnk"
!insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk"
!insertmacro UnpinShortcut "$DESKTOP\${PRODUCTNAME}.lnk"
; 兼容旧名称快捷方式
!insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\clash-verge.lnk"
!insertmacro UnpinShortcut "$DESKTOP\clash-verge.lnk"
; Remove start menu shortcut
!insertmacro MUI_STARTMENU_GETFOLDER Application $AppStartMenuFolder
Delete "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk"
Delete "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk"
; 兼容旧名称快捷方式
Delete "$SMPROGRAMS\$AppStartMenuFolder\clash-verge.lnk"
RMDir "$SMPROGRAMS\$AppStartMenuFolder"
; Remove desktop shortcuts
Delete "$DESKTOP\${MAINBINARYNAME}.lnk"
Delete "$DESKTOP\${PRODUCTNAME}.lnk"
; 兼容旧名称快捷方式
Delete "$DESKTOP\clash-verge.lnk"
; Remove registry information for add/remove programs
!if "${INSTALLMODE}" == "both"
@@ -1004,6 +1015,10 @@ Section Uninstall
RmDir /r "$LOCALAPPDATA\${BUNDLEID}"
${EndIf}
;删除 .window-state.json 文件
SetShellVarContext current
Delete "$APPDATA\io.github.clash-verge-rev.clash-verge-rev\.window-state.json"
${GetOptions} $CMDLINE "/P" $R0
IfErrors +2 0
SetAutoClose true
@@ -1046,12 +1061,12 @@ FunctionEnd
!macroend
Function CreateDesktopShortcut
CreateShortcut "$DESKTOP\${MAINBINARYNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
!insertmacro SetLnkAppUserModelId "$DESKTOP\${MAINBINARYNAME}.lnk"
CreateShortcut "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
!insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk"
FunctionEnd
Function CreateStartMenuShortcut
CreateDirectory "$SMPROGRAMS\$AppStartMenuFolder"
CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
!insertmacro SetLnkAppUserModelId "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk"
CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
!insertmacro SetLnkAppUserModelId "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk"
FunctionEnd
+40 -10
View File
@@ -49,7 +49,7 @@ pub fn open_devtools(app_handle: tauri::AppHandle) {
/// 退出应用
#[tauri::command]
pub fn exit_app() {
feat::quit(Some(0));
feat::quit();
}
/// 重启应用
@@ -87,23 +87,18 @@ pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String>
let icon_cache_dir = wrap_err!(dirs::app_home_dir())?.join("icons").join("cache");
let icon_path = icon_cache_dir.join(&name);
// 如果文件已存在,直接返回路径
if icon_path.exists() {
return Ok(icon_path.to_string_lossy().to_string());
}
// 确保缓存目录存在
if !icon_cache_dir.exists() {
let _ = std::fs::create_dir_all(&icon_cache_dir);
}
// 使用临时文件名来下载
let temp_path = icon_cache_dir.join(format!("{}.downloading", &name));
// 下载文件到临时位置
let response = wrap_err!(reqwest::get(&url).await)?;
// 检查内容类型是否为图片
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
@@ -112,16 +107,13 @@ pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String>
let is_image = content_type.starts_with("image/");
// 获取响应内容
let content = wrap_err!(response.bytes().await)?;
// 检查内容是否为HTML (针对CDN错误页面)
let is_html = content.len() > 15
&& (content.starts_with(b"<!DOCTYPE html")
|| content.starts_with(b"<html")
|| content.starts_with(b"<?xml"));
// 只有当内容确实是图片时才保存
if is_image && !is_html {
{
let mut file = match std::fs::File::create(&temp_path) {
@@ -138,7 +130,6 @@ pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String>
wrap_err!(std::io::copy(&mut content.as_ref(), &mut file))?;
}
// 再次检查目标文件是否已存在,避免重命名覆盖其他线程已完成的文件
if !icon_path.exists() {
match std::fs::rename(&temp_path, &icon_path) {
Ok(_) => {}
@@ -214,3 +205,42 @@ pub fn copy_icon_file(path: String, icon_info: IconInfo) -> CmdResult<String> {
Err("file not found".to_string())
}
}
/// 通知UI已准备就绪
#[tauri::command]
pub fn notify_ui_ready() -> CmdResult<()> {
log::info!(target: "app", "前端UI已准备就绪");
crate::utils::resolve::mark_ui_ready();
Ok(())
}
/// UI加载阶段
#[tauri::command]
pub fn update_ui_stage(stage: String) -> CmdResult<()> {
log::info!(target: "app", "UI加载阶段更新: {}", stage);
use crate::utils::resolve::UiReadyStage;
let stage_enum = match stage.as_str() {
"NotStarted" => UiReadyStage::NotStarted,
"Loading" => UiReadyStage::Loading,
"DomReady" => UiReadyStage::DomReady,
"ResourcesLoaded" => UiReadyStage::ResourcesLoaded,
"Ready" => UiReadyStage::Ready,
_ => {
log::warn!(target: "app", "未知的UI加载阶段: {}", stage);
return Err(format!("未知的UI加载阶段: {}", stage));
}
};
crate::utils::resolve::update_ui_ready_stage(stage_enum);
Ok(())
}
/// 重置UI就绪状态
#[tauri::command]
pub fn reset_ui_ready_state() -> CmdResult<()> {
log::info!(target: "app", "重置UI就绪状态");
crate::utils::resolve::reset_ui_ready();
Ok(())
}
+53 -7
View File
@@ -1,5 +1,7 @@
use super::CmdResult;
use crate::{config::*, core::*, feat, module::mihomo::MihomoManager, wrap_err};
use crate::{
config::*, core::*, feat, module::mihomo::MihomoManager, process::AsyncHandler, wrap_err,
};
use serde_yaml::Mapping;
/// 复制Clash环境变量
@@ -38,10 +40,21 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
.await
{
Ok(_) => {
log::info!(target: "app", "core changed to {clash_core}");
handle::Handle::notice_message("config_core::change_success", &clash_core);
handle::Handle::refresh_clash();
Ok(None)
// 切换内核后重启内核
match CoreManager::global().restart_core().await {
Ok(_) => {
log::info!(target: "app", "core changed and restarted to {clash_core}");
handle::Handle::notice_message("config_core::change_success", &clash_core);
handle::Handle::refresh_clash();
Ok(None)
}
Err(err) => {
let error_msg = format!("Core changed but failed to restart: {}", err);
log::error!(target: "app", "{}", error_msg);
handle::Handle::notice_message("config_core::change_error", &error_msg);
Ok(Some(error_msg))
}
}
}
Err(err) => {
let error_msg = err.to_string();
@@ -52,6 +65,18 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
}
}
/// 启动核心
#[tauri::command]
pub async fn start_core() -> CmdResult {
wrap_err!(CoreManager::global().start_core().await)
}
/// 关闭核心
#[tauri::command]
pub async fn stop_core() -> CmdResult {
wrap_err!(CoreManager::global().stop_core().await)
}
/// 重启核心
#[tauri::command]
pub async fn restart_core() -> CmdResult {
@@ -104,10 +129,9 @@ pub fn apply_dns_config(apply: bool) -> CmdResult {
core::{handle, CoreManager},
utils::dirs,
};
use tauri::async_runtime;
// 使用spawn来处理异步操作
async_runtime::spawn(async move {
AsyncHandler::spawn(move || async move {
if apply {
// 读取DNS配置文件
let dns_path = match dirs::app_home_dir() {
@@ -221,3 +245,25 @@ pub async fn get_dns_config_content() -> CmdResult<String> {
let content = fs::read_to_string(&dns_path).map_err(|e| e.to_string())?;
Ok(content)
}
/// 验证DNS配置文件
#[tauri::command]
pub async fn validate_dns_config() -> CmdResult<(bool, String)> {
use crate::{core::CoreManager, utils::dirs};
let app_dir = dirs::app_home_dir().map_err(|e| e.to_string())?;
let dns_path = app_dir.join("dns_config.yaml");
let dns_path_str = dns_path.to_str().unwrap_or_default();
if !dns_path.exists() {
return Ok((false, "DNS config file not found".to_string()));
}
match CoreManager::global()
.validate_config_file(dns_path_str, None)
.await
{
Ok(result) => Ok(result),
Err(e) => Err(e.to_string()),
}
}
+6
View File
@@ -7,3 +7,9 @@ pub async fn entry_lightweight_mode() -> CmdResult {
lightweight::entry_lightweight_mode();
Ok(())
}
#[tauri::command]
pub async fn exit_lightweight_mode() -> CmdResult {
lightweight::exit_lightweight_mode();
Ok(())
}
+1 -1
View File
@@ -207,7 +207,7 @@ async fn check_chatgpt_combined(client: &Client) -> Vec<UnlockItem> {
if let Ok(body) = response.text().await {
let body_lower = body.to_lowercase();
if body_lower.contains("unsupported_country") {
"Unsupported Country"
"Unsupported Country/Region"
} else {
"Yes"
}
+31 -4
View File
@@ -3,11 +3,16 @@ use crate::wrap_err;
use network_interface::NetworkInterface;
use serde_yaml::Mapping;
use sysproxy::{Autoproxy, Sysproxy};
use tokio::task::spawn_blocking;
/// get the system proxy
#[tauri::command]
pub fn get_sys_proxy() -> CmdResult<Mapping> {
let current = wrap_err!(Sysproxy::get_system_proxy())?;
pub async fn get_sys_proxy() -> CmdResult<Mapping> {
let current = spawn_blocking(Sysproxy::get_system_proxy)
.await
.map_err(|e| format!("Failed to spawn blocking task for sysproxy: {}", e))?
.map_err(|e| format!("Failed to get system proxy: {}", e))?;
let mut map = Mapping::new();
map.insert("enable".into(), current.enable.into());
map.insert(
@@ -21,8 +26,11 @@ pub fn get_sys_proxy() -> CmdResult<Mapping> {
/// get the system proxy
#[tauri::command]
pub fn get_auto_proxy() -> CmdResult<Mapping> {
let current = wrap_err!(Autoproxy::get_auto_proxy())?;
pub async fn get_auto_proxy() -> CmdResult<Mapping> {
let current = spawn_blocking(Autoproxy::get_auto_proxy)
.await
.map_err(|e| format!("Failed to spawn blocking task for autoproxy: {}", e))?
.map_err(|e| format!("Failed to get auto proxy: {}", e))?;
let mut map = Mapping::new();
map.insert("enable".into(), current.enable.into());
@@ -31,6 +39,25 @@ pub fn get_auto_proxy() -> CmdResult<Mapping> {
Ok(map)
}
/// 获取系统主机名
#[tauri::command]
pub fn get_system_hostname() -> CmdResult<String> {
use gethostname::gethostname;
// 获取系统主机名,处理可能的非UTF-8字符
let hostname = match gethostname().into_string() {
Ok(name) => name,
Err(os_string) => {
// 对于包含非UTF-8的主机名,使用调试格式化
let fallback = format!("{:?}", os_string);
// 去掉可能存在的引号
fallback.trim_matches('"').to_string()
}
};
Ok(hostname)
}
/// 获取网络接口列表
#[tauri::command]
pub fn get_network_interfaces() -> Vec<String> {
+220 -47
View File
@@ -1,17 +1,58 @@
use super::CmdResult;
use crate::{
config::{Config, IProfiles, PrfItem, PrfOption},
core::{handle, tray::Tray, CoreManager},
core::{handle, timer::Timer, tray::Tray, CoreManager},
feat, logging, ret_err,
utils::{dirs, help, logging::Type},
wrap_err,
};
use std::time::Duration;
use tokio::sync::Mutex;
// 添加全局互斥锁防止并发配置更新
static PROFILE_UPDATE_MUTEX: Mutex<()> = Mutex::const_new(());
/// 获取配置文件列表
#[tauri::command]
pub fn get_profiles() -> CmdResult<IProfiles> {
let _ = Tray::global().update_menu();
Ok(Config::profiles().data().clone())
pub async fn get_profiles() -> CmdResult<IProfiles> {
let profiles_result = tokio::time::timeout(
Duration::from_secs(3), // 3秒超时
tokio::task::spawn_blocking(move || Config::profiles().data().clone()),
)
.await;
match profiles_result {
Ok(Ok(profiles)) => Ok(profiles),
Ok(Err(join_err)) => {
logging!(error, Type::Cmd, true, "获取配置列表任务失败: {}", join_err);
Ok(IProfiles {
current: None,
items: Some(vec![]),
})
}
Err(_) => {
// 超时情况
logging!(
error,
Type::Cmd,
true,
"获取配置列表超时(3秒),可能存在锁竞争"
);
match tokio::task::spawn_blocking(move || Config::profiles().latest().clone()).await {
Ok(profiles) => {
logging!(info, Type::Cmd, true, "使用latest()成功获取配置");
Ok(profiles)
}
Err(_) => {
logging!(error, Type::Cmd, true, "fallback获取配置也失败,返回空配置");
Ok(IProfiles {
current: None,
items: Some(vec![]),
})
}
}
}
}
}
/// 增强配置文件
@@ -45,7 +86,7 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
/// 更新配置文件
#[tauri::command]
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
wrap_err!(feat::update_profile(index, option).await)
wrap_err!(feat::update_profile(index, option, Some(true)).await)
}
/// 删除配置文件
@@ -62,6 +103,9 @@ pub async fn delete_profile(index: String) -> CmdResult {
/// 修改profiles的配置
#[tauri::command]
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
// 获取互斥锁,防止并发执行
let _guard = PROFILE_UPDATE_MUTEX.lock().await;
logging!(info, Type::Cmd, true, "开始修改配置文件");
// 保存当前配置,以便在验证失败时恢复
@@ -74,21 +118,23 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
logging!(info, Type::Cmd, true, "正在切换到新配置: {}", new_profile);
// 获取目标配置文件路径
let profiles_config = Config::profiles();
let profiles_data = profiles_config.latest();
let config_file_result = match profiles_data.get_item(new_profile) {
Ok(item) => {
if let Some(file) = &item.file {
let path = dirs::app_profiles_dir().map(|dir| dir.join(file));
path.ok()
} else {
let config_file_result = {
let profiles_config = Config::profiles();
let profiles_data = profiles_config.latest();
match profiles_data.get_item(new_profile) {
Ok(item) => {
if let Some(file) = &item.file {
let path = dirs::app_profiles_dir().map(|dir| dir.join(file));
path.ok()
} else {
None
}
}
Err(e) => {
logging!(error, Type::Cmd, true, "获取目标配置信息失败: {}", e);
None
}
}
Err(e) => {
logging!(error, Type::Cmd, true, "获取目标配置信息失败: {}", e);
None
}
};
// 如果获取到文件路径,检查YAML语法
@@ -108,28 +154,51 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
return Ok(false);
}
match std::fs::read_to_string(&file_path) {
Ok(content) => match serde_yaml::from_str::<serde_yaml::Value>(&content) {
Ok(_) => {
logging!(info, Type::Cmd, true, "目标配置文件语法正确");
// 超时保护
let file_read_result = tokio::time::timeout(
Duration::from_secs(5),
tokio::fs::read_to_string(&file_path),
)
.await;
match file_read_result {
Ok(Ok(content)) => {
let yaml_parse_result = tokio::task::spawn_blocking(move || {
serde_yaml::from_str::<serde_yaml::Value>(&content)
})
.await;
match yaml_parse_result {
Ok(Ok(_)) => {
logging!(info, Type::Cmd, true, "目标配置文件语法正确");
}
Ok(Err(err)) => {
let error_msg = format!(" {}", err);
logging!(
error,
Type::Cmd,
true,
"目标配置文件存在YAML语法错误:{}",
error_msg
);
handle::Handle::notice_message(
"config_validate::yaml_syntax_error",
&error_msg,
);
return Ok(false);
}
Err(join_err) => {
let error_msg = format!("YAML解析任务失败: {}", join_err);
logging!(error, Type::Cmd, true, "{}", error_msg);
handle::Handle::notice_message(
"config_validate::yaml_parse_error",
&error_msg,
);
return Ok(false);
}
}
Err(err) => {
let error_msg = format!(" {}", err);
logging!(
error,
Type::Cmd,
true,
"目标配置文件存在YAML语法错误:{}",
error_msg
);
handle::Handle::notice_message(
"config_validate::yaml_syntax_error",
&error_msg,
);
return Ok(false);
}
},
Err(err) => {
}
Ok(Err(err)) => {
let error_msg = format!("无法读取目标配置文件: {}", err);
logging!(error, Type::Cmd, true, "{}", error_msg);
handle::Handle::notice_message(
@@ -138,6 +207,15 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
);
return Ok(false);
}
Err(_) => {
let error_msg = "读取配置文件超时(5秒)".to_string();
logging!(error, Type::Cmd, true, "{}", error_msg);
handle::Handle::notice_message(
"config_validate::file_read_timeout",
&error_msg,
);
return Ok(false);
}
}
}
}
@@ -145,19 +223,49 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
// 更新profiles配置
logging!(info, Type::Cmd, true, "正在更新配置草稿");
let current_value = profiles.current.clone();
let _ = Config::profiles().draft().patch_config(profiles);
// 为配置更新添加超时保护
let update_result = tokio::time::timeout(
Duration::from_secs(30), // 30秒超时
CoreManager::global().update_config(),
)
.await;
// 更新配置并进行验证
match CoreManager::global().update_config().await {
Ok((true, _)) => {
match update_result {
Ok(Ok((true, _))) => {
logging!(info, Type::Cmd, true, "配置更新成功");
handle::Handle::refresh_clash();
let _ = Tray::global().update_tooltip();
Config::profiles().apply();
wrap_err!(Config::profiles().data().save_file())?;
handle::Handle::refresh_clash();
crate::process::AsyncHandler::spawn(|| async move {
if let Err(e) = Tray::global().update_tooltip() {
log::warn!(target: "app", "异步更新托盘提示失败: {}", e);
}
if let Err(e) = Tray::global().update_menu() {
log::warn!(target: "app", "异步更新托盘菜单失败: {}", e);
}
// 保存配置文件
if let Err(e) = Config::profiles().data().save_file() {
log::warn!(target: "app", "异步保存配置文件失败: {}", e);
}
});
// 立即通知前端配置变更
if let Some(current) = &current_value {
logging!(info, Type::Cmd, true, "向前端发送配置变更事件: {}", current);
handle::Handle::notify_profile_changed(current.clone());
}
Ok(true)
}
Ok((false, error_msg)) => {
Ok(Ok((false, error_msg))) => {
logging!(warn, Type::Cmd, true, "配置验证失败: {}", error_msg);
Config::profiles().discard();
// 如果验证失败,恢复到之前的配置
@@ -176,7 +284,13 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
// 静默恢复,不触发验证
wrap_err!({ Config::profiles().draft().patch_config(restore_profiles) })?;
Config::profiles().apply();
wrap_err!(Config::profiles().data().save_file())?;
crate::process::AsyncHandler::spawn(|| async move {
if let Err(e) = Config::profiles().data().save_file() {
log::warn!(target: "app", "异步保存恢复配置文件失败: {}", e);
}
});
logging!(info, Type::Cmd, true, "成功恢复到之前的配置");
}
@@ -184,12 +298,37 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
handle::Handle::notice_message("config_validate::error", &error_msg);
Ok(false)
}
Err(e) => {
Ok(Err(e)) => {
logging!(warn, Type::Cmd, true, "更新过程发生错误: {}", e);
Config::profiles().discard();
handle::Handle::notice_message("config_validate::boot_error", e.to_string());
Ok(false)
}
Err(_) => {
// 超时处理
let timeout_msg = "配置更新超时(30秒),可能是配置验证或核心通信阻塞";
logging!(error, Type::Cmd, true, "{}", timeout_msg);
Config::profiles().discard();
if let Some(prev_profile) = current_profile {
logging!(
info,
Type::Cmd,
true,
"超时后尝试恢复到之前的配置: {}",
prev_profile
);
let restore_profiles = IProfiles {
current: Some(prev_profile),
items: None,
};
wrap_err!({ Config::profiles().draft().patch_config(restore_profiles) })?;
Config::profiles().apply();
}
handle::Handle::notice_message("config_validate::timeout", timeout_msg);
Ok(false)
}
}
}
@@ -211,7 +350,33 @@ pub async fn patch_profiles_config_by_profile_index(
/// 修改某个profile item的
#[tauri::command]
pub fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
wrap_err!(Config::profiles().data().patch_item(index, profile))?;
// 保存修改前检查是否有更新 update_interval
let update_interval_changed =
if let Ok(old_profile) = Config::profiles().latest().get_item(&index) {
let old_interval = old_profile.option.as_ref().and_then(|o| o.update_interval);
let new_interval = profile.option.as_ref().and_then(|o| o.update_interval);
old_interval != new_interval
} else {
false
};
// 保存修改
wrap_err!(Config::profiles().data().patch_item(index.clone(), profile))?;
// 如果更新间隔变更,异步刷新定时器
if update_interval_changed {
let index_clone = index.clone();
crate::process::AsyncHandler::spawn(move || async move {
logging!(info, Type::Timer, "定时器更新间隔已变更,正在刷新定时器...");
if let Err(e) = crate::core::Timer::global().refresh() {
logging!(error, Type::Timer, "刷新定时器失败: {}", e);
} else {
// 刷新成功后发送自定义事件,不触发配置重载
crate::core::handle::Handle::notify_timer_updated(index_clone);
}
});
}
Ok(())
}
@@ -242,3 +407,11 @@ pub fn read_profile_file(index: String) -> CmdResult<String> {
let data = wrap_err!(item.read_file())?;
Ok(data)
}
/// 获取下一次更新时间
#[tauri::command]
pub fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
let timer = Timer::global();
let next_time = timer.get_next_update_time(&uid);
Ok(next_time)
}
+57 -10
View File
@@ -1,24 +1,71 @@
use super::CmdResult;
use crate::module::mihomo::MihomoManager;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use std::{
sync::atomic::{AtomicBool, Ordering},
time::{Duration, Instant},
};
static LAST_REFRESH_TIME: Lazy<Mutex<Option<Instant>>> = Lazy::new(|| Mutex::new(None));
static IS_REFRESHING: AtomicBool = AtomicBool::new(false);
const REFRESH_INTERVAL: Duration = Duration::from_secs(5);
#[tauri::command]
pub async fn get_proxies() -> CmdResult<serde_json::Value> {
let mannager = MihomoManager::global();
let manager = MihomoManager::global();
mannager
manager
.refresh_proxies()
.await
.map(|_| mannager.get_proxies())
.or_else(|_| Ok(mannager.get_proxies()))
.map(|_| manager.get_proxies())
.or_else(|_| Ok(manager.get_proxies()))
}
#[tauri::command]
pub async fn get_providers_proxies() -> CmdResult<serde_json::Value> {
let mannager = MihomoManager::global();
let manager = MihomoManager::global();
let cached_data = manager.get_providers_proxies();
mannager
.refresh_providers_proxies()
.await
.map(|_| mannager.get_providers_proxies())
.or_else(|_| Ok(mannager.get_providers_proxies()))
let safe_data = if cached_data.is_null() {
serde_json::json!({
"providers": {}
})
} else {
cached_data
};
// 检查是否需要刷新
let should_refresh = {
let last_refresh = LAST_REFRESH_TIME.lock();
match *last_refresh {
Some(last_time) => last_time.elapsed() > REFRESH_INTERVAL,
None => true,
}
};
if should_refresh && !IS_REFRESHING.load(Ordering::Acquire) {
IS_REFRESHING.store(true, Ordering::Release);
crate::process::AsyncHandler::spawn(|| async move {
let manager = MihomoManager::global();
match manager.refresh_providers_proxies().await {
Ok(_) => {
log::debug!(target: "app", "providers_proxies静默后台刷新成功");
}
Err(e) => {
log::warn!(target: "app", "providers_proxies后台刷新失败: {}", e);
}
}
{
let mut last_refresh = LAST_REFRESH_TIME.lock();
*last_refresh = Some(Instant::now());
}
IS_REFRESHING.store(false, Ordering::Release);
});
}
Ok(safe_data)
}
+8
View File
@@ -38,3 +38,11 @@ pub async fn reinstall_service() -> CmdResult {
pub async fn repair_service() -> CmdResult {
execute_service_operation(service::force_reinstall_service(), "Repair").await
}
#[tauri::command]
pub async fn is_service_available() -> CmdResult<bool> {
service::is_service_available()
.await
.map(|_| true)
.map_err(|e| e.to_string())
}
+48 -5
View File
@@ -20,6 +20,12 @@ impl IClashTemp {
map.insert(key.clone(), template.0.get(key).unwrap().clone());
}
});
// 确保 secret 字段存在且不为空
if let Some(Value::String(s)) = map.get_mut("secret") {
if s.is_empty() {
*s = "set-your-secret".to_string();
}
}
Self(Self::guard(map))
}
Err(err) => {
@@ -32,13 +38,13 @@ impl IClashTemp {
pub fn template() -> Self {
let mut map = Mapping::new();
let mut tun = Mapping::new();
let mut cors_map = Mapping::new();
tun.insert("enable".into(), false.into());
tun.insert("stack".into(), "gvisor".into());
tun.insert("auto-route".into(), true.into());
tun.insert("strict-route".into(), false.into());
tun.insert("auto-detect-interface".into(), true.into());
tun.insert("dns-hijack".into(), vec!["any:53"].into());
#[cfg(not(target_os = "windows"))]
map.insert("redir-port".into(), 7895.into());
#[cfg(target_os = "linux")]
@@ -46,14 +52,25 @@ impl IClashTemp {
map.insert("mixed-port".into(), 7897.into());
map.insert("socks-port".into(), 7898.into());
map.insert("port".into(), 7899.into());
map.insert("log-level".into(), "info".into());
map.insert("log-level".into(), "warning".into());
map.insert("allow-lan".into(), false.into());
map.insert("ipv6".into(), true.into());
map.insert("mode".into(), "rule".into());
map.insert("external-controller".into(), "127.0.0.1:9097".into());
let mut cors_map = Mapping::new();
cors_map.insert("allow-private-network".into(), true.into());
cors_map.insert("allow-origins".into(), vec!["*"].into());
map.insert("secret".into(), "".into());
cors_map.insert(
"allow-origins".into(),
vec![
"tauri://localhost",
"http://tauri.localhost",
"http://localhost:3000",
"https://yacd.metacubex.one",
"https://metacubex.github.io",
"https://board.zash.run.place",
]
.into(),
);
map.insert("secret".into(), "set-your-secret".into());
map.insert("tun".into(), tun.into());
map.insert("external-controller-cors".into(), cors_map.into());
map.insert("unified-delay".into(), true.into());
@@ -77,6 +94,24 @@ impl IClashTemp {
config.insert("socks-port".into(), socks_port.into());
config.insert("port".into(), port.into());
config.insert("external-controller".into(), ctrl.into());
// 强制覆盖 external-controller-cors 字段,允许本地和 tauri 前端
let mut cors_map = Mapping::new();
cors_map.insert("allow-private-network".into(), true.into());
cors_map.insert(
"allow-origins".into(),
vec![
"tauri://localhost",
"http://tauri.localhost",
"http://localhost:3000",
"https://yacd.metacubex.one",
"https://metacubex.github.io",
"https://board.zash.run.place",
]
.into(),
);
config.insert("external-controller-cors".into(), cors_map.into());
config
}
@@ -316,6 +351,13 @@ fn test_clash_info() {
);
}
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub struct IClashExternalControllerCors {
pub allow_origins: Option<Vec<String>>,
pub allow_private_network: Option<bool>,
}
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub struct IClash {
@@ -329,6 +371,7 @@ pub struct IClash {
pub dns: Option<IClashDNS>,
pub tun: Option<IClashTUN>,
pub interface_name: Option<String>,
pub external_controller_cors: Option<IClashExternalControllerCors>,
}
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
+2 -1
View File
@@ -3,6 +3,7 @@ use crate::{
config::PrfItem,
core::{handle, CoreManager},
enhance, logging,
process::AsyncHandler,
utils::{dirs, help, logging::Type},
};
use anyhow::{anyhow, Result};
@@ -117,7 +118,7 @@ impl Config {
// 在单独的任务中发送通知
if let Some((msg_type, msg_content)) = validation_result {
tauri::async_runtime::spawn(async move {
AsyncHandler::spawn(move || async move {
sleep(Duration::from_secs(2)).await;
handle::Handle::notice_message(msg_type, &msg_content);
});
+38 -47
View File
@@ -1,10 +1,13 @@
use crate::utils::{dirs, help, resolve::VERSION, tmpl};
use crate::utils::{
dirs, help,
network::{NetworkManager, ProxyType},
tmpl,
};
use anyhow::{bail, Context, Result};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use serde_yaml::Mapping;
use std::fs;
use sysproxy::Sysproxy;
use std::{fs, time::Duration};
use super::Config;
@@ -89,6 +92,12 @@ pub struct PrfOption {
#[serde(skip_serializing_if = "Option::is_none")]
pub update_interval: Option<u64>,
/// for `remote` profile
/// HTTP request timeout in seconds
/// default is 60 seconds
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u64>,
/// for `remote` profile
/// disable certificate validation
/// default is `false`
@@ -122,6 +131,7 @@ impl PrfOption {
a.rules = b.rules.or(a.rules);
a.proxies = b.proxies.or(a.proxies);
a.groups = b.groups.or(a.groups);
a.timeout_seconds = b.timeout_seconds.or(a.timeout_seconds);
Some(a)
}
t => t.0.or(t.1),
@@ -240,58 +250,39 @@ impl PrfItem {
opt_ref.is_some_and(|o| o.danger_accept_invalid_certs.unwrap_or(false));
let user_agent = opt_ref.and_then(|o| o.user_agent.clone());
let update_interval = opt_ref.and_then(|o| o.update_interval);
let timeout = opt_ref.and_then(|o| o.timeout_seconds).unwrap_or(20);
let mut merge = opt_ref.and_then(|o| o.merge.clone());
let mut script = opt_ref.and_then(|o| o.script.clone());
let mut rules = opt_ref.and_then(|o| o.rules.clone());
let mut proxies = opt_ref.and_then(|o| o.proxies.clone());
let mut groups = opt_ref.and_then(|o| o.groups.clone());
let mut builder = reqwest::ClientBuilder::new().use_rustls_tls().no_proxy();
// 使用软件自己的代理
if self_proxy {
let port = Config::verge()
.latest()
.verge_mixed_port
.unwrap_or(Config::clash().data().get_mixed_port());
let proxy_scheme = format!("http://127.0.0.1:{port}");
if let Ok(proxy) = reqwest::Proxy::http(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::https(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::all(&proxy_scheme) {
builder = builder.proxy(proxy);
}
}
// 使用系统代理
else if with_proxy {
if let Ok(p @ Sysproxy { enable: true, .. }) = Sysproxy::get_system_proxy() {
let proxy_scheme = format!("http://{}:{}", p.host, p.port);
if let Ok(proxy) = reqwest::Proxy::http(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::https(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::all(&proxy_scheme) {
builder = builder.proxy(proxy);
}
}
}
let version = match VERSION.get() {
Some(v) => format!("clash-verge/v{}", v),
None => "clash-verge/unknown".to_string(),
// 选择代理类型
let proxy_type = if self_proxy {
ProxyType::Localhost
} else if with_proxy {
ProxyType::System
} else {
ProxyType::None
};
builder = builder.danger_accept_invalid_certs(accept_invalid_certs);
builder = builder.user_agent(user_agent.unwrap_or(version));
let resp = builder.build()?.get(url).send().await?;
// 使用网络管理器发送请求
let resp = match NetworkManager::global()
.get_with_interrupt(
url,
proxy_type,
Some(timeout),
user_agent.clone(),
accept_invalid_certs,
)
.await
{
Ok(r) => r,
Err(e) => {
tokio::time::sleep(Duration::from_millis(100)).await;
bail!("failed to fetch remote profile: {}", e);
}
};
let status_code = resp.status();
if !StatusCode::is_success(&status_code) {
+10 -4
View File
@@ -88,6 +88,9 @@ pub struct IVerge {
/// pac script content
pub pac_file_content: Option<String>,
/// proxy host address
pub proxy_host: Option<String>,
/// theme setting
pub theme_setting: Option<IVergeTheme>,
@@ -131,7 +134,7 @@ pub struct IVerge {
pub test_list: Option<Vec<IVergeTestItem>>,
/// 日志清理
/// 0: 不清理; 1: 7天; 2: 30天; 3: 90天
/// 0: 不清理; 1: 1天;2: 7天; 3: 30天; 4: 90天
pub auto_log_clean: Option<i32>,
/// 是否启用随机端口
@@ -275,6 +278,7 @@ impl IVerge {
enable_system_proxy: Some(false),
proxy_auto_config: Some(false),
pac_file_content: Some(DEFAULT_PAC.into()),
proxy_host: Some("127.0.0.1".into()),
enable_random_port: Some(false),
#[cfg(not(target_os = "windows"))]
verge_redir_port: Some(7895),
@@ -299,12 +303,12 @@ impl IVerge {
webdav_url: None,
webdav_username: None,
webdav_password: None,
enable_tray_speed: Some(true),
enable_tray_speed: Some(false),
enable_tray_icon: Some(true),
enable_global_hotkey: Some(true),
enable_auto_light_weight_mode: Some(false),
auto_light_weight_minutes: Some(10),
enable_dns_settings: Some(true),
enable_dns_settings: Some(false),
home_cards: None,
service_state: None,
..Self::default()
@@ -368,7 +372,7 @@ impl IVerge {
patch!(proxy_guard_duration);
patch!(proxy_auto_config);
patch!(pac_file_content);
patch!(proxy_host);
patch!(theme_setting);
patch!(web_ui_list);
patch!(clash_core);
@@ -452,6 +456,7 @@ pub struct IVergeResponse {
pub proxy_guard_duration: Option<u64>,
pub proxy_auto_config: Option<bool>,
pub pac_file_content: Option<String>,
pub proxy_host: Option<String>,
pub theme_setting: Option<IVergeTheme>,
pub web_ui_list: Option<Vec<String>>,
pub clash_core: Option<String>,
@@ -520,6 +525,7 @@ impl From<IVerge> for IVergeResponse {
proxy_guard_duration: verge.proxy_guard_duration,
proxy_auto_config: verge.proxy_auto_config,
pac_file_content: verge.pac_file_content,
proxy_host: verge.proxy_host,
theme_setting: verge.theme_setting,
web_ui_list: verge.web_ui_list,
clash_core: verge.clash_core,
+56 -8
View File
@@ -15,6 +15,9 @@ use std::{
use tokio::time::timeout;
use zip::write::SimpleFileOptions;
// 应用版本常量,来自 tauri.conf.json
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
const TIMEOUT_UPLOAD: u64 = 300; // 上传超时 5 分钟
const TIMEOUT_DOWNLOAD: u64 = 300; // 下载超时 5 分钟
const TIMEOUT_LIST: u64 = 3; // 列表超时 30 秒
@@ -105,6 +108,18 @@ impl WebDavClient {
reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(op.timeout()))
.user_agent(format!(
"clash-verge/{} ({} WebDAV-Client)",
APP_VERSION, OS
))
.redirect(reqwest::redirect::Policy::custom(|attempt| {
// 允许所有请求类型的重定向,包括PUT
if attempt.previous().len() >= 5 {
attempt.error("重定向次数过多")
} else {
attempt.follow()
}
}))
.build()
.unwrap(),
)
@@ -112,12 +127,13 @@ impl WebDavClient {
.set_auth(reqwest_dav::Auth::Basic(config.username, config.password))
.build()?;
// 确保备份目录存在
let list_result = client
// 尝试检查目录是否存在,如果不存在尝试创建,但创建失败不报错
if client
.list(dirs::BACKUP_DIR, reqwest_dav::Depth::Number(0))
.await;
if list_result.is_err() {
client.mkcol(dirs::BACKUP_DIR).await?;
.await
.is_err()
{
let _ = client.mkcol(dirs::BACKUP_DIR).await;
}
// 缓存客户端
@@ -137,9 +153,41 @@ impl WebDavClient {
pub async fn upload(&self, file_path: PathBuf, file_name: String) -> Result<(), Error> {
let client = self.get_client(Operation::Upload).await?;
let webdav_path: String = format!("{}/{}", dirs::BACKUP_DIR, file_name);
let fut = client.put(webdav_path.as_ref(), fs::read(file_path)?);
timeout(Duration::from_secs(TIMEOUT_UPLOAD), fut).await??;
Ok(())
// 读取文件并上传,如果失败尝试一次重试
let file_content = fs::read(&file_path)?;
// 添加超时保护
let upload_result = timeout(
Duration::from_secs(TIMEOUT_UPLOAD),
client.put(&webdav_path, file_content.clone()),
)
.await;
match upload_result {
Err(_) => {
log::warn!("Upload timed out, retrying once");
tokio::time::sleep(Duration::from_millis(500)).await;
timeout(
Duration::from_secs(TIMEOUT_UPLOAD),
client.put(&webdav_path, file_content),
)
.await??;
Ok(())
}
Ok(Err(e)) => {
log::warn!("Upload failed, retrying once: {}", e);
tokio::time::sleep(Duration::from_millis(500)).await;
timeout(
Duration::from_secs(TIMEOUT_UPLOAD),
client.put(&webdav_path, file_content),
)
.await??;
Ok(())
}
Ok(Ok(_)) => Ok(()),
}
}
pub async fn download(&self, filename: String, storage_path: PathBuf) -> Result<(), Error> {
+209 -58
View File
@@ -15,8 +15,15 @@ use crate::{
},
};
use anyhow::Result;
use chrono::Local;
use once_cell::sync::OnceCell;
use std::{fmt, path::PathBuf, sync::Arc};
use std::{
fmt,
fs::{create_dir_all, File},
io::Write,
path::PathBuf,
sync::Arc,
};
use tauri_plugin_shell::{process::CommandChild, ShellExt};
use tokio::sync::Mutex;
@@ -441,7 +448,18 @@ impl CoreManager {
.clone()
.unwrap_or("verge-mihomo".to_string());
let config_dir = dirs::app_home_dir()?;
let (_, child) = app_handle
let service_log_dir = dirs::app_home_dir()?.join("logs").join("service");
create_dir_all(&service_log_dir)?;
let now = Local::now();
let timestamp = now.format("%Y%m%d_%H%M%S").to_string();
let log_path = service_log_dir.join(format!("sidecar_{}.log", timestamp));
let mut log_file = File::create(log_path)?;
let (mut rx, child) = app_handle
.shell()
.sidecar(&clash_core)?
.args([
@@ -451,6 +469,23 @@ impl CoreManager {
dirs::path_to_str(config_file)?,
])
.spawn()?;
tokio::spawn(async move {
while let Some(event) = rx.recv().await {
if let tauri_plugin_shell::process::CommandEvent::Stdout(line) = event {
if let Err(e) = writeln!(log_file, "{}", String::from_utf8_lossy(&line)) {
logging!(
error,
Type::Core,
true,
"[Sidecar] Failed to write stdout to file: {}",
e
);
}
}
}
});
let pid = child.pid();
logging!(
trace,
@@ -506,89 +541,205 @@ impl CoreManager {
child_sidecar: Arc::new(Mutex::new(None)),
})
}
// 当服务安装失败时的回退逻辑
async fn attempt_service_init(&self) -> Result<()> {
if service::check_service_needs_reinstall().await {
logging!(info, Type::Core, true, "服务版本不匹配或状态异常,执行重装");
if let Err(e) = service::reinstall_service().await {
logging!(
warn,
Type::Core,
true,
"服务重装失败 during attempt_service_init: {}",
e
);
return Err(e);
}
// 如果重装成功,还需要尝试启动服务
logging!(info, Type::Core, true, "服务重装成功,尝试启动服务");
}
if let Err(e) = self.start_core_by_service().await {
logging!(
warn,
Type::Core,
true,
"通过服务启动核心失败 during attempt_service_init: {}",
e
);
// 确保 prefer_sidecar 在 start_core_by_service 失败时也被设置
let mut state = service::ServiceState::get();
if !state.prefer_sidecar {
state.prefer_sidecar = true;
state.last_error = Some(format!("通过服务启动核心失败: {}", e));
if let Err(save_err) = state.save() {
logging!(
error,
Type::Core,
true,
"保存ServiceState失败 (in attempt_service_init/start_core_by_service): {}",
save_err
);
}
}
return Err(e);
}
Ok(())
}
pub async fn init(&self) -> Result<()> {
logging!(trace, Type::Core, "Initializing core");
let mut core_started_successfully = false;
if service::is_service_available().await.is_ok() {
logging!(info, Type::Core, true, "服务可用,直接使用服务模式");
// 检查版本是否需要重装
if service::check_service_needs_reinstall().await {
logging!(info, Type::Core, true, "服务版本不匹配,执行重装");
service::reinstall_service().await?;
logging!(
info,
Type::Core,
true,
"服务当前可用或看似可用,尝试通过服务模式启动/重装"
);
match self.attempt_service_init().await {
Ok(_) => {
logging!(info, Type::Core, true, "服务模式成功启动核心");
core_started_successfully = true;
}
Err(_err) => {
logging!(
warn,
Type::Core,
true,
"服务模式启动或重装失败。将尝试Sidecar模式回退。"
);
}
}
self.start_core_by_service().await?;
} else {
// 服务不可用,获取服务状态
logging!(
info,
Type::Core,
true,
"服务初始不可用 (is_service_available 调用失败)"
);
}
if !core_started_successfully {
logging!(
info,
Type::Core,
true,
"核心未通过服务模式启动,执行Sidecar回退或首次安装逻辑"
);
let service_state = service::ServiceState::get();
let has_service_install_record = service_state.last_install_time > 0;
if service_state.prefer_sidecar {
logging!(
info,
Type::Core,
true,
"用户偏好Sidecar模式,使用Sidecar模式启动"
"用户偏好Sidecar模式或先前服务启动失败,使用Sidecar模式启动"
);
self.start_core_by_sidecar().await?;
}
// 检查是否已经有服务安装记录,如果没有,则尝试安装
else if !has_service_install_record {
logging!(info, Type::Core, true, "首次运行,服务不可用,尝试安装");
match service::install_service().await {
Ok(_) => {
logging!(info, Type::Core, true, "服务安装成功");
let mut new_state = service::ServiceState::default();
new_state.record_install();
new_state.prefer_sidecar = false;
new_state.save()?;
if service::is_service_available().await.is_ok() {
self.start_core_by_service().await?;
logging!(info, Type::Core, true, "服务启动成功");
} else {
logging!(
warn,
Type::Core,
true,
"服务安装成功但未能连接,回退到Sidecar模式"
);
// 如果 sidecar 启动成功,我们可以认为核心初始化流程到此结束
// 后续的 Tray::global().subscribe_traffic().await 仍然会执行
} else {
let has_service_install_record = service_state.last_install_time > 0;
if !has_service_install_record {
logging!(
info,
Type::Core,
true,
"无服务安装记录 (首次运行或状态重置),尝试安装服务"
);
match service::install_service().await {
Ok(_) => {
logging!(info, Type::Core, true, "服务安装成功(首次尝试)");
let mut new_state = service::ServiceState::default();
new_state.record_install();
new_state.prefer_sidecar = false;
new_state.save()?;
if service::is_service_available().await.is_ok() {
logging!(info, Type::Core, true, "新安装的服务可用,尝试启动");
if self.start_core_by_service().await.is_ok() {
logging!(info, Type::Core, true, "新安装的服务启动成功");
} else {
logging!(
warn,
Type::Core,
true,
"新安装的服务启动失败,回退到Sidecar模式"
);
let mut final_state = service::ServiceState::get();
final_state.prefer_sidecar = true;
final_state.last_error =
Some("Newly installed service failed to start".to_string());
final_state.save()?;
self.start_core_by_sidecar().await?;
}
} else {
logging!(
warn,
Type::Core,
true,
"服务安装成功但未能连接/立即可用,回退到Sidecar模式"
);
let mut final_state = service::ServiceState::get();
final_state.prefer_sidecar = true;
final_state.last_error = Some(
"Newly installed service not immediately available/connectable"
.to_string(),
);
final_state.save()?;
self.start_core_by_sidecar().await?;
}
}
Err(err) => {
logging!(warn, Type::Core, true, "服务首次安装失败: {}", err);
let new_state = service::ServiceState {
last_error: Some(err.to_string()),
prefer_sidecar: true,
..Default::default()
};
new_state.save()?;
self.start_core_by_sidecar().await?;
}
}
Err(err) => {
// 安装失败,记录错误并使用sidecar模式
logging!(warn, Type::Core, true, "服务安装失败: {}", err);
let new_state = service::ServiceState {
last_error: Some(err.to_string()),
prefer_sidecar: true,
..Default::default()
};
new_state.save()?;
self.start_core_by_sidecar().await?;
} else {
// 有安装记录,服务未成功启动,且初始不偏好sidecar
// 这意味着服务之前可能可用,但 attempt_service_init 失败了(并应已设置 prefer_sidecar),
// 或者服务初始不可用,无偏好,有记录。应强制使用 sidecar。
logging!(
info,
Type::Core,
true,
"有服务安装记录但服务不可用/未启动,强制切换到Sidecar模式"
);
let mut final_state = service::ServiceState::get();
if !final_state.prefer_sidecar {
logging!(
warn,
Type::Core,
true,
"prefer_sidecar 为 false,因服务启动失败或不可用而强制设置为 true"
);
final_state.prefer_sidecar = true;
final_state.last_error =
Some(final_state.last_error.unwrap_or_else(|| {
"Service startup failed or unavailable before sidecar fallback"
.to_string()
}));
final_state.save()?;
}
self.start_core_by_sidecar().await?;
}
} else {
logging!(
info,
Type::Core,
true,
"有服务安装记录但服务不可用,使用Sidecar模式"
);
self.start_core_by_sidecar().await?;
}
}
logging!(trace, Type::Core, "Initied core");
logging!(trace, Type::Core, "Initied core logic completed");
#[cfg(target_os = "macos")]
logging_error!(Type::Core, true, Tray::global().subscribe_traffic().await);
Ok(())
}
+379 -54
View File
@@ -1,9 +1,37 @@
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use std::{sync::Arc, time::Duration};
use std::{
sync::{
atomic::{AtomicU64, Ordering},
mpsc, Arc,
},
thread,
time::{Duration, Instant},
};
use tauri::{AppHandle, Emitter, Manager, WebviewWindow};
use crate::{logging, logging_error, utils::logging::Type};
use crate::{logging, utils::logging::Type};
/// 不同类型的前端通知
#[derive(Debug, Clone)]
enum FrontendEvent {
RefreshClash,
RefreshVerge,
NoticeMessage { status: String, message: String },
ProfileChanged { current_profile_id: String },
TimerUpdated { profile_index: String },
StartupCompleted,
ProfileUpdateStarted { uid: String },
ProfileUpdateCompleted { uid: String },
}
/// 事件发送统计和监控
#[derive(Debug, Default)]
struct EventStats {
total_sent: AtomicU64,
total_errors: AtomicU64,
last_error_time: RwLock<Option<Instant>>,
}
/// 存储启动期间的错误消息
#[derive(Debug, Clone)]
@@ -12,30 +40,236 @@ struct ErrorMessage {
message: String,
}
#[derive(Debug, Default, Clone)]
/// 全局前端通知系统
#[derive(Debug)]
struct NotificationSystem {
sender: Option<mpsc::Sender<FrontendEvent>>,
worker_handle: Option<thread::JoinHandle<()>>,
is_running: bool,
stats: EventStats,
last_emit_time: RwLock<Instant>,
/// 当通知系统失败超过阈值时,进入紧急模式
emergency_mode: RwLock<bool>,
}
impl Default for NotificationSystem {
fn default() -> Self {
Self::new()
}
}
impl NotificationSystem {
fn new() -> Self {
Self {
sender: None,
worker_handle: None,
is_running: false,
stats: EventStats::default(),
last_emit_time: RwLock::new(Instant::now()),
emergency_mode: RwLock::new(false),
}
}
/// 启动通知处理线程
fn start(&mut self) {
if self.is_running {
return;
}
let (tx, rx) = mpsc::channel();
self.sender = Some(tx);
self.is_running = true;
*self.last_emit_time.write() = Instant::now();
self.worker_handle = Some(
thread::Builder::new()
.name("frontend-notifier".into())
.spawn(move || {
let handle = Handle::global();
while !handle.is_exiting() {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(event) => {
let system_guard = handle.notification_system.read();
if system_guard.as_ref().is_none() {
log::warn!("NotificationSystem not found in handle while processing event.");
continue;
}
let system = system_guard.as_ref().unwrap();
let is_emergency = *system.emergency_mode.read();
if is_emergency {
if let FrontendEvent::NoticeMessage { ref status, .. } = event {
if status == "info" {
log::warn!(
"Emergency mode active, skipping info message"
);
continue;
}
}
}
if let Some(window) = handle.get_window() {
*system.last_emit_time.write() = Instant::now();
let (event_name_str, payload_result) = match event {
FrontendEvent::RefreshClash => {
("verge://refresh-clash-config", Ok(serde_json::json!("yes")))
}
FrontendEvent::RefreshVerge => {
("verge://refresh-verge-config", Ok(serde_json::json!("yes")))
}
FrontendEvent::NoticeMessage { status, message } => {
match serde_json::to_value((status, message)) {
Ok(p) => ("verge://notice-message", Ok(p)),
Err(e) => {
log::error!("Failed to serialize NoticeMessage payload: {}", e);
("verge://notice-message", Err(e))
}
}
}
FrontendEvent::ProfileChanged { current_profile_id } => {
("profile-changed", Ok(serde_json::json!(current_profile_id)))
}
FrontendEvent::TimerUpdated { profile_index } => {
("verge://timer-updated", Ok(serde_json::json!(profile_index)))
}
FrontendEvent::StartupCompleted => {
("verge://startup-completed", Ok(serde_json::json!(null)))
}
FrontendEvent::ProfileUpdateStarted { uid } => {
("profile-update-started", Ok(serde_json::json!({ "uid": uid })))
}
FrontendEvent::ProfileUpdateCompleted { uid } => {
("profile-update-completed", Ok(serde_json::json!({ "uid": uid })))
}
};
if let Ok(payload) = payload_result {
match window.emit(event_name_str, payload) {
Ok(_) => {
system.stats.total_sent.fetch_add(1, Ordering::SeqCst);
}
Err(e) => {
log::warn!("Failed to emit event {}: {}", event_name_str, e);
system.stats.total_errors.fetch_add(1, Ordering::SeqCst);
*system.stats.last_error_time.write() = Some(Instant::now());
let errors = system.stats.total_errors.load(Ordering::SeqCst);
const EMIT_ERROR_THRESHOLD: u64 = 10;
if errors > EMIT_ERROR_THRESHOLD && !*system.emergency_mode.read() {
log::warn!(
"Reached {} emit errors, entering emergency mode",
EMIT_ERROR_THRESHOLD
);
*system.emergency_mode.write() = true;
}
}
}
} else {
system.stats.total_errors.fetch_add(1, Ordering::SeqCst);
*system.stats.last_error_time.write() = Some(Instant::now());
log::warn!("Skipped emitting event due to payload serialization error for {}", event_name_str);
}
} else {
log::warn!("No window found, skipping event emit.");
}
thread::sleep(Duration::from_millis(20));
}
Err(mpsc::RecvTimeoutError::Timeout) => {
continue;
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
log::info!(
"Notification channel disconnected, exiting worker thread"
);
break;
}
}
}
log::info!("Notification worker thread exiting");
})
.expect("Failed to start notification worker thread"),
);
}
/// 发送事件到队列
fn send_event(&self, event: FrontendEvent) -> bool {
if *self.emergency_mode.read() {
if let FrontendEvent::NoticeMessage { ref status, .. } = event {
if status == "info" {
log::info!("Skipping info message in emergency mode");
return false;
}
}
}
if let Some(sender) = &self.sender {
match sender.send(event) {
Ok(_) => true,
Err(e) => {
log::warn!("Failed to send event to notification queue: {:?}", e);
self.stats.total_errors.fetch_add(1, Ordering::SeqCst);
*self.stats.last_error_time.write() = Some(Instant::now());
false
}
}
} else {
log::warn!("Notification system not started, can't send event");
false
}
}
fn shutdown(&mut self) {
self.is_running = false;
self.sender = None;
if let Some(handle) = self.worker_handle.take() {
let _ = handle.join();
}
}
}
#[derive(Debug, Clone)]
pub struct Handle {
pub app_handle: Arc<RwLock<Option<AppHandle>>>,
pub is_exiting: Arc<RwLock<bool>>,
/// 存储启动过程中产生的错误消息队列
startup_errors: Arc<RwLock<Vec<ErrorMessage>>>,
startup_completed: Arc<RwLock<bool>>,
notification_system: Arc<RwLock<Option<NotificationSystem>>>,
}
impl Default for Handle {
fn default() -> Self {
Self {
app_handle: Arc::new(RwLock::new(None)),
is_exiting: Arc::new(RwLock::new(false)),
startup_errors: Arc::new(RwLock::new(Vec::new())),
startup_completed: Arc::new(RwLock::new(false)),
notification_system: Arc::new(RwLock::new(Some(NotificationSystem::new()))),
}
}
}
impl Handle {
pub fn global() -> &'static Handle {
static HANDLE: OnceCell<Handle> = OnceCell::new();
HANDLE.get_or_init(|| Handle {
app_handle: Arc::new(RwLock::new(None)),
is_exiting: Arc::new(RwLock::new(false)),
startup_errors: Arc::new(RwLock::new(Vec::new())),
startup_completed: Arc::new(RwLock::new(false)),
})
HANDLE.get_or_init(Handle::default)
}
pub fn init(&self, app_handle: &AppHandle) {
let mut handle = self.app_handle.write();
*handle = Some(app_handle.clone());
{
let mut handle = self.app_handle.write();
*handle = Some(app_handle.clone());
}
let mut system_opt = self.notification_system.write();
if let Some(system) = system_opt.as_mut() {
system.start();
}
}
pub fn app_handle(&self) -> Option<AppHandle> {
@@ -43,7 +277,7 @@ impl Handle {
}
pub fn get_window(&self) -> Option<WebviewWindow> {
let app_handle = self.app_handle().unwrap();
let app_handle = self.app_handle()?;
let window: Option<WebviewWindow> = app_handle.get_webview_window("main");
if window.is_none() {
log::debug!(target:"app", "main window not found");
@@ -52,43 +286,113 @@ impl Handle {
}
pub fn refresh_clash() {
if let Some(window) = Self::global().get_window() {
logging_error!(
Type::Frontend,
true,
window.emit("verge://refresh-clash-config", "yes")
);
let handle = Self::global();
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() {
if let Some(window) = Self::global().get_window() {
logging_error!(
Type::Frontend,
true,
window.emit("verge://refresh-verge-config", "yes")
let handle = Self::global();
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) {
let handle = Self::global();
if handle.is_exiting() {
return;
}
let system_opt = handle.notification_system.read();
if let Some(system) = system_opt.as_ref() {
system.send_event(FrontendEvent::ProfileChanged {
current_profile_id: profile_id,
});
} else {
log::warn!(
"Notification system not initialized when trying to send ProfileChanged event."
);
}
}
#[allow(unused)]
pub fn refresh_profiles() {
if let Some(window) = Self::global().get_window() {
logging_error!(
Type::Frontend,
true,
window.emit("verge://refresh-profiles-config", "yes")
pub fn notify_timer_updated(profile_index: String) {
let handle = Self::global();
if handle.is_exiting() {
return;
}
let system_opt = handle.notification_system.read();
if let Some(system) = system_opt.as_ref() {
system.send_event(FrontendEvent::TimerUpdated { profile_index });
} else {
log::warn!(
"Notification system not initialized when trying to send TimerUpdated event."
);
}
}
/// 通知前端显示消息,如果在启动过程中,则将消息存入启动错误队列
pub fn notify_startup_completed() {
let handle = Self::global();
if handle.is_exiting() {
return;
}
let system_opt = handle.notification_system.read();
if let Some(system) = system_opt.as_ref() {
system.send_event(FrontendEvent::StartupCompleted);
} else {
log::warn!(
"Notification system not initialized when trying to send StartupCompleted event."
);
}
}
pub fn notify_profile_update_started(uid: String) {
let handle = Self::global();
if handle.is_exiting() {
return;
}
let system_opt = handle.notification_system.read();
if let Some(system) = system_opt.as_ref() {
system.send_event(FrontendEvent::ProfileUpdateStarted { uid });
} else {
log::warn!("Notification system not initialized when trying to send ProfileUpdateStarted event.");
}
}
pub fn notify_profile_update_completed(uid: String) {
let handle = Self::global();
if handle.is_exiting() {
return;
}
let system_opt = handle.notification_system.read();
if let Some(system) = system_opt.as_ref() {
system.send_event(FrontendEvent::ProfileUpdateCompleted { uid });
} else {
log::warn!("Notification system not initialized when trying to send ProfileUpdateCompleted event.");
}
}
/// 通知前端显示消息队列
pub fn notice_message<S: Into<String>, M: Into<String>>(status: S, msg: M) {
let handle = Self::global();
let status_str = status.into();
let msg_str = msg.into();
// 检查是否正在启动过程中
if !*handle.startup_completed.read() {
logging!(
info,
@@ -99,7 +403,6 @@ impl Handle {
msg_str
);
// 将消息添加到启动错误队列
let mut errors = handle.startup_errors.write();
errors.push(ErrorMessage {
status: status_str,
@@ -108,17 +411,19 @@ impl Handle {
return;
}
// 已经完成启动,直接发送消息
if let Some(window) = handle.get_window() {
logging_error!(
Type::Frontend,
true,
window.emit("verge://notice-message", (status_str, msg_str))
);
if handle.is_exiting() {
return;
}
let system_opt = handle.notification_system.read();
if let Some(system) = system_opt.as_ref() {
system.send_event(FrontendEvent::NoticeMessage {
status: status_str,
message: msg_str,
});
}
}
/// 标记启动已完成,并发送所有启动阶段累积的错误消息
pub fn mark_startup_completed(&self) {
{
let mut completed = self.startup_completed.write();
@@ -147,27 +452,47 @@ impl Handle {
errors.len()
);
// 等待2秒以确保前端已完全加载,延迟发送错误通知
if let Some(window) = self.get_window() {
let window_clone = window.clone();
let errors_clone = errors.clone();
// 启动单独线程处理启动错误,避免阻塞主线程
let thread_result = thread::Builder::new()
.name("startup-errors-sender".into())
.spawn(move || {
thread::sleep(Duration::from_secs(2));
tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
let handle = Handle::global();
if handle.is_exiting() {
return;
}
for error in errors_clone {
let _ =
window_clone.emit("verge://notice-message", (error.status, error.message));
// 每条消息之间间隔500ms,避免消息堆积
tokio::time::sleep(Duration::from_millis(500)).await;
let system_opt = handle.notification_system.read();
if let Some(system) = system_opt.as_ref() {
for error in errors {
if handle.is_exiting() {
break;
}
system.send_event(FrontendEvent::NoticeMessage {
status: error.status,
message: error.message,
});
thread::sleep(Duration::from_millis(300));
}
}
});
if let Err(e) = thread_result {
log::error!("Failed to spawn startup errors thread: {}", e);
}
}
pub fn set_is_exiting(&self) {
let mut is_exiting = self.is_exiting.write();
*is_exiting = true;
let mut system_opt = self.notification_system.write();
if let Some(system) = system_opt.as_mut() {
system.shutdown();
}
}
pub fn is_exiting(&self) -> bool {
+15 -26
View File
@@ -3,13 +3,14 @@ use crate::{
core::handle,
feat, logging, logging_error,
module::lightweight::entry_lightweight_mode,
process::AsyncHandler,
utils::{logging::Type, resolve},
};
use anyhow::{bail, Result};
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use std::{collections::HashMap, sync::Arc};
use tauri::{async_runtime, Manager};
use tauri::Manager;
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, ShortcutState};
pub struct Hotkey {
@@ -33,7 +34,7 @@ impl Hotkey {
debug,
Type::Hotkey,
true,
"Initializing hotkeys with enable: {}",
"Initializing global hotkeys: {}",
enable_global_hotkey
);
@@ -80,7 +81,6 @@ impl Hotkey {
logging!(
debug,
Type::Hotkey,
true,
"Successfully registered hotkey {} -> {}",
key,
func
@@ -103,7 +103,7 @@ impl Hotkey {
}
self.current.lock().clone_from(hotkeys);
} else {
logging!(debug, Type::Hotkey, true, "No hotkeys configured");
logging!(debug, Type::Hotkey, "No hotkeys configured");
}
Ok(())
@@ -123,7 +123,6 @@ impl Hotkey {
logging!(
debug,
Type::Hotkey,
true,
"Attempting to register hotkey: {} for function: {}",
hotkey,
func
@@ -133,7 +132,6 @@ impl Hotkey {
logging!(
debug,
Type::Hotkey,
true,
"Hotkey {} was already registered, unregistering first",
hotkey
);
@@ -145,7 +143,6 @@ impl Hotkey {
logging!(
debug,
Type::Hotkey,
true,
"Registering open_or_close_dashboard function"
);
|| {
@@ -157,23 +154,18 @@ impl Hotkey {
);
// 使用 spawn_blocking 来确保在正确的线程上执行
async_runtime::spawn_blocking(|| {
logging!(
debug,
Type::Hotkey,
true,
"Toggle dashboard window visibility"
);
AsyncHandler::spawn_blocking(|| {
logging!(debug, Type::Hotkey, "Toggle dashboard window visibility");
// 检查窗口是否存在
if let Some(window) = handle::Handle::global().get_window() {
// 如果窗口可见,则隐藏它
if window.is_visible().unwrap_or(false) {
logging!(info, Type::Window, true, "Window is visible, hiding it");
logging!(info, Type::Window, "Window is visible, hiding it");
let _ = window.hide();
} else {
// 如果窗口不可见,则显示它
logging!(info, Type::Window, true, "Window is hidden, showing it");
logging!(info, Type::Window, "Window is hidden, showing it");
if window.is_minimized().unwrap_or(false) {
let _ = window.unminimize();
}
@@ -185,7 +177,6 @@ impl Hotkey {
logging!(
info,
Type::Window,
true,
"Window does not exist, creating a new one"
);
resolve::create_window(true);
@@ -195,7 +186,6 @@ impl Hotkey {
logging!(
debug,
Type::Hotkey,
true,
"=== Hotkey Dashboard Window Operation End ==="
);
}
@@ -206,12 +196,12 @@ impl Hotkey {
"toggle_system_proxy" => || feat::toggle_system_proxy(),
"toggle_tun_mode" => || feat::toggle_tun_mode(None),
"entry_lightweight_mode" => || entry_lightweight_mode(),
"quit" => || feat::quit(Some(0)),
"quit" => || feat::quit(),
#[cfg(target_os = "macos")]
"hide" => || feat::hide(),
_ => {
logging!(error, Type::Hotkey, true, "Invalid function: {}", func);
logging!(error, Type::Hotkey, "Invalid function: {}", func);
bail!("invalid function \"{func}\"");
}
};
@@ -220,18 +210,18 @@ impl Hotkey {
let _ = manager.on_shortcut(hotkey, move |app_handle, hotkey, event| {
if event.state == ShortcutState::Pressed {
logging!(debug, Type::Hotkey, true, "Hotkey pressed: {:?}", hotkey);
logging!(debug, Type::Hotkey, "Hotkey pressed: {:?}", hotkey);
if hotkey.key == Code::KeyQ && is_quit {
if let Some(window) = app_handle.get_webview_window("main") {
if window.is_focused().unwrap_or(false) {
logging!(debug, Type::Hotkey, true, "Executing quit function");
logging!(debug, Type::Hotkey, "Executing quit function");
f();
}
}
} else {
// 直接执行函数,不做任何状态检查
logging!(debug, Type::Hotkey, true, "Executing function directly");
logging!(debug, Type::Hotkey, "Executing function directly");
// 获取全局热键状态
let is_enable_global_hotkey = Config::verge()
@@ -257,7 +247,6 @@ impl Hotkey {
logging!(
debug,
Type::Hotkey,
true,
"Successfully registered hotkey {} for {}",
hotkey,
func
@@ -269,7 +258,7 @@ impl Hotkey {
let app_handle = handle::Handle::global().app_handle().unwrap();
let manager = app_handle.global_shortcut();
manager.unregister(hotkey)?;
logging!(debug, Type::Hotkey, true, "Unregister hotkey {}", hotkey);
logging!(debug, Type::Hotkey, "Unregister hotkey {}", hotkey);
Ok(())
}
@@ -285,7 +274,7 @@ impl Hotkey {
});
add.iter().for_each(|(key, func)| {
logging_error!(Type::Hotkey, true, self.register(key, func));
logging_error!(Type::Hotkey, self.register(key, func));
});
*current = new_hotkeys;
+2 -1
View File
@@ -4,9 +4,10 @@ mod core;
pub mod handle;
pub mod hotkey;
pub mod service;
pub mod service_ipc;
pub mod sysopt;
pub mod timer;
pub mod tray;
pub mod win_uwp;
pub use self::core::*;
pub use self::{core::*, timer::Timer};
+534 -131
View File
@@ -1,23 +1,19 @@
use crate::{
config::Config,
core::service_ipc::{send_ipc_request, IpcCommand},
logging,
utils::{dirs, logging::Type},
};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
env::current_exe,
path::PathBuf,
process::Command as StdCommand,
time::{SystemTime, UNIX_EPOCH},
};
use tokio::time::Duration;
// Windows only
const SERVICE_URL: &str = "http://127.0.0.1:33211";
const REQUIRED_SERVICE_VERSION: &str = "1.0.5"; // 定义所需的服务版本号
const REQUIRED_SERVICE_VERSION: &str = "1.1.0"; // 定义所需的服务版本号
// 限制重装时间和次数的常量
const REINSTALL_COOLDOWN_SECS: u64 = 300; // 5分钟冷却期
@@ -49,7 +45,8 @@ impl ServiceState {
latest.service_state = Some(self.clone());
*config.draft() = latest;
config.apply();
Config::verge().latest().save_file()
let result = config.latest().save_file();
result
}
// 更新安装信息
@@ -91,6 +88,7 @@ impl ServiceState {
}
}
// 保留核心数据结构,但将HTTP特定的结构体合并为通用结构体
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ResponseBody {
pub core_type: Option<String>,
@@ -105,6 +103,7 @@ pub struct VersionResponse {
pub version: String,
}
// 保留通用的响应结构体,用于IPC通信后的数据解析
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct JsonResponse {
pub code: u64,
@@ -112,13 +111,6 @@ pub struct JsonResponse {
pub data: Option<ResponseBody>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct VersionJsonResponse {
pub code: u64,
pub msg: String,
pub data: Option<VersionResponse>,
}
#[cfg(target_os = "windows")]
pub async fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, true, "uninstall service");
@@ -228,6 +220,7 @@ pub async fn reinstall_service() -> Result<()> {
Err(err) => {
let error = format!("failed to install service: {}", err);
service_state.last_error = Some(error.clone());
service_state.prefer_sidecar = true;
service_state.save()?;
bail!(error)
}
@@ -355,6 +348,7 @@ pub async fn reinstall_service() -> Result<()> {
Err(err) => {
let error = format!("failed to install service: {}", err);
service_state.last_error = Some(error.clone());
service_state.prefer_sidecar = true;
service_state.save()?;
bail!(error)
}
@@ -381,7 +375,7 @@ pub async fn uninstall_service() -> Result<()> {
r#"do shell script "sudo '{uninstall_shell}'" with administrator privileges with prompt "{prompt}""#
);
logging!(debug, Type::Service, true, "uninstall command: {}", command);
// logging!(debug, Type::Service, true, "uninstall command: {}", command);
let status = StdCommand::new("osascript")
.args(vec!["-e", &command])
@@ -417,7 +411,7 @@ pub async fn install_service() -> Result<()> {
r#"do shell script "sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""#
);
logging!(debug, Type::Service, true, "install command: {}", command);
// logging!(debug, Type::Service, true, "install command: {}", command);
let status = StdCommand::new("osascript")
.args(vec!["-e", &command])
@@ -474,101 +468,279 @@ pub async fn reinstall_service() -> Result<()> {
Err(err) => {
let error = format!("failed to install service: {}", err);
service_state.last_error = Some(error.clone());
service_state.prefer_sidecar = true;
service_state.save()?;
bail!(error)
}
}
}
/// check the windows service status
pub async fn check_service() -> Result<JsonResponse> {
let url = format!("{SERVICE_URL}/get_clash");
let response = reqwest::ClientBuilder::new()
.no_proxy()
.timeout(Duration::from_secs(3))
.build()?
.get(url)
.send()
.await
.context("failed to connect to the Clash Verge Service")?
.json::<JsonResponse>()
.await
.context("failed to parse the Clash Verge Service response")?;
/// 检查服务状态 - 使用IPC通信
pub async fn check_ipc_service_status() -> Result<JsonResponse> {
logging!(info, Type::Service, true, "开始检查服务状态 (IPC)");
Ok(response)
}
// 使用IPC通信
let payload = serde_json::json!({});
// logging!(debug, Type::Service, true, "发送GetClash请求");
/// check the service version
pub async fn check_service_version() -> Result<String> {
let url = format!("{SERVICE_URL}/version");
let response = reqwest::ClientBuilder::new()
.no_proxy()
.timeout(Duration::from_secs(3))
.build()?
.get(url)
.send()
.await
.context("failed to connect to the Clash Verge Service")?
.json::<VersionJsonResponse>()
.await
.context("failed to parse the Clash Verge Service version response")?;
match send_ipc_request(IpcCommand::GetClash, payload).await {
Ok(response) => {
/* logging!(
debug,
Type::Service,
true,
"收到GetClash响应: success={}, error={:?}",
response.success,
response.error
); */
match response.data {
Some(data) => Ok(data.version),
None => bail!("service version not found in response"),
if !response.success {
let err_msg = response.error.unwrap_or_else(|| "未知服务错误".to_string());
logging!(error, Type::Service, true, "服务响应错误: {}", err_msg);
bail!(err_msg);
}
match response.data {
Some(data) => {
// 检查嵌套结构
if let (Some(code), Some(msg)) = (data.get("code"), data.get("msg")) {
let code_value = code.as_u64().unwrap_or(0);
let msg_value = msg.as_str().unwrap_or("ok").to_string();
// 提取嵌套的data字段并解析为ResponseBody
let response_body = if let Some(nested_data) = data.get("data") {
match serde_json::from_value::<ResponseBody>(nested_data.clone()) {
Ok(body) => Some(body),
Err(e) => {
logging!(
warn,
Type::Service,
true,
"解析嵌套的ResponseBody失败: {}; 尝试其他方式",
e
);
None
}
}
} else {
None
};
let json_response = JsonResponse {
code: code_value,
msg: msg_value,
data: response_body,
};
logging!(
info,
Type::Service,
true,
"服务检测成功: code={}, msg={}, data存在={}",
json_response.code,
json_response.msg,
json_response.data.is_some()
);
Ok(json_response)
} else {
// 尝试直接解析
match serde_json::from_value::<JsonResponse>(data.clone()) {
Ok(json_response) => {
logging!(
info,
Type::Service,
true,
"服务检测成功: code={}, msg={}",
json_response.code,
json_response.msg
);
Ok(json_response)
}
Err(e) => {
logging!(
error,
Type::Service,
true,
"解析服务响应失败: {}; 原始数据: {:?}",
e,
data
);
bail!("无法解析服务响应数据: {}", e)
}
}
}
}
None => {
logging!(error, Type::Service, true, "服务响应中没有数据");
bail!("服务响应中没有数据")
}
}
}
Err(e) => {
logging!(error, Type::Service, true, "IPC通信失败: {}", e);
bail!("无法连接到Clash Verge Service: {}", e)
}
}
}
/// check if service needs to be reinstalled
/// 检查服务版本 - 使用IPC通信
pub async fn check_service_version() -> Result<String> {
logging!(info, Type::Service, true, "开始检查服务版本 (IPC)");
let payload = serde_json::json!({});
// logging!(debug, Type::Service, true, "发送GetVersion请求");
match send_ipc_request(IpcCommand::GetVersion, payload).await {
Ok(response) => {
/* logging!(
debug,
Type::Service,
true,
"收到GetVersion响应: success={}, error={:?}",
response.success,
response.error
); */
if !response.success {
let err_msg = response
.error
.unwrap_or_else(|| "获取服务版本失败".to_string());
logging!(error, Type::Service, true, "获取版本错误: {}", err_msg);
bail!(err_msg);
}
match response.data {
Some(data) => {
if let Some(nested_data) = data.get("data") {
if let Some(version) = nested_data.get("version") {
if let Some(version_str) = version.as_str() {
logging!(
info,
Type::Service,
true,
"获取到服务版本: {}",
version_str
);
return Ok(version_str.to_string());
}
}
logging!(
error,
Type::Service,
true,
"嵌套数据中没有version字段: {:?}",
nested_data
);
} else {
// 兼容旧格式
match serde_json::from_value::<VersionResponse>(data.clone()) {
Ok(version_response) => {
logging!(
info,
Type::Service,
true,
"获取到服务版本: {}",
version_response.version
);
return Ok(version_response.version);
}
Err(e) => {
logging!(
error,
Type::Service,
true,
"解析版本响应失败: {}; 原始数据: {:?}",
e,
data
);
bail!("无法解析服务版本数据: {}", e)
}
}
}
bail!("响应中未找到有效的版本信息")
}
None => {
logging!(error, Type::Service, true, "版本响应中没有数据");
bail!("服务版本响应中没有数据")
}
}
}
Err(e) => {
logging!(error, Type::Service, true, "IPC通信失败: {}", e);
bail!("无法连接到Clash Verge Service: {}", e)
}
}
}
/// 检查服务是否需要重装
pub async fn check_service_needs_reinstall() -> bool {
// 获取当前服务状态
logging!(info, Type::Service, true, "开始检查服务是否需要重装");
let service_state = ServiceState::get();
// 首先检查是否在冷却期或超过重装次数限制
if !service_state.can_reinstall() {
log::info!(target: "app", "service reinstall check: in cooldown period or max attempts reached");
log::info!(target: "app", "服务重装检查: 处于冷却期或已达最大尝试次数");
return false;
}
// 然后才检查版本和可用性
// 检查版本和可用性
match check_service_version().await {
Ok(version) => {
// 打印更详细的日志,方便排查问题
log::info!(target: "app", "服务版本检测:当前={}, 要求={}", version, REQUIRED_SERVICE_VERSION);
/* logging!(
info,
Type::Service,
true,
"服务版本检测:当前={}, 要求={}",
version,
REQUIRED_SERVICE_VERSION
); */
let needs_reinstall = version != REQUIRED_SERVICE_VERSION;
if needs_reinstall {
log::warn!(target: "app", "发现服务版本不匹配,需要重装! 当前={}, 要求={}",
version, REQUIRED_SERVICE_VERSION);
logging!(warn, Type::Service, true, "服务版本不匹配,需要重装");
// 打印版本字符串的原始字节,确认没有隐藏字符
log::debug!(target: "app", "当前版本字节: {:?}", version.as_bytes());
log::debug!(target: "app", "要求版本字节: {:?}", REQUIRED_SERVICE_VERSION.as_bytes());
// log::debug!(target: "app", "当前版本字节: {:?}", version.as_bytes());
// log::debug!(target: "app", "要求版本字节: {:?}", REQUIRED_SERVICE_VERSION.as_bytes());
} else {
log::info!(target: "app", "服务版本匹配,无需重装");
// logging!(info, Type::Service, true, "服务版本匹配,无需重装");
}
needs_reinstall
}
Err(err) => {
// 检查服务是否可用,如果可用但版本检查失败,可能只是版本API有问题
match is_service_running().await {
Ok(true) => {
log::info!(target: "app", "service is running but version check failed: {}", err);
false // 服务在运行,不需要重装
logging!(error, Type::Service, true, "检查服务版本失败: {}", err);
// 检查服务是否可用
match is_service_available().await {
Ok(()) => {
log::info!(target: "app", "服务正在运行但版本检查失败: {}", err);
/* logging!(
info,
Type::Service,
true,
"服务正在运行但版本检查失败: {}",
err
); */
false
}
_ => {
log::info!(target: "app", "service is not running or unavailable");
true // 服务不可用,需要重装
log::info!(target: "app", "服务不可用或未运行,需要重装");
// logging!(info, Type::Service, true, "服务不可用或未运行,需要重装");
true
}
}
}
}
}
/// 尝试使用现有服务启动核心,不进行重装
/// 尝试使用服务启动core
pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result<()> {
log::info!(target:"app", "attempting to start core with existing service");
log::info!(target:"app", "尝试使用现有服务启动核心 (IPC)");
// logging!(info, Type::Service, true, "尝试使用现有服务启动核心");
let clash_core = { Config::verge().latest().clash_core.clone() };
let clash_core = clash_core.unwrap_or("verge-mihomo".into());
@@ -586,29 +758,70 @@ pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result
let config_file = dirs::path_to_str(config_file)?;
let mut map = HashMap::new();
map.insert("core_type", clash_core.as_str());
map.insert("bin_path", bin_path);
map.insert("config_dir", config_dir);
map.insert("config_file", config_file);
map.insert("log_file", log_path);
// 构建启动参数
let payload = serde_json::json!({
"core_type": clash_core,
"bin_path": bin_path,
"config_dir": config_dir,
"config_file": config_file,
"log_file": log_path,
});
log::info!(target:"app", "start service: {:?}", map.clone());
// log::info!(target:"app", "启动服务参数: {:?}", payload);
// logging!(info, Type::Service, true, "发送StartClash请求");
let url = format!("{SERVICE_URL}/start_clash");
let _ = reqwest::ClientBuilder::new()
.no_proxy()
.build()?
.post(url)
.json(&map)
.send()
.await
.context("failed to connect to the Clash Verge Service")?;
// 使用IPC通信
match send_ipc_request(IpcCommand::StartClash, payload).await {
Ok(response) => {
/* logging!(
info,
Type::Service,
true,
"收到StartClash响应: success={}, error={:?}",
response.success,
response.error
); */
Ok(())
if !response.success {
let err_msg = response.error.unwrap_or_else(|| "启动核心失败".to_string());
logging!(error, Type::Service, true, "启动核心失败: {}", err_msg);
bail!(err_msg);
}
// 添加对嵌套JSON结构的处理
if let Some(data) = &response.data {
if let Some(code) = data.get("code") {
let code_value = code.as_u64().unwrap_or(1);
let msg = data
.get("msg")
.and_then(|m| m.as_str())
.unwrap_or("未知错误");
if code_value != 0 {
logging!(
error,
Type::Service,
true,
"启动核心返回错误: code={}, msg={}",
code_value,
msg
);
bail!("启动核心失败: {}", msg);
}
}
}
logging!(info, Type::Service, true, "服务成功启动核心");
Ok(())
}
Err(e) => {
logging!(error, Type::Service, true, "启动核心IPC通信失败: {}", e);
bail!("无法连接到Clash Verge Service: {}", e)
}
}
}
/// start the clash by service
// 以服务启动core
pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
log::info!(target: "app", "正在尝试通过服务启动核心");
@@ -618,39 +831,33 @@ pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
log::info!(target: "app", "检测到服务版本: {}, 要求版本: {}",
version, REQUIRED_SERVICE_VERSION);
// 通过字节比较确保完全匹配
if version.as_bytes() != REQUIRED_SERVICE_VERSION.as_bytes() {
log::warn!(target: "app", "服务版本不匹配,需要重装");
false // 版本不匹配
false
} else {
log::info!(target: "app", "服务版本匹配");
true // 版本匹配
true
}
}
Err(err) => {
log::warn!(target: "app", "无法获取服务版本: {}", err);
false // 无法获取版本
false
}
};
// 先尝试直接启动服务,如果服务可用且版本匹配
if version_check {
if let Ok(true) = is_service_running().await {
// 服务正在运行且版本匹配,直接使用
if is_service_available().await.is_ok() {
log::info!(target: "app", "服务已在运行且版本匹配,尝试使用");
return start_with_existing_service(config_file).await;
}
}
// 强制执行版本检查,如果版本不匹配则重装
if !version_check {
log::info!(target: "app", "服务版本不匹配,尝试重装");
// 获取服务状态,检查是否可以重装
let service_state = ServiceState::get();
if !service_state.can_reinstall() {
log::warn!(target: "app", "由于限制无法重装服务");
// 尝试直接启动,即使版本不匹配
if let Ok(()) = start_with_existing_service(config_file).await {
log::info!(target: "app", "尽管版本不匹配,但成功启动了服务");
return Ok(());
@@ -659,25 +866,21 @@ pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
}
}
// 尝试重装
log::info!(target: "app", "开始重装服务");
if let Err(err) = reinstall_service().await {
log::warn!(target: "app", "服务重装失败: {}", err);
// 尝试使用现有服务
log::info!(target: "app", "尝试使用现有服务");
return start_with_existing_service(config_file).await;
}
// 重装成功,尝试启动
log::info!(target: "app", "服务重装成功,尝试启动");
return start_with_existing_service(config_file).await;
}
// 检查服务状态
match check_service().await {
match check_ipc_service_status().await {
Ok(_) => {
// 服务可访问但可能没有运行核心,尝试直接启动
log::info!(target: "app", "服务可用但未运行核心,尝试启动");
if let Ok(()) = start_with_existing_service(config_file).await {
return Ok(());
@@ -692,69 +895,94 @@ pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
if check_service_needs_reinstall().await {
log::info!(target: "app", "服务需要重装");
// 尝试重装
if let Err(err) = reinstall_service().await {
log::warn!(target: "app", "服务重装失败: {}", err);
bail!("Failed to reinstall service: {}", err);
}
// 重装后再次尝试启动
log::info!(target: "app", "服务重装完成,尝试启动核心");
start_with_existing_service(config_file).await
} else {
// 不需要或不能重装,返回错误
log::warn!(target: "app", "服务不可用且无法重装");
bail!("Service is not available and cannot be reinstalled at this time")
}
}
/// stop the clash by service
/// 通过服务停止core
pub(super) async fn stop_core_by_service() -> Result<()> {
let url = format!("{SERVICE_URL}/stop_clash");
let _ = reqwest::ClientBuilder::new()
.no_proxy()
.build()?
.post(url)
.send()
logging!(info, Type::Service, true, "通过服务停止核心 (IPC)");
let payload = serde_json::json!({});
let response = send_ipc_request(IpcCommand::StopClash, payload)
.await
.context("failed to connect to the Clash Verge Service")?;
.context("无法连接到Clash Verge Service")?;
if !response.success {
bail!(response.error.unwrap_or_else(|| "停止核心失败".to_string()));
}
if let Some(data) = &response.data {
if let Some(code) = data.get("code") {
let code_value = code.as_u64().unwrap_or(1);
let msg = data
.get("msg")
.and_then(|m| m.as_str())
.unwrap_or("未知错误");
if code_value != 0 {
logging!(
error,
Type::Service,
true,
"停止核心返回错误: code={}, msg={}",
code_value,
msg
);
bail!("停止核心失败: {}", msg);
}
}
}
Ok(())
}
/// 检查服务是否正在运行
pub async fn is_service_running() -> Result<bool> {
let resp = check_service().await?;
// 检查服务状态码和消息
if resp.code == 0 && resp.msg == "ok" && resp.data.is_some() {
logging!(debug, Type::Service, "Service is running");
Ok(true)
} else {
logging!(debug, Type::Service, "Service is not running");
Ok(false)
}
}
pub async fn is_service_available() -> Result<()> {
let resp = check_service().await?;
if resp.code == 0 && resp.msg == "ok" && resp.data.is_some() {
logging!(debug, Type::Service, "Service is available");
logging!(info, Type::Service, true, "开始检查服务是否正在运行");
match check_ipc_service_status().await {
Ok(resp) => {
if resp.code == 0 && resp.msg == "ok" && resp.data.is_some() {
logging!(info, Type::Service, true, "服务正在运行");
Ok(())
} else {
logging!(
warn,
Type::Service,
true,
"服务未正常运行: code={}, msg={}",
resp.code,
resp.msg
);
Ok(())
}
}
Err(err) => {
logging!(error, Type::Service, true, "检查服务运行状态失败: {}", err);
Err(err)
}
}
Ok(())
}
/// 强制重装服务(用于UI中的修复服务按钮)
/// 强制重装服务(UI修复按钮)
pub async fn force_reinstall_service() -> Result<()> {
log::info!(target: "app", "用户请求强制重装服务");
// 创建默认服务状态(重置所有限制)
let service_state = ServiceState::default();
service_state.save()?;
log::info!(target: "app", "已重置服务状态,开始执行重装");
// 执行重装
match reinstall_service().await {
Ok(()) => {
log::info!(target: "app", "服务重装成功");
@@ -766,3 +994,178 @@ pub async fn force_reinstall_service() -> Result<()> {
}
}
}
/*
/// 彻底诊断服务状态,检查安装状态、IPC通信和服务版本
pub async fn diagnose_service() -> Result<()> {
logging!(info, Type::Service, true, "============= 开始服务诊断 =============");
// 1. 检查服务文件是否存在
let service_path = dirs::service_path();
match service_path {
Ok(path) => {
let service_exists = path.exists();
logging!(info, Type::Service, true, "服务可执行文件路径: {:?}, 存在: {}", path, service_exists);
if !service_exists {
logging!(error, Type::Service, true, "服务可执行文件不存在,需要重新安装");
bail!("服务可执行文件不存在,需要重新安装");
}
// 检查服务版本文件
let version_file = path.with_file_name("version.txt");
if version_file.exists() {
match std::fs::read_to_string(&version_file) {
Ok(content) => {
logging!(info, Type::Service, true, "服务版本文件内容: {}", content.trim());
}
Err(e) => {
logging!(warn, Type::Service, true, "读取服务版本文件失败: {}", e);
}
}
} else {
logging!(warn, Type::Service, true, "服务版本文件不存在: {:?}", version_file);
}
}
Err(e) => {
logging!(error, Type::Service, true, "获取服务路径失败: {}", e);
bail!("获取服务路径失败: {}", e);
}
}
// 2. 检查IPC通信 - 命名管道/Unix套接字
let socket_path = if cfg!(windows) {
r"\\.\pipe\clash-verge-service"
} else {
"/tmp/clash-verge-service.sock"
};
logging!(info, Type::Service, true, "IPC通信路径: {}", socket_path);
if !cfg!(windows) {
// Unix系统检查套接字文件是否存在
let socket_exists = std::path::Path::new(socket_path).exists();
logging!(info, Type::Service, true, "Unix套接字文件是否存在: {}", socket_exists);
if !socket_exists {
logging!(warn, Type::Service, true, "Unix套接字文件不存在,服务可能未运行");
}
}
// 3. 尝试通过IPC检查服务状态
logging!(info, Type::Service, true, "尝试通过IPC通信检查服务状态...");
match check_service().await {
Ok(resp) => {
logging!(info, Type::Service, true, "服务状态检查成功: code={}, msg={}", resp.code, resp.msg);
// 4. 检查服务版本
match check_service_version().await {
Ok(version) => {
logging!(info, Type::Service, true, "服务版本: {}, 要求版本: {}",
version, REQUIRED_SERVICE_VERSION);
if version != REQUIRED_SERVICE_VERSION {
logging!(warn, Type::Service, true, "服务版本不匹配,建议重装服务");
} else {
logging!(info, Type::Service, true, "服务版本匹配");
}
}
Err(err) => {
logging!(error, Type::Service, true, "检查服务版本失败: {}", err);
}
}
}
Err(err) => {
logging!(error, Type::Service, true, "服务状态检查失败: {}", err);
// 5. 检查系统服务状态 - Windows专用
#[cfg(windows)]
{
use std::process::Command;
logging!(info, Type::Service, true, "尝试检查Windows服务状态...");
let output = Command::new("sc")
.args(["query", "clash_verge_service"])
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let contains_running = stdout.contains("RUNNING");
logging!(info, Type::Service, true, "Windows服务查询结果: {}",
if contains_running { "正在运行" } else { "未运行" });
if !contains_running {
logging!(info, Type::Service, true, "服务输出: {}", stdout);
}
}
Err(e) => {
logging!(error, Type::Service, true, "检查Windows服务状态失败: {}", e);
}
}
}
// macOS专用
#[cfg(target_os = "macos")]
{
use std::process::Command;
logging!(info, Type::Service, true, "尝试检查macOS服务状态...");
let output = Command::new("launchctl")
.args(["list", "io.github.clash-verge-rev.clash-verge-rev.service"])
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if out.status.success() {
logging!(info, Type::Service, true, "macOS服务正在运行");
logging!(debug, Type::Service, true, "服务详情: {}", stdout);
} else {
logging!(warn, Type::Service, true, "macOS服务未运行");
if !stderr.is_empty() {
logging!(info, Type::Service, true, "错误信息: {}", stderr);
}
}
}
Err(e) => {
logging!(error, Type::Service, true, "检查macOS服务状态失败: {}", e);
}
}
}
// Linux专用
#[cfg(target_os = "linux")]
{
use std::process::Command;
logging!(info, Type::Service, true, "尝试检查Linux服务状态...");
let output = Command::new("systemctl")
.args(["status", "clash_verge_service"])
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let is_active = stdout.contains("Active: active (running)");
logging!(info, Type::Service, true, "Linux服务状态: {}",
if is_active { "活跃运行中" } else { "未运行" });
if !is_active {
logging!(info, Type::Service, true, "服务状态详情: {}", stdout);
}
}
Err(e) => {
logging!(error, Type::Service, true, "检查Linux服务状态失败: {}", e);
}
}
}
}
}
logging!(info, Type::Service, true, "============= 服务诊断完成 =============");
Ok(())
} */
+340
View File
@@ -0,0 +1,340 @@
use crate::{logging, utils::logging::Type};
use anyhow::{bail, Context, Result};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::time::{SystemTime, UNIX_EPOCH};
const IPC_SOCKET_NAME: &str = if cfg!(windows) {
r"\\.\pipe\clash-verge-service"
} else {
"/tmp/clash-verge-service.sock"
};
// 定义命令类型
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum IpcCommand {
GetClash,
GetVersion,
StartClash,
StopClash,
}
// IPC消息格式
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IpcRequest {
pub id: String,
pub timestamp: u64,
pub command: IpcCommand,
pub payload: serde_json::Value,
pub signature: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IpcResponse {
pub id: String,
pub success: bool,
pub data: Option<serde_json::Value>,
pub error: Option<String>,
pub signature: String,
}
// 密钥派生函数
fn derive_secret_key() -> Vec<u8> {
// to do
// 从系统安全存储中获取或从程序安装时生成的密钥文件中读取
let unique_app_id = "clash-verge-app-secret-fuck-me-until-daylight";
let mut hasher = Sha256::new();
hasher.update(unique_app_id.as_bytes());
hasher.finalize().to_vec()
}
// 创建带签名的请求
pub fn create_signed_request(
command: IpcCommand,
payload: serde_json::Value,
) -> Result<IpcRequest> {
let id = nanoid::nanoid!(32);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let unsigned_request = IpcRequest {
id: id.clone(),
timestamp,
command: command.clone(),
payload: payload.clone(),
signature: String::new(),
};
let unsigned_json = serde_json::to_string(&unsigned_request)?;
let signature = sign_message(&unsigned_json)?;
Ok(IpcRequest {
id,
timestamp,
command,
payload,
signature,
})
}
// 签名消息
fn sign_message(message: &str) -> Result<String> {
type HmacSha256 = Hmac<Sha256>;
let secret_key = derive_secret_key();
let mut mac = HmacSha256::new_from_slice(&secret_key).context("HMAC初始化失败")?;
mac.update(message.as_bytes());
let result = mac.finalize();
let signature = hex::encode(result.into_bytes());
Ok(signature)
}
// 验证响应签名
pub fn verify_response_signature(response: &IpcResponse) -> Result<bool> {
let verification_response = IpcResponse {
id: response.id.clone(),
success: response.success,
data: response.data.clone(),
error: response.error.clone(),
signature: String::new(),
};
let message = serde_json::to_string(&verification_response)?;
let expected_signature = sign_message(&message)?;
Ok(expected_signature == response.signature)
}
// IPC连接管理-win
#[cfg(target_os = "windows")]
pub async fn send_ipc_request(
command: IpcCommand,
payload: serde_json::Value,
) -> Result<IpcResponse> {
use std::{
ffi::CString,
fs::File,
io::{Read, Write},
os::windows::io::{FromRawHandle, RawHandle},
ptr,
};
use winapi::um::{
fileapi::{CreateFileA, OPEN_EXISTING},
handleapi::INVALID_HANDLE_VALUE,
winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE},
};
logging!(info, Type::Service, true, "正在连接服务 (Windows)...");
let command_type = format!("{:?}", command);
let request = match create_signed_request(command, payload) {
Ok(req) => req,
Err(e) => {
logging!(error, Type::Service, true, "创建签名请求失败: {}", e);
return Err(e);
}
};
let request_json = serde_json::to_string(&request)?;
let result = tokio::task::spawn_blocking(move || -> Result<IpcResponse> {
let c_pipe_name = match CString::new(IPC_SOCKET_NAME) {
Ok(name) => name,
Err(e) => {
logging!(error, Type::Service, true, "创建CString失败: {}", e);
return Err(anyhow::anyhow!("创建CString失败: {}", e));
}
};
let handle = unsafe {
CreateFileA(
c_pipe_name.as_ptr(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
ptr::null_mut(),
OPEN_EXISTING,
0,
ptr::null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
let error = std::io::Error::last_os_error();
logging!(
error,
Type::Service,
true,
"连接到服务命名管道失败: {}",
error
);
return Err(anyhow::anyhow!("无法连接到服务命名管道: {}", error));
}
let mut pipe = unsafe { File::from_raw_handle(handle as RawHandle) };
logging!(info, Type::Service, true, "服务连接成功 (Windows)");
let request_bytes = request_json.as_bytes();
let len_bytes = (request_bytes.len() as u32).to_be_bytes();
if let Err(e) = pipe.write_all(&len_bytes) {
logging!(error, Type::Service, true, "写入请求长度失败: {}", e);
return Err(anyhow::anyhow!("写入请求长度失败: {}", e));
}
if let Err(e) = pipe.write_all(request_bytes) {
logging!(error, Type::Service, true, "写入请求内容失败: {}", e);
return Err(anyhow::anyhow!("写入请求内容失败: {}", e));
}
if let Err(e) = pipe.flush() {
logging!(error, Type::Service, true, "刷新管道失败: {}", e);
return Err(anyhow::anyhow!("刷新管道失败: {}", e));
}
let mut response_len_bytes = [0u8; 4];
if let Err(e) = pipe.read_exact(&mut response_len_bytes) {
logging!(error, Type::Service, true, "读取响应长度失败: {}", e);
return Err(anyhow::anyhow!("读取响应长度失败: {}", e));
}
let response_len = u32::from_be_bytes(response_len_bytes) as usize;
let mut response_bytes = vec![0u8; response_len];
if let Err(e) = pipe.read_exact(&mut response_bytes) {
logging!(error, Type::Service, true, "读取响应内容失败: {}", e);
return Err(anyhow::anyhow!("读取响应内容失败: {}", e));
}
let response: IpcResponse = match serde_json::from_slice::<IpcResponse>(&response_bytes) {
Ok(r) => r,
Err(e) => {
logging!(error, Type::Service, true, "服务响应解析失败: {}", e);
return Err(anyhow::anyhow!("解析响应失败: {}", e));
}
};
match verify_response_signature(&response) {
Ok(valid) => {
if !valid {
logging!(error, Type::Service, true, "服务响应签名验证失败");
bail!("服务响应签名验证失败");
}
}
Err(e) => {
logging!(error, Type::Service, true, "验证响应签名时出错: {}", e);
return Err(e);
}
}
logging!(
info,
Type::Service,
true,
"IPC请求完成: 命令={}, 成功={}",
command_type,
response.success
);
Ok(response)
})
.await??;
Ok(result)
}
// IPC连接管理-unix
#[cfg(target_family = "unix")]
pub async fn send_ipc_request(
command: IpcCommand,
payload: serde_json::Value,
) -> Result<IpcResponse> {
use std::os::unix::net::UnixStream;
logging!(info, Type::Service, true, "正在连接服务 (Unix)...");
let command_type = format!("{:?}", command);
let request = match create_signed_request(command, payload) {
Ok(req) => req,
Err(e) => {
logging!(error, Type::Service, true, "创建签名请求失败: {}", e);
return Err(e);
}
};
let request_json = serde_json::to_string(&request)?;
let mut stream = match UnixStream::connect(IPC_SOCKET_NAME) {
Ok(s) => {
logging!(info, Type::Service, true, "服务连接成功 (Unix)");
s
}
Err(e) => {
logging!(error, Type::Service, true, "连接到Unix套接字失败: {}", e);
return Err(anyhow::anyhow!("无法连接到服务Unix套接字: {}", e));
}
};
let request_bytes = request_json.as_bytes();
let len_bytes = (request_bytes.len() as u32).to_be_bytes();
if let Err(e) = std::io::Write::write_all(&mut stream, &len_bytes) {
logging!(error, Type::Service, true, "写入请求长度失败: {}", e);
return Err(anyhow::anyhow!("写入请求长度失败: {}", e));
}
if let Err(e) = std::io::Write::write_all(&mut stream, request_bytes) {
logging!(error, Type::Service, true, "写入请求内容失败: {}", e);
return Err(anyhow::anyhow!("写入请求内容失败: {}", e));
}
let mut response_len_bytes = [0u8; 4];
if let Err(e) = std::io::Read::read_exact(&mut stream, &mut response_len_bytes) {
logging!(error, Type::Service, true, "读取响应长度失败: {}", e);
return Err(anyhow::anyhow!("读取响应长度失败: {}", e));
}
let response_len = u32::from_be_bytes(response_len_bytes) as usize;
let mut response_bytes = vec![0u8; response_len];
if let Err(e) = std::io::Read::read_exact(&mut stream, &mut response_bytes) {
logging!(error, Type::Service, true, "读取响应内容失败: {}", e);
return Err(anyhow::anyhow!("读取响应内容失败: {}", e));
}
let response: IpcResponse = match serde_json::from_slice::<IpcResponse>(&response_bytes) {
Ok(r) => r,
Err(e) => {
logging!(error, Type::Service, true, "服务响应解析失败: {}", e,);
return Err(anyhow::anyhow!("解析响应失败: {}", e));
}
};
match verify_response_signature(&response) {
Ok(valid) => {
if !valid {
logging!(error, Type::Service, true, "服务响应签名验证失败");
bail!("服务响应签名验证失败");
}
}
Err(e) => {
logging!(error, Type::Service, true, "验证响应签名时出错: {}", e);
return Err(e);
}
}
logging!(
info,
Type::Service,
true,
"IPC请求完成: 命令={}, 成功={}",
command_type,
response.success
);
Ok(response)
}
+194 -112
View File
@@ -1,7 +1,10 @@
#[cfg(target_os = "windows")]
use crate::utils::autostart as startup_shortcut;
use crate::{
config::{Config, IVerge},
core::handle::Handle,
logging_error,
logging, logging_error,
process::AsyncHandler,
utils::logging::Type,
};
use anyhow::Result;
@@ -16,8 +19,6 @@ use tokio::time::{sleep, Duration};
pub struct Sysopt {
update_sysproxy: Arc<TokioMutex<bool>>,
reset_sysproxy: Arc<TokioMutex<bool>>,
/// helps to auto launch the app
auto_launch: Arc<Mutex<bool>>,
/// record whether the guard async is running or not
guard_state: Arc<Mutex<bool>>,
}
@@ -58,7 +59,6 @@ impl Sysopt {
SYSOPT.get_or_init(|| Sysopt {
update_sysproxy: Arc::new(TokioMutex::new(false)),
reset_sysproxy: Arc::new(TokioMutex::new(false)),
auto_launch: Arc::new(Mutex::new(false)),
guard_state: Arc::new(false.into()),
})
}
@@ -78,12 +78,16 @@ impl Sysopt {
.unwrap_or(Config::clash().data().get_mixed_port());
let pac_port = IVerge::get_singleton_port();
let (sys_enable, pac_enable) = {
let (sys_enable, pac_enable, proxy_host) = {
let verge = Config::verge();
let verge = verge.latest();
(
verge.enable_system_proxy.unwrap_or(false),
verge.proxy_auto_config.unwrap_or(false),
verge
.proxy_host
.clone()
.unwrap_or_else(|| String::from("127.0.0.1")),
)
};
@@ -91,13 +95,13 @@ impl Sysopt {
{
let mut sys = Sysproxy {
enable: false,
host: String::from("127.0.0.1"),
host: proxy_host.clone(),
port,
bypass: get_bypass(),
};
let mut auto = Autoproxy {
enable: false,
url: format!("http://127.0.0.1:{pac_port}/commands/pac"),
url: format!("http://{}:{}/commands/pac", proxy_host, pac_port),
};
if !sys_enable {
@@ -141,7 +145,7 @@ impl Sysopt {
let shell = app_handle.shell();
let output = if pac_enable {
let address = format!("http://{}:{}/commands/pac", "127.0.0.1", pac_port);
let address = format!("http://{}:{}/commands/pac", proxy_host, pac_port);
let output = shell
.command(sysproxy_exe.as_path().to_str().unwrap())
.args(["pac", address.as_str()])
@@ -150,7 +154,7 @@ impl Sysopt {
.unwrap();
output
} else {
let address = format!("{}:{}", "127.0.0.1", port);
let address = format!("{}:{}", proxy_host, port);
let bypass = get_bypass();
let output = shell
.command(sysproxy_exe.as_path().to_str().unwrap())
@@ -216,40 +220,69 @@ impl Sysopt {
/// update the startup
pub fn update_launch(&self) -> Result<()> {
let _lock = self.auto_launch.lock();
let enable = { Config::verge().latest().enable_auto_launch };
let enable = enable.unwrap_or(false);
let app_handle = Handle::global().app_handle().unwrap();
let autostart_manager = app_handle.autolaunch();
let enable_auto_launch = { Config::verge().latest().enable_auto_launch };
let is_enable = enable_auto_launch.unwrap_or(false);
logging!(info, true, "Setting auto-launch state to: {:?}", is_enable);
log::info!(target: "app", "Setting auto launch to: {}", enable);
// 首先尝试使用快捷方式方法
#[cfg(target_os = "windows")]
{
if is_enable {
if let Err(e) = startup_shortcut::create_shortcut() {
log::error!(target: "app", "创建启动快捷方式失败: {}", e);
// 如果快捷方式创建失败,回退到原来的方法
self.try_original_autostart_method(is_enable);
} else {
return Ok(());
}
} else {
if let Err(e) = startup_shortcut::remove_shortcut() {
log::error!(target: "app", "删除启动快捷方式失败: {}", e);
self.try_original_autostart_method(is_enable);
} else {
return Ok(());
}
}
}
match enable {
true => {
let result = autostart_manager.enable();
if let Err(ref e) = result {
log::error!(target: "app", "Failed to enable auto launch: {}", e);
} else {
log::info!(target: "app", "Auto launch enabled successfully");
}
logging_error!(Type::System, true, result);
}
false => {
let result = autostart_manager.disable();
if let Err(ref e) = result {
log::error!(target: "app", "Failed to disable auto launch: {}", e);
} else {
log::info!(target: "app", "Auto launch disabled successfully");
}
logging_error!(Type::System, true, result);
}
};
#[cfg(not(target_os = "windows"))]
{
// 非Windows平台使用原来的方法
self.try_original_autostart_method(is_enable);
}
Ok(())
}
/// 尝试使用原来的自启动方法
fn try_original_autostart_method(&self, is_enable: bool) {
let app_handle = Handle::global().app_handle().unwrap();
let autostart_manager = app_handle.autolaunch();
if is_enable {
logging_error!(Type::System, true, "{:?}", autostart_manager.enable());
} else {
logging_error!(Type::System, true, "{:?}", autostart_manager.disable());
}
}
/// 获取当前自启动的实际状态
pub fn get_launch_status(&self) -> Result<bool> {
// 首先尝试检查快捷方式是否存在
#[cfg(target_os = "windows")]
{
match startup_shortcut::is_shortcut_enabled() {
Ok(enabled) => {
log::info!(target: "app", "快捷方式自启动状态: {}", enabled);
return Ok(enabled);
}
Err(e) => {
log::error!(target: "app", "检查快捷方式失败,尝试原来的方法: {}", e);
}
}
}
// 回退到原来的方法
let app_handle = Handle::global().app_handle().unwrap();
let autostart_manager = app_handle.autolaunch();
@@ -268,14 +301,14 @@ impl Sysopt {
fn guard_proxy(&self) {
let _lock = self.guard_state.lock();
tauri::async_runtime::spawn(async move {
AsyncHandler::spawn(move || async move {
// default duration is 10s
let mut wait_secs = 10u64;
loop {
sleep(Duration::from_secs(wait_secs)).await;
let (enable, guard, guard_duration, pac) = {
let (enable, guard, guard_duration, pac, proxy_host) = {
let verge = Config::verge();
let verge = verge.latest();
(
@@ -283,6 +316,10 @@ impl Sysopt {
verge.enable_proxy_guard.unwrap_or(false),
verge.proxy_guard_duration.unwrap_or(10),
verge.proxy_auto_config.unwrap_or(false),
verge
.proxy_host
.clone()
.unwrap_or_else(|| String::from("127.0.0.1")),
)
};
@@ -296,85 +333,130 @@ impl Sysopt {
log::debug!(target: "app", "try to guard the system proxy");
let sysproxy = Sysproxy::get_system_proxy();
let autoproxy = Autoproxy::get_auto_proxy();
if sysproxy.is_err() || autoproxy.is_err() {
log::error!(target: "app", "failed to get the system proxy");
continue;
}
let sysproxy_enable = sysproxy.ok().map(|s| s.enable).unwrap_or(false);
let autoproxy_enable = autoproxy.ok().map(|s| s.enable).unwrap_or(false);
if sysproxy_enable || autoproxy_enable {
continue;
}
let port = {
Config::verge()
.latest()
.verge_mixed_port
.unwrap_or(Config::clash().data().get_mixed_port())
};
// 获取期望的代理端口
let port = Config::verge()
.latest()
.verge_mixed_port
.unwrap_or(Config::clash().data().get_mixed_port());
let pac_port = IVerge::get_singleton_port();
#[cfg(not(target_os = "windows"))]
{
if pac {
let autoproxy = Autoproxy {
enable: true,
url: format!("http://127.0.0.1:{pac_port}/commands/pac"),
};
logging_error!(Type::System, true, autoproxy.set_auto_proxy());
} else {
let sysproxy = Sysproxy {
enable: true,
host: "127.0.0.1".into(),
port,
bypass: get_bypass(),
};
let bypass = get_bypass();
logging_error!(Type::System, true, sysproxy.set_system_proxy());
// 检查系统代理配置
if pac {
// 检查 PAC 代理设置
let expected_url = format!("http://{}:{}/commands/pac", proxy_host, pac_port);
let autoproxy = match Autoproxy::get_auto_proxy() {
Ok(ap) => ap,
Err(e) => {
log::error!(target: "app", "failed to get the auto proxy: {}", e);
continue;
}
};
// 检查自动代理是否启用且URL是否正确
if !autoproxy.enable || autoproxy.url != expected_url {
log::info!(target: "app", "auto proxy settings changed, restoring...");
#[cfg(not(target_os = "windows"))]
{
let new_autoproxy = Autoproxy {
enable: true,
url: expected_url,
};
logging_error!(Type::System, true, new_autoproxy.set_auto_proxy());
}
#[cfg(target_os = "windows")]
{
use crate::{core::handle::Handle, utils::dirs};
use tauri_plugin_shell::ShellExt;
let app_handle = Handle::global().app_handle().unwrap();
let binary_path = match dirs::service_path() {
Ok(path) => path,
Err(e) => {
log::error!(target: "app", "failed to get service path: {}", e);
continue;
}
};
let sysproxy_exe = binary_path.with_file_name("sysproxy.exe");
if !sysproxy_exe.exists() {
log::error!(target: "app", "sysproxy.exe not found");
continue;
}
let shell = app_handle.shell();
let output = shell
.command(sysproxy_exe.as_path().to_str().unwrap())
.args(["pac", expected_url.as_str()])
.output()
.await
.unwrap();
if !output.status.success() {
log::error!(target: "app", "failed to set auto proxy");
}
}
}
} else {
// 检查常规系统代理设置
let sysproxy = match Sysproxy::get_system_proxy() {
Ok(sp) => sp,
Err(e) => {
log::error!(target: "app", "failed to get the system proxy: {}", e);
continue;
}
};
// 检查系统代理是否启用且配置是否匹配
if !sysproxy.enable || sysproxy.host != proxy_host || sysproxy.port != port {
log::info!(target: "app", "system proxy settings changed, restoring...");
#[cfg(not(target_os = "windows"))]
{
let new_sysproxy = Sysproxy {
enable: true,
host: proxy_host.clone(),
port,
bypass: bypass.clone(),
};
logging_error!(Type::System, true, new_sysproxy.set_system_proxy());
}
#[cfg(target_os = "windows")]
{
use crate::{core::handle::Handle, utils::dirs};
use tauri_plugin_shell::ShellExt;
let app_handle = Handle::global().app_handle().unwrap();
let binary_path = match dirs::service_path() {
Ok(path) => path,
Err(e) => {
log::error!(target: "app", "failed to get service path: {}", e);
continue;
}
};
let sysproxy_exe = binary_path.with_file_name("sysproxy.exe");
if !sysproxy_exe.exists() {
log::error!(target: "app", "sysproxy.exe not found");
continue;
}
let address = format!("{}:{}", proxy_host, port);
let shell = app_handle.shell();
let output = shell
.command(sysproxy_exe.as_path().to_str().unwrap())
.args(["global", address.as_str(), bypass.as_ref()])
.output()
.await
.unwrap();
if !output.status.success() {
log::error!(target: "app", "failed to set system proxy");
}
}
}
}
#[cfg(target_os = "windows")]
{
use crate::{core::handle::Handle, utils::dirs};
use tauri_plugin_shell::ShellExt;
let app_handle = Handle::global().app_handle().unwrap();
let binary_path = dirs::service_path().unwrap();
let sysproxy_exe = binary_path.with_file_name("sysproxy.exe");
if !sysproxy_exe.exists() {
break;
}
let shell = app_handle.shell();
let output = if pac {
let address = format!("http://{}:{}/commands/pac", "127.0.0.1", pac_port);
shell
.command(sysproxy_exe.as_path().to_str().unwrap())
.args(["pac", address.as_str()])
.output()
.await
.unwrap()
} else {
let address = format!("{}:{}", "127.0.0.1", port);
let bypass = get_bypass();
shell
.command(sysproxy_exe.as_path().to_str().unwrap())
.args(["global", address.as_str(), bypass.as_ref()])
.output()
.await
.unwrap()
};
if !output.status.success() {
break;
}
};
}
});
}
+168 -31
View File
@@ -1,6 +1,4 @@
use crate::{
config::Config, core::CoreManager, feat, logging, logging_error, utils::logging::Type,
};
use crate::{config::Config, feat, logging, logging_error, utils::logging::Type};
use anyhow::{Context, Result};
use delay_timer::prelude::{DelayTimer, DelayTimerBuilder, TaskBuilder};
use once_cell::sync::OnceCell;
@@ -71,6 +69,25 @@ impl Timer {
return Err(e);
}
let timer_map = self.timer_map.read();
logging!(
info,
Type::Timer,
"已注册的定时任务数量: {}",
timer_map.len()
);
for (uid, task) in timer_map.iter() {
logging!(
info,
Type::Timer,
"注册了定时任务 - uid={}, interval={}min, task_id={}",
uid,
task.interval_minutes,
task.task_id
);
}
let cur_timestamp = chrono::Local::now().timestamp();
// Collect profiles that need immediate update
@@ -83,6 +100,7 @@ impl Timer {
let uid = item.uid.as_ref()?;
if interval > 0 && cur_timestamp - updated >= interval * 60 {
logging!(info, Type::Timer, "需要立即更新的配置: uid={}", uid);
Some(uid.clone())
} else {
None
@@ -95,12 +113,18 @@ impl Timer {
// Advance tasks outside of locks to minimize lock contention
if !profiles_to_update.is_empty() {
logging!(
info,
Type::Timer,
"需要立即更新的配置数量: {}",
profiles_to_update.len()
);
let timer_map = self.timer_map.read();
let delay_timer = self.delay_timer.write();
for uid in profiles_to_update {
if let Some(task) = timer_map.get(&uid) {
logging!(info, Type::Timer, "Advancing task for uid: {}", uid);
logging!(info, Type::Timer, "立即执行任务: uid={}", uid);
if let Err(e) = delay_timer.advance_task(task.task_id) {
logging!(warn, Type::Timer, "Failed to advance task {}: {}", uid, e);
}
@@ -210,6 +234,13 @@ impl Timer {
if let Some(option) = item.option.as_ref() {
if let (Some(interval), Some(uid)) = (option.update_interval, &item.uid) {
if interval > 0 {
logging!(
debug,
Type::Timer,
"找到定时更新配置: uid={}, interval={}min",
uid,
interval
);
new_map.insert(uid.clone(), interval);
}
}
@@ -217,6 +248,12 @@ impl Timer {
}
}
logging!(
debug,
Type::Timer,
"生成的定时更新配置数量: {}",
new_map.len()
);
new_map
}
@@ -227,20 +264,36 @@ impl Timer {
// Read lock for comparing current state
let timer_map = self.timer_map.read();
logging!(
debug,
Type::Timer,
"当前 timer_map 大小: {}",
timer_map.len()
);
// Find tasks to modify or delete
for (uid, task) in timer_map.iter() {
match new_map.get(uid) {
Some(&interval) if interval != task.interval_minutes => {
// Task exists but interval changed
logging!(
debug,
Type::Timer,
"定时任务间隔变更: uid={}, 旧={}, 新={}",
uid,
task.interval_minutes,
interval
);
diff_map.insert(uid.clone(), DiffFlag::Mod(task.task_id, interval));
}
None => {
// Task no longer needed
logging!(debug, Type::Timer, "定时任务已删除: uid={}", uid);
diff_map.insert(uid.clone(), DiffFlag::Del(task.task_id));
}
_ => {
// Task exists with same interval, no change needed
logging!(debug, Type::Timer, "定时任务保持不变: uid={}", uid);
}
}
}
@@ -250,6 +303,13 @@ impl Timer {
for (uid, &interval) in new_map.iter() {
if !timer_map.contains_key(uid) {
logging!(
debug,
Type::Timer,
"新增定时任务: uid={}, interval={}min",
uid,
interval
);
diff_map.insert(uid.clone(), DiffFlag::Add(next_id, interval));
next_id += 1;
}
@@ -260,6 +320,7 @@ impl Timer {
*self.timer_count.lock() = next_id;
}
logging!(debug, Type::Timer, "定时任务变更数量: {}", diff_map.len());
diff_map
}
@@ -300,42 +361,118 @@ impl Timer {
Ok(())
}
/// Get next update time for a profile
pub fn get_next_update_time(&self, uid: &str) -> Option<i64> {
logging!(info, Type::Timer, "获取下次更新时间,uid={}", uid);
let timer_map = self.timer_map.read();
let task = match timer_map.get(uid) {
Some(t) => t,
None => {
logging!(warn, Type::Timer, "找不到对应的定时任务,uid={}", uid);
return None;
}
};
// Get the profile updated timestamp
let profiles_config = Config::profiles();
let profiles = profiles_config.latest();
let items = match profiles.get_items() {
Some(i) => i,
None => {
logging!(warn, Type::Timer, "获取配置列表失败");
return None;
}
};
let profile = match items.iter().find(|item| item.uid.as_deref() == Some(uid)) {
Some(p) => p,
None => {
logging!(warn, Type::Timer, "找不到对应的配置,uid={}", uid);
return None;
}
};
let updated = profile.updated.unwrap_or(0) as i64;
// Calculate next update time
if updated > 0 && task.interval_minutes > 0 {
let next_time = updated + (task.interval_minutes as i64 * 60);
logging!(
info,
Type::Timer,
"计算得到下次更新时间: {}, uid={}",
next_time,
uid
);
Some(next_time)
} else {
logging!(
warn,
Type::Timer,
"更新时间或间隔无效,updated={}, interval={}",
updated,
task.interval_minutes
);
None
}
}
/// Emit update events for frontend notification
fn emit_update_event(_uid: &str, _is_start: bool) {
#[cfg(any(feature = "verge-dev", feature = "default"))]
{
if _is_start {
super::handle::Handle::notify_profile_update_started(_uid.to_string());
} else {
super::handle::Handle::notify_profile_update_completed(_uid.to_string());
}
}
}
/// Async task with better error handling and logging
async fn async_task(uid: String) {
let task_start = std::time::Instant::now();
logging!(info, Type::Timer, "Running timer task for profile: {}", uid);
// Update profile
let profile_result = feat::update_profile(uid.clone(), None).await;
match tokio::time::timeout(std::time::Duration::from_secs(40), async {
Self::emit_update_event(&uid, true);
match profile_result {
Ok(_) => {
// Update configuration
match CoreManager::global().update_config().await {
Ok(_) => {
let duration = task_start.elapsed().as_millis();
logging!(
info,
Type::Timer,
"Timer task completed successfully for uid: {} (took {}ms)",
uid,
duration
);
}
Err(e) => {
logging_error!(
Type::Timer,
"Failed to refresh config after profile update for uid {}: {}",
uid,
e
);
}
let is_current = Config::profiles().latest().current.as_ref() == Some(&uid);
logging!(
info,
Type::Timer,
"配置 {} 是否为当前激活配置: {}",
uid,
is_current
);
feat::update_profile(uid.clone(), None, Some(is_current)).await
})
.await
{
Ok(result) => match result {
Ok(_) => {
let duration = task_start.elapsed().as_millis();
logging!(
info,
Type::Timer,
"Timer task completed successfully for uid: {} (took {}ms)",
uid,
duration
);
}
}
Err(e) => {
logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e);
Err(e) => {
logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e);
}
},
Err(_) => {
logging_error!(Type::Timer, false, "Timer task timed out for uid: {}", uid);
}
}
// Emit completed event
Self::emit_update_event(&uid, false);
}
}
+341 -119
View File
@@ -5,28 +5,35 @@ pub mod speed_rate;
use crate::{
cmd,
config::Config,
feat,
module::{lightweight::entry_lightweight_mode, mihomo::Rate},
feat, logging,
module::{
lightweight::{entry_lightweight_mode, is_in_lightweight_mode},
mihomo::Rate,
},
resolve,
utils::{dirs::find_target_icons, i18n::t, logging::Type, resolve::VERSION},
utils::{dirs::find_target_icons, i18n::t, resolve::VERSION},
Type,
};
use anyhow::Result;
#[cfg(target_os = "macos")]
use futures::StreamExt;
#[cfg(target_os = "macos")]
use parking_lot::Mutex;
#[cfg(target_os = "macos")]
use parking_lot::RwLock;
#[cfg(target_os = "macos")]
pub use speed_rate::{SpeedRate, Traffic};
use std::fs;
#[cfg(target_os = "macos")]
use std::sync::Arc;
use std::{
fs,
sync::atomic::{AtomicBool, Ordering},
time::{Duration, Instant},
};
use tauri::{
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
tray::{MouseButton, MouseButtonState, TrayIconEvent},
App, AppHandle, Wry,
AppHandle, Wry,
};
#[cfg(target_os = "macos")]
use tokio::sync::broadcast;
@@ -42,10 +49,15 @@ pub struct Tray {
shutdown_tx: Arc<RwLock<Option<broadcast::Sender<()>>>>,
is_subscribed: Arc<RwLock<bool>>,
pub rate_cache: Arc<Mutex<Option<Rate>>>,
last_menu_update: Mutex<Option<Instant>>,
menu_updating: AtomicBool,
}
#[cfg(not(target_os = "macos"))]
pub struct Tray {}
pub struct Tray {
last_menu_update: Mutex<Option<Instant>>,
menu_updating: AtomicBool,
}
impl TrayState {
pub fn get_common_tray_icon() -> (bool, Vec<u8>) {
@@ -160,10 +172,15 @@ impl Tray {
shutdown_tx: Arc::new(RwLock::new(None)),
is_subscribed: Arc::new(RwLock::new(false)),
rate_cache: Arc::new(Mutex::new(None)),
last_menu_update: Mutex::new(None),
menu_updating: AtomicBool::new(false),
});
#[cfg(not(target_os = "macos"))]
return TRAY.get_or_init(|| Tray {});
return TRAY.get_or_init(|| Tray {
last_menu_update: Mutex::new(None),
menu_updating: AtomicBool::new(false),
});
}
pub fn init(&self) -> Result<()> {
@@ -175,45 +192,6 @@ impl Tray {
Ok(())
}
pub fn create_systray(&self, app: &App) -> Result<()> {
let mut builder = TrayIconBuilder::with_id("main")
.icon(app.default_window_icon().unwrap().clone())
.icon_as_template(false);
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
let tray_event = { Config::verge().latest().tray_event.clone() };
let tray_event: String = tray_event.unwrap_or("main_window".into());
if tray_event.as_str() != "tray_menu" {
builder = builder.show_menu_on_left_click(false);
}
}
let tray = builder.build(app)?;
tray.on_tray_icon_event(|_, event| {
let tray_event = { Config::verge().latest().tray_event.clone() };
let tray_event: String = tray_event.unwrap_or("main_window".into());
log::debug!(target: "app","tray event: {:?}", tray_event);
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Down,
..
} = event
{
match tray_event.as_str() {
"system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(None),
"main_window" => resolve::create_window(true),
_ => {}
}
}
});
tray.on_menu_event(on_menu_event);
Ok(())
}
/// 更新托盘点击行为
pub fn update_click_behavior(&self) -> Result<()> {
let app_handle = handle::Handle::global().app_handle().unwrap();
@@ -227,9 +205,50 @@ impl Tray {
Ok(())
}
/// 更新托盘菜单
/// 更新托盘菜单(带频率限制)
pub fn update_menu(&self) -> Result<()> {
let app_handle = handle::Handle::global().app_handle().unwrap();
// 检查是否正在更新或距离上次更新太近
const MIN_UPDATE_INTERVAL: Duration = Duration::from_millis(500);
// 检查是否已有更新任务在执行
if self.menu_updating.load(Ordering::Acquire) {
log::debug!(target: "app", "托盘菜单正在更新中,跳过本次更新");
return Ok(());
}
// 检查更新频率
{
let last_update = self.last_menu_update.lock();
if let Some(last_time) = *last_update {
if last_time.elapsed() < MIN_UPDATE_INTERVAL {
log::debug!(target: "app", "托盘菜单更新频率过高,跳过本次更新");
return Ok(());
}
}
}
let app_handle = match handle::Handle::global().app_handle() {
Some(handle) => handle,
None => {
log::warn!(target: "app", "更新托盘菜单失败: app_handle不存在");
return Ok(()); // 早期返回,避免panic
}
};
// 设置更新状态
self.menu_updating.store(true, Ordering::Release);
let result = self.update_menu_internal(&app_handle);
{
let mut last_update = self.last_menu_update.lock();
*last_update = Some(Instant::now());
}
self.menu_updating.store(false, Ordering::Release);
result
}
fn update_menu_internal(&self, app_handle: &AppHandle) -> Result<()> {
let verge = Config::verge().latest().clone();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
@@ -246,27 +265,50 @@ impl Tray {
.data()
.all_profile_uid_and_name()
.unwrap_or_default();
let is_lightweight_mode = is_in_lightweight_mode();
let tray = app_handle.tray_by_id("main").unwrap();
let _ = tray.set_menu(Some(create_tray_menu(
&app_handle,
Some(mode.as_str()),
*system_proxy,
*tun_mode,
profile_uid_and_name,
)?));
Ok(())
match app_handle.tray_by_id("main") {
Some(tray) => {
let _ = tray.set_menu(Some(create_tray_menu(
&app_handle,
Some(mode.as_str()),
*system_proxy,
*tun_mode,
profile_uid_and_name,
is_lightweight_mode,
)?));
log::debug!(target: "app", "托盘菜单更新成功");
Ok(())
}
None => {
log::warn!(target: "app", "更新托盘菜单失败: 托盘不存在");
Ok(())
}
}
}
/// 更新托盘图标
pub fn update_icon(&self, rate: Option<Rate>) -> Result<()> {
let app_handle = match handle::Handle::global().app_handle() {
Some(handle) => handle,
None => {
log::warn!(target: "app", "更新托盘图标失败: app_handle不存在");
return Ok(());
}
};
let tray = match app_handle.tray_by_id("main") {
Some(tray) => tray,
None => {
log::warn!(target: "app", "更新托盘图标失败: 托盘不存在");
return Ok(());
}
};
let verge = Config::verge().latest().clone();
let system_mode = verge.enable_system_proxy.as_ref().unwrap_or(&false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
let app_handle = handle::Handle::global().app_handle().unwrap();
let tray = app_handle.tray_by_id("main").unwrap();
let (is_custom_icon, icon_bytes) = match (*system_mode, *tun_mode) {
(true, true) => TrayState::get_tun_tray_icon(),
(true, false) => TrayState::get_sysproxy_tray_icon(),
@@ -275,7 +317,7 @@ impl Tray {
};
#[cfg(target_os = "macos")]
{
let enable_tray_speed = verge.enable_tray_speed.unwrap_or(true);
let enable_tray_speed = verge.enable_tray_speed.unwrap_or(false);
let enable_tray_icon = verge.enable_tray_icon.unwrap_or(true);
let colorful = verge.tray_icon.clone().unwrap_or("monochrome".to_string());
let is_colorful = colorful == "colorful";
@@ -290,8 +332,12 @@ impl Tray {
Some(rate)
} else {
let guard = self.speed_rate.lock();
if let Some(rate) = guard.as_ref().unwrap().get_curent_rate() {
Some(rate)
if let Some(guard) = guard.as_ref() {
if let Some(rate) = guard.get_curent_rate() {
Some(rate)
} else {
Some(Rate::default())
}
} else {
Some(Rate::default())
}
@@ -308,9 +354,10 @@ impl Tray {
};
let rate = rate_guard.as_ref();
let rate_bytes = SpeedRate::add_speed_text(is_custom_icon, bytes, rate).unwrap();
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&rate_bytes)?));
let _ = tray.set_icon_as_template(!is_custom_icon && !is_colorful);
if let Ok(rate_bytes) = SpeedRate::add_speed_text(is_custom_icon, bytes, rate) {
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&rate_bytes)?));
let _ = tray.set_icon_as_template(!is_custom_icon && !is_colorful);
}
}
Ok(())
}
@@ -324,8 +371,21 @@ impl Tray {
/// 更新托盘提示
pub fn update_tooltip(&self) -> Result<()> {
let app_handle = handle::Handle::global().app_handle().unwrap();
let version = VERSION.get().unwrap();
let app_handle = match handle::Handle::global().app_handle() {
Some(handle) => handle,
None => {
log::warn!(target: "app", "更新托盘提示失败: app_handle不存在");
return Ok(());
}
};
let version = match VERSION.get() {
Some(v) => v,
None => {
log::warn!(target: "app", "更新托盘提示失败: 版本信息不存在");
return Ok(());
}
};
let verge = Config::verge().latest().clone();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
@@ -342,23 +402,28 @@ impl Tray {
let profiles = Config::profiles();
let profiles = profiles.latest();
if let Some(current_profile_uid) = profiles.get_current() {
let current_profile = profiles.get_item(&current_profile_uid);
current_profile_name = match &current_profile.unwrap().name {
Some(profile_name) => profile_name.to_string(),
None => current_profile_name,
};
if let Ok(profile) = profiles.get_item(&current_profile_uid) {
current_profile_name = match &profile.name {
Some(profile_name) => profile_name.to_string(),
None => current_profile_name,
};
}
};
let tray = app_handle.tray_by_id("main").unwrap();
let _ = tray.set_tooltip(Some(&format!(
"Clash Verge {version}\n{}: {}\n{}: {}\n{}: {}",
t("SysProxy"),
switch_map[system_proxy],
t("TUN"),
switch_map[tun_mode],
t("Profile"),
current_profile_name
)));
if let Some(tray) = app_handle.tray_by_id("main") {
let _ = tray.set_tooltip(Some(&format!(
"Clash Verge {version}\n{}: {}\n{}: {}\n{}: {}",
t("SysProxy"),
switch_map[system_proxy],
t("TUN"),
switch_map[tun_mode],
t("Profile"),
current_profile_name
)));
} else {
log::warn!(target: "app", "更新托盘提示失败: 托盘不存在");
}
Ok(())
}
@@ -377,52 +442,134 @@ impl Tray {
// 如果已经订阅,先取消订阅
if *self.is_subscribed.read() {
self.unsubscribe_traffic();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
let (shutdown_tx, shutdown_rx) = broadcast::channel(1);
let (shutdown_tx, shutdown_rx) = broadcast::channel(3);
*self.shutdown_tx.write() = Some(shutdown_tx);
*self.is_subscribed.write() = true;
let speed_rate = Arc::clone(&self.speed_rate);
let is_subscribed = Arc::clone(&self.is_subscribed);
tauri::async_runtime::spawn(async move {
let mut shutdown = shutdown_rx;
// 使用单线程防止阻塞主线程
std::thread::Builder::new()
.name("traffic-monitor".into())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to build tokio runtime for traffic monitor");
// 在单独的运行时中执行异步任务
rt.block_on(async move {
let mut shutdown = shutdown_rx;
let speed_rate = speed_rate.clone();
let is_subscribed = is_subscribed.clone();
let mut consecutive_errors = 0;
let max_consecutive_errors = 5;
'outer: loop {
match Traffic::get_traffic_stream().await {
Ok(mut stream) => loop {
tokio::select! {
Some(traffic) = stream.next() => {
if let Ok(traffic) = traffic {
let guard = speed_rate.lock();
let enable_tray_speed: bool = Config::verge().latest().enable_tray_speed.unwrap_or(true);
if !enable_tray_speed {
continue;
}
if let Some(sr) = guard.as_ref() {
if let Some(rate) = sr.update_and_check_changed(traffic.up, traffic.down) {
let _ = Tray::global().update_icon(Some(rate));
let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
'outer: loop {
if !*is_subscribed.read() {
log::info!(target: "app", "Traffic subscription has been cancelled");
break;
}
match tokio::time::timeout(
std::time::Duration::from_secs(5),
Traffic::get_traffic_stream()
).await {
Ok(stream_result) => {
match stream_result {
Ok(mut stream) => {
consecutive_errors = 0;
loop {
tokio::select! {
traffic_result = stream.next() => {
match traffic_result {
Some(Ok(traffic)) => {
if let Ok(Some(rate)) = tokio::time::timeout(
std::time::Duration::from_millis(50),
async {
let guard = speed_rate.try_lock();
if let Some(guard) = guard {
if let Some(sr) = guard.as_ref() {
sr.update_and_check_changed(traffic.up, traffic.down)
} else {
None
}
} else {
None
}
}
).await {
let _ = tokio::time::timeout(
std::time::Duration::from_millis(100),
async { let _ = Tray::global().update_icon(Some(rate)); }
).await;
}
},
Some(Err(e)) => {
log::error!(target: "app", "Traffic stream error: {}", e);
consecutive_errors += 1;
if consecutive_errors >= max_consecutive_errors {
log::error!(target: "app", "Too many errors, reconnecting traffic stream");
break;
}
},
None => {
log::info!(target: "app", "Traffic stream ended, reconnecting");
break;
}
}
},
_ = shutdown.recv() => {
log::info!(target: "app", "Received shutdown signal for traffic stream");
break 'outer;
},
_ = interval.tick() => {
if !*is_subscribed.read() {
log::info!(target: "app", "Traffic monitor detected subscription cancelled");
break 'outer;
}
log::debug!(target: "app", "Traffic subscription periodic health check");
},
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
log::info!(target: "app", "Traffic stream max active time reached, reconnecting");
break;
}
}
}
},
Err(e) => {
log::error!(target: "app", "Failed to get traffic stream: {}", e);
consecutive_errors += 1;
if consecutive_errors >= max_consecutive_errors {
log::error!(target: "app", "Too many consecutive errors, pausing traffic monitoring");
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
consecutive_errors = 0;
} else {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
}
},
Err(_) => {
log::error!(target: "app", "Traffic stream initialization timed out");
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
_ = shutdown.recv() => break 'outer,
}
},
Err(e) => {
log::error!(target: "app", "Failed to get traffic stream: {}", e);
// 如果获取流失败,等待一段时间后重试
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// 检查是否应该继续重试
if !*is_subscribed.read() {
break;
}
}
}
}
});
log::info!(target: "app", "Traffic subscription thread terminated");
});
})
.expect("Failed to spawn traffic monitor thread");
Ok(())
}
@@ -436,6 +583,57 @@ impl Tray {
drop(tx);
}
}
pub fn create_tray_from_handle(&self, app_handle: &AppHandle) -> Result<()> {
log::info!(target: "app", "正在从AppHandle创建系统托盘");
// 获取图标
let icon_bytes = TrayState::get_common_tray_icon().1;
let icon = tauri::image::Image::from_bytes(&icon_bytes)?;
let mut builder = TrayIconBuilder::with_id("main")
.icon(icon)
.icon_as_template(false);
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
let tray_event = { Config::verge().latest().tray_event.clone() };
let tray_event: String = tray_event.unwrap_or("main_window".into());
if tray_event.as_str() != "tray_menu" {
builder = builder.show_menu_on_left_click(false);
}
}
let tray = builder.build(app_handle)?;
tray.on_tray_icon_event(|_, event| {
let tray_event = { Config::verge().latest().tray_event.clone() };
let tray_event: String = tray_event.unwrap_or("main_window".into());
log::debug!(target: "app","tray event: {:?}", tray_event);
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Down,
..
} = event
{
match tray_event.as_str() {
"system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(None),
"main_window" => {
if crate::module::lightweight::is_in_lightweight_mode() {
crate::module::lightweight::exit_lightweight_mode();
}
let _ = resolve::create_window(true);
}
_ => {}
}
}
});
tray.on_menu_event(on_menu_event);
log::info!(target: "app", "系统托盘创建成功");
Ok(())
}
}
fn create_tray_menu(
@@ -444,9 +642,13 @@ fn create_tray_menu(
system_proxy_enabled: bool,
tun_mode_enabled: bool,
profile_uid_and_name: Vec<(String, String)>,
is_lightweight_mode: bool,
) -> Result<tauri::menu::Menu<Wry>> {
let mode = mode.unwrap_or("");
let version = VERSION.get().unwrap();
let unknown_version = String::from("unknown");
let version = VERSION.get().unwrap_or(&unknown_version);
let hotkeys = Config::verge()
.latest()
.hotkeys
@@ -554,11 +756,12 @@ fn create_tray_menu(
)
.unwrap();
let lighteweight_mode = &MenuItem::with_id(
let lighteweight_mode = &CheckMenuItem::with_id(
app_handle,
"entry_lightweight_mode",
t("LightWeight Mode"),
true,
is_lightweight_mode,
hotkeys.get("entry_lightweight_mode").map(|s| s.as_str()),
)
.unwrap();
@@ -672,21 +875,40 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
match event.id.as_ref() {
mode @ ("rule_mode" | "global_mode" | "direct_mode") => {
let mode = &mode[0..mode.len() - 5];
println!("change mode to: {}", mode);
logging!(
info,
Type::ProxyMode,
true,
"Switch Proxy Mode To: {}",
mode
);
feat::change_clash_mode(mode.into());
}
"open_window" => resolve::create_window(true),
"open_window" => {
// 如果在轻量模式中,先退出轻量模式
if crate::module::lightweight::is_in_lightweight_mode() {
crate::module::lightweight::exit_lightweight_mode();
}
// 然后创建窗口
let _ = resolve::create_window(true);
}
"system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(None),
"copy_env" => feat::copy_clash_env(),
"open_app_dir" => crate::logging_error!(Type::Cmd, true, cmd::open_app_dir()),
"open_core_dir" => crate::logging_error!(Type::Cmd, true, cmd::open_core_dir()),
"open_logs_dir" => crate::logging_error!(Type::Cmd, true, cmd::open_logs_dir()),
"open_app_dir" => {
let _ = cmd::open_app_dir();
}
"open_core_dir" => {
let _ = cmd::open_core_dir();
}
"open_logs_dir" => {
let _ = cmd::open_logs_dir();
}
"restart_clash" => feat::restart_clash_core(),
"restart_app" => feat::restart_app(),
"entry_lightweight_mode" => entry_lightweight_mode(),
"quit" => {
feat::quit(Some(0));
feat::quit();
}
id if id.starts_with("profiles_") => {
let profile_index = &id["profiles_".len()..];
+80 -29
View File
@@ -9,7 +9,7 @@ use image::{GenericImageView, Rgba, RgbaImage};
use imageproc::drawing::draw_text_mut;
use parking_lot::Mutex;
use std::{io::Cursor, sync::Arc};
use tokio_tungstenite::tungstenite::{http, Message};
use tokio_tungstenite::tungstenite::http;
use tungstenite::client::IntoClientRequest;
#[derive(Debug, Clone)]
pub struct SpeedRate {
@@ -240,41 +240,92 @@ pub struct Traffic {
impl Traffic {
pub async fn get_traffic_stream() -> Result<impl Stream<Item = Result<Traffic, anyhow::Error>>>
{
use futures::stream::{self, StreamExt};
use futures::{
future::FutureExt,
stream::{self, StreamExt},
};
use std::time::Duration;
// 先处理错误和超时情况
let stream = Box::pin(
stream::unfold((), |_| async {
loop {
stream::unfold((), move |_| async move {
'retry: loop {
log::info!(target: "app", "establishing traffic websocket connection");
let (url, token) = MihomoManager::get_traffic_ws_url();
let mut request = url.into_client_request().unwrap();
request
.headers_mut()
.insert(http::header::AUTHORIZATION, token);
match tokio_tungstenite::connect_async(request).await {
Ok((ws_stream, _)) => {
log::info!(target: "app", "traffic ws connection established");
return Some((
ws_stream.map(|msg| {
msg.map_err(anyhow::Error::from).and_then(|msg: Message| {
let data = msg.into_text()?;
let json: serde_json::Value = serde_json::from_str(&data)?;
Ok(Traffic {
up: json["up"].as_u64().unwrap_or(0),
down: json["down"].as_u64().unwrap_or(0),
})
})
}),
(),
));
}
let mut request = match url.into_client_request() {
Ok(req) => req,
Err(e) => {
log::error!(target: "app", "traffic ws connection failed: {e}");
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
log::error!(target: "app", "failed to create websocket request: {}", e);
tokio::time::sleep(Duration::from_secs(2)).await;
continue 'retry;
}
};
request.headers_mut().insert(http::header::AUTHORIZATION, token);
match tokio::time::timeout(Duration::from_secs(3),
tokio_tungstenite::connect_async(request)
).await {
Ok(Ok((ws_stream, _))) => {
log::info!(target: "app", "traffic websocket connection established");
// 设置流超时控制
let traffic_stream = ws_stream
.take_while(|msg| {
let continue_stream = msg.is_ok();
async move { continue_stream }.boxed()
})
.filter_map(|msg| async move {
match msg {
Ok(msg) => {
if !msg.is_text() {
return None;
}
match tokio::time::timeout(
Duration::from_millis(200),
async { msg.into_text() }
).await {
Ok(Ok(text)) => {
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(json) => {
let up = json["up"].as_u64().unwrap_or(0);
let down = json["down"].as_u64().unwrap_or(0);
Some(Ok(Traffic { up, down }))
},
Err(e) => {
log::warn!(target: "app", "traffic json parse error: {} for {}", e, text);
None
}
}
},
Ok(Err(e)) => {
log::warn!(target: "app", "traffic text conversion error: {}", e);
None
},
Err(_) => {
log::warn!(target: "app", "traffic text processing timeout");
None
}
}
},
Err(e) => {
log::error!(target: "app", "traffic websocket error: {}", e);
None
}
}
});
return Some((traffic_stream, ()));
},
Ok(Err(e)) => {
log::error!(target: "app", "traffic websocket connection failed: {}", e);
},
Err(_) => {
log::error!(target: "app", "traffic websocket connection timed out");
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
})
.flatten(),
+17 -4
View File
@@ -261,16 +261,29 @@ pub async fn enhance() -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
use crate::utils::dirs;
use std::fs;
// 尝试读取dns_config.yaml
if let Ok(app_dir) = dirs::app_home_dir() {
let dns_path = app_dir.join("dns_config.yaml");
if dns_path.exists() {
if let Ok(dns_yaml) = fs::read_to_string(&dns_path) {
if let Ok(dns_config) = serde_yaml::from_str::<serde_yaml::Mapping>(&dns_yaml) {
// 将DNS配置合并到最终配置中
config.insert("dns".into(), dns_config.into());
log::info!(target: "app", "apply dns_config.yaml");
// 处理hosts配置
if let Some(hosts_value) = dns_config.get("hosts") {
if hosts_value.is_mapping() {
config.insert("hosts".into(), hosts_value.clone());
log::info!(target: "app", "apply hosts configuration");
}
}
if let Some(dns_value) = dns_config.get("dns") {
if let Some(dns_mapping) = dns_value.as_mapping() {
config.insert("dns".into(), dns_mapping.clone().into());
log::info!(target: "app", "apply dns_config.yaml (dns section)");
}
} else {
config.insert("dns".into(), dns_config.into());
log::info!(target: "app", "apply dns_config.yaml");
}
}
}
}
+24 -35
View File
@@ -3,6 +3,7 @@ use crate::{
core::{handle, tray, CoreManager},
logging_error,
module::mihomo::MihomoManager,
process::AsyncHandler,
utils::{logging::Type, resolve},
};
use serde_yaml::{Mapping, Value};
@@ -10,7 +11,7 @@ use tauri::Manager;
/// Restart the Clash core
pub fn restart_clash_core() {
tauri::async_runtime::spawn(async {
AsyncHandler::spawn(move || async move {
match CoreManager::global().restart_core().await {
Ok(_) => {
handle::Handle::refresh_clash();
@@ -26,19 +27,17 @@ pub fn restart_clash_core() {
/// Restart the application
pub fn restart_app() {
tauri::async_runtime::spawn_blocking(|| {
tauri::async_runtime::block_on(async {
logging_error!(Type::Core, true, CoreManager::global().stop_core().await);
resolve::resolve_reset_async().await;
let app_handle = handle::Handle::global().app_handle().unwrap();
std::thread::sleep(std::time::Duration::from_secs(1));
tauri::process::restart(&app_handle.env());
});
AsyncHandler::spawn(move || async move {
logging_error!(Type::Core, true, CoreManager::global().stop_core().await);
resolve::resolve_reset_async().await;
let app_handle = handle::Handle::global().app_handle().unwrap();
std::thread::sleep(std::time::Duration::from_secs(1));
tauri::process::restart(&app_handle.env());
});
}
fn after_change_clash_mode() {
tauri::async_runtime::spawn(async {
AsyncHandler::spawn(move || async {
match MihomoManager::global().get_connections().await {
Ok(connections) => {
if let Some(connections_array) = connections["connections"].as_array() {
@@ -64,7 +63,7 @@ pub fn change_clash_mode(mode: String) {
let json_value = serde_json::json!({
"mode": mode
});
tauri::async_runtime::spawn(async move {
AsyncHandler::spawn(move || async move {
log::debug!(target: "app", "change clash mode to {mode}");
match MihomoManager::global().patch_configs(json_value).await {
Ok(_) => {
@@ -92,36 +91,26 @@ pub fn change_clash_mode(mode: String) {
/// Test connection delay to a URL
pub async fn test_delay(url: String) -> anyhow::Result<u32> {
use tokio::time::{Duration, Instant};
let mut builder = reqwest::ClientBuilder::new().use_rustls_tls().no_proxy();
use crate::utils::network::{NetworkManager, ProxyType};
use tokio::time::Instant;
let port = Config::verge()
.latest()
.verge_mixed_port
.unwrap_or(Config::clash().data().get_mixed_port());
let tun_mode = Config::verge().latest().enable_tun_mode.unwrap_or(false);
let proxy_scheme = format!("http://127.0.0.1:{port}");
// 如果是TUN模式,不使用代理,否则使用自身代理
let proxy_type = if !tun_mode {
ProxyType::Localhost
} else {
ProxyType::None
};
if !tun_mode {
if let Ok(proxy) = reqwest::Proxy::http(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::https(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::all(&proxy_scheme) {
builder = builder.proxy(proxy);
}
}
let user_agent = Some("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0".to_string());
let request = builder
.timeout(Duration::from_millis(10000))
.build()?
.get(url).header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0");
let start = Instant::now();
let response = request.send().await;
let response = NetworkManager::global()
.get_with_interrupt(&url, proxy_type, Some(10), user_agent, false)
.await;
match response {
Ok(response) => {
log::trace!(target: "app", "test_delay response: {:#?}", response);
@@ -133,7 +122,7 @@ pub async fn test_delay(url: String) -> anyhow::Result<u32> {
}
Err(err) => {
log::trace!(target: "app", "test_delay error: {:#?}", err);
Err(err.into())
Err(err)
}
}
}
+76 -13
View File
@@ -2,12 +2,13 @@ use crate::{
cmd,
config::{Config, PrfItem, PrfOption},
core::{handle, CoreManager, *},
process::AsyncHandler,
};
use anyhow::{bail, Result};
/// Toggle proxy profile
pub fn toggle_proxy_profile(profile_index: String) {
tauri::async_runtime::spawn(async move {
AsyncHandler::spawn(|| async move {
let app_handle = handle::Handle::global().app_handle().unwrap();
match cmd::patch_profiles_config_by_profile_index(app_handle, profile_index).await {
Ok(_) => {
@@ -22,8 +23,14 @@ pub fn toggle_proxy_profile(profile_index: String) {
/// Update a profile
/// If updating current profile, activate it
pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()> {
/// auto_refresh: 是否自动更新配置和刷新前端
pub async fn update_profile(
uid: String,
option: Option<PrfOption>,
auto_refresh: Option<bool>,
) -> Result<()> {
println!("[订阅更新] 开始更新订阅 {}", uid);
let auto_refresh = auto_refresh.unwrap_or(true); // 默认为true,保持兼容性
let url_opt = {
let profiles = Config::profiles();
@@ -50,19 +57,75 @@ pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()
let should_update = match url_opt {
Some((url, opt)) => {
println!("[订阅更新] 开始下载新的订阅内容");
let merged_opt = PrfOption::merge(opt, option);
let item = PrfItem::from_url(&url, None, None, merged_opt).await?;
let merged_opt = PrfOption::merge(opt.clone(), option.clone());
println!("[订阅更新] 更新订阅配置");
let profiles = Config::profiles();
let mut profiles = profiles.latest();
profiles.update_item(uid.clone(), item)?;
// 尝试使用正常设置更新
match PrfItem::from_url(&url, None, None, merged_opt.clone()).await {
Ok(item) => {
println!("[订阅更新] 更新订阅配置成功");
let profiles = Config::profiles();
let mut profiles = profiles.latest();
profiles.update_item(uid.clone(), item)?;
let is_current = Some(uid.clone()) == profiles.get_current();
println!("[订阅更新] 是否为当前使用的订阅: {}", is_current);
is_current
let is_current = Some(uid.clone()) == profiles.get_current();
println!("[订阅更新] 是否为当前使用的订阅: {}", is_current);
is_current && auto_refresh
}
Err(err) => {
// 首次更新失败,尝试使用Clash代理
println!("[订阅更新] 正常更新失败: {},尝试使用Clash代理更新", err);
// 发送通知
handle::Handle::notice_message("update_retry_with_clash", uid.clone());
// 保存原始代理设置
let original_with_proxy = merged_opt.as_ref().and_then(|o| o.with_proxy);
let original_self_proxy = merged_opt.as_ref().and_then(|o| o.self_proxy);
// 创建使用Clash代理的选项
let mut fallback_opt = merged_opt.unwrap_or_default();
fallback_opt.with_proxy = Some(false);
fallback_opt.self_proxy = Some(true);
// 使用Clash代理重试
match PrfItem::from_url(&url, None, None, Some(fallback_opt)).await {
Ok(mut item) => {
println!("[订阅更新] 使用Clash代理更新成功");
// 恢复原始代理设置到item
if let Some(option) = item.option.as_mut() {
option.with_proxy = original_with_proxy;
option.self_proxy = original_self_proxy;
}
// 更新到配置
let profiles = Config::profiles();
let mut profiles = profiles.latest();
profiles.update_item(uid.clone(), item.clone())?;
// 获取配置名称用于通知
let profile_name = item.name.clone().unwrap_or_else(|| uid.clone());
// 发送通知告知用户自动更新使用了回退机制
handle::Handle::notice_message("update_with_clash_proxy", profile_name);
let is_current = Some(uid.clone()) == profiles.get_current();
println!("[订阅更新] 是否为当前使用的订阅: {}", is_current);
is_current && auto_refresh
}
Err(retry_err) => {
println!("[订阅更新] 使用Clash代理更新仍然失败: {}", retry_err);
handle::Handle::notice_message(
"update_failed_even_with_clash",
format!("{}", retry_err),
);
return Err(retry_err);
}
}
}
}
}
None => true,
None => auto_refresh,
};
if should_update {
@@ -74,7 +137,7 @@ pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()
}
Err(err) => {
println!("[订阅更新] 更新失败: {}", err);
handle::Handle::notice_message("set_config::error", format!("{err}"));
handle::Handle::notice_message("update_failed", format!("{err}"));
log::error!(target: "app", "{err}");
}
}
+25 -18
View File
@@ -1,6 +1,7 @@
use crate::{
config::{Config, IVerge},
core::handle,
process::AsyncHandler,
};
use std::env;
use tauri_plugin_clipboard_manager::ClipboardExt;
@@ -9,8 +10,22 @@ use tauri_plugin_clipboard_manager::ClipboardExt;
pub fn toggle_system_proxy() {
let enable = Config::verge().draft().enable_system_proxy;
let enable = enable.unwrap_or(false);
let auto_close_connection = Config::verge()
.data()
.auto_close_connection
.unwrap_or(false);
AsyncHandler::spawn(move || async move {
// 如果当前系统代理即将关闭,且自动关闭连接设置为true,则关闭所有连接
if enable && auto_close_connection {
if let Err(err) = crate::module::mihomo::MihomoManager::global()
.close_all_connections()
.await
{
log::error!(target: "app", "Failed to close all connections: {}", err);
}
}
tauri::async_runtime::spawn(async move {
match super::patch_verge(
IVerge {
enable_system_proxy: Some(!enable),
@@ -28,23 +43,10 @@ pub fn toggle_system_proxy() {
/// Toggle TUN mode on/off
pub fn toggle_tun_mode(not_save_file: Option<bool>) {
// tauri::async_runtime::spawn(async move {
// logging!(
// info,
// Type::Service,
// true,
// "Toggle TUN mode need install service"
// );
// if is_service_available().await.is_err() {
// logging_error!(Type::Service, true, install_service().await);
// }
// logging_error!(Type::Core, true, CoreManager::global().restart_core().await);
// });
let enable = Config::verge().data().enable_tun_mode;
let enable = enable.unwrap_or(false);
tauri::async_runtime::spawn(async move {
AsyncHandler::spawn(async move || {
match super::patch_verge(
IVerge {
enable_tun_mode: Some(!enable),
@@ -62,9 +64,14 @@ pub fn toggle_tun_mode(not_save_file: Option<bool>) {
/// Copy proxy environment variables to clipboard
pub fn copy_clash_env() {
// 从环境变量获取IP地址,默认127.0.0.1
let clash_verge_rev_ip =
env::var("CLASH_VERGE_REV_IP").unwrap_or_else(|_| "127.0.0.1".to_string());
// 从环境变量获取IP地址,如果没有则从配置中获取 proxy_host,默认为 127.0.0.1
let clash_verge_rev_ip = env::var("CLASH_VERGE_REV_IP").unwrap_or_else(|_| {
Config::verge()
.latest()
.proxy_host
.clone()
.unwrap_or_else(|| "127.0.0.1".to_string())
});
let app_handle = handle::Handle::global().app_handle().unwrap();
let port = { Config::verge().latest().verge_mixed_port.unwrap_or(7897) };
+56 -42
View File
@@ -50,7 +50,7 @@ pub fn open_or_close_dashboard() {
}
/// 优化的应用退出函数
pub fn quit(code: Option<i32>) {
pub fn quit() {
log::debug!(target: "app", "启动退出流程");
// 获取应用句柄并设置退出标志
@@ -64,56 +64,70 @@ pub fn quit(code: Option<i32>) {
// 在单独线程中处理资源清理,避免阻塞主线程
std::thread::spawn(move || {
// 使用tokio运行时执行异步清理任务
tauri::async_runtime::block_on(async {
// 使用超时机制处理清理操作
use tokio::time::{timeout, Duration};
// 1. 直接关闭TUN模式 (优先处理,通常最容易卡住)
if Config::verge().data().enable_tun_mode.unwrap_or(false) {
let disable = serde_json::json!({
"tun": {
"enable": false
}
});
// 设置1秒超时
let _ = timeout(
Duration::from_secs(1),
MihomoManager::global().patch_configs(disable),
)
.await;
}
// 2. 并行处理系统代理和核心进程清理
let proxy_future = timeout(
Duration::from_secs(1),
sysopt::Sysopt::global().reset_sysproxy(),
);
let core_future = timeout(Duration::from_secs(1), CoreManager::global().stop_core());
// 同时等待两个任务完成
let _ = futures::join!(proxy_future, core_future);
// 3. 处理macOS特定清理
#[cfg(target_os = "macos")]
{
let _ = timeout(Duration::from_millis(500), resolve::restore_public_dns()).await;
}
let cleanup_result = clean();
app_handle.exit(match cleanup_result {
true => 0,
false => 1,
});
// 无论清理结果如何,确保应用退出
app_handle.exit(code.unwrap_or(0));
});
}
pub fn clean() -> bool {
use tokio::time::{timeout, Duration};
let rt = tokio::runtime::Runtime::new().unwrap();
let cleanup_result = rt.block_on(async {
// 1. 处理TUN模式
let tun_success = if Config::verge().data().enable_tun_mode.unwrap_or(false) {
let disable_tun = serde_json::json!({
"tun": {
"enable": false
}
});
timeout(
Duration::from_secs(1),
MihomoManager::global().patch_configs(disable_tun),
)
.await
.is_ok()
} else {
true
};
// 2. 顺序执行关键清理
let proxy_res = timeout(
Duration::from_secs(1),
sysopt::Sysopt::global().reset_sysproxy(),
)
.await;
let core_res = timeout(Duration::from_secs(1), CoreManager::global().stop_core()).await;
// 3. 平台特定清理
#[cfg(target_os = "macos")]
let _dns_res = timeout(Duration::from_millis(500), resolve::restore_public_dns()).await;
tun_success && proxy_res.is_ok() && core_res.is_ok()
});
cleanup_result
}
#[cfg(target_os = "macos")]
pub fn hide() {
use crate::module::lightweight::add_light_weight_timer;
let enable_auto_light_weight_mode = Config::verge()
.data()
.enable_auto_light_weight_mode
.unwrap_or(false);
if enable_auto_light_weight_mode {
add_light_weight_timer();
}
if let Some(window) = handle::Handle::global().get_window() {
if window.is_visible().unwrap_or(false) {
AppHandleManager::global().set_activation_policy_accessory();
let _ = window.hide();
}
}
AppHandleManager::global().set_activation_policy_accessory();
}
+116 -47
View File
@@ -5,9 +5,11 @@ mod enhance;
mod error;
mod feat;
mod module;
mod process;
mod utils;
use crate::{
core::hotkey,
process::AsyncHandler,
utils::{resolve, resolve::resolve_scheme, server},
};
use config::Config;
@@ -17,6 +19,7 @@ use tauri::AppHandle;
use tauri::Manager;
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_deep_link::DeepLinkExt;
use tokio::time::{timeout, Duration};
use utils::logging::Type;
/// A global singleton handle to the application.
@@ -83,13 +86,32 @@ impl AppHandleManager {
#[allow(clippy::panic)]
pub fn run() {
utils::network::NetworkManager::global().init();
let _ = utils::dirs::init_portable_flag();
// 单例检测
let app_exists: bool = tauri::async_runtime::block_on(async move {
if server::check_singleton().await.is_err() {
println!("app exists");
true
} else {
false
let app_exists: bool = AsyncHandler::block_on(move || async move {
logging!(info, Type::Setup, true, "开始检查单例实例...");
match timeout(Duration::from_secs(3), server::check_singleton()).await {
Ok(result) => {
if result.is_err() {
logging!(info, Type::Setup, true, "检测到已有应用实例运行");
true
} else {
logging!(info, Type::Setup, true, "未检测到其他应用实例");
false
}
}
Err(_) => {
logging!(
warn,
Type::Setup,
true,
"单例检查超时,假定没有其他实例运行"
);
false
}
}
});
if app_exists {
@@ -101,13 +123,9 @@ pub fn run() {
#[cfg(debug_assertions)]
let devtools = tauri_plugin_devtools::init();
#[allow(unused_mut)]
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
None,
))
.plugin(tauri_plugin_window_state::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_process::init())
@@ -116,26 +134,78 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.setup(|app| {
logging!(info, Type::Setup, true, "开始应用初始化...");
let mut auto_start_plugin_builder = tauri_plugin_autostart::Builder::new();
#[cfg(target_os = "macos")]
{
auto_start_plugin_builder = auto_start_plugin_builder
.macos_launcher(MacosLauncher::LaunchAgent)
.app_name(app.config().identifier.clone());
}
let _ = app.handle().plugin(auto_start_plugin_builder.build());
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
use tauri_plugin_deep_link::DeepLinkExt;
logging!(info, Type::Setup, true, "注册深层链接...");
logging_error!(Type::System, true, app.deep_link().register_all());
}
app.deep_link().on_open_url(|event| {
tauri::async_runtime::spawn(async move {
if let Some(url) = event.urls().first() {
logging_error!(Type::Setup, true, resolve_scheme(url.to_string()).await);
AsyncHandler::spawn(move || {
let url = event.urls().first().map(|u| u.to_string());
async move {
if let Some(url) = url {
logging_error!(Type::Setup, true, resolve_scheme(url).await);
}
}
});
});
tauri::async_runtime::block_on(async move {
resolve::resolve_setup(app).await;
// 异步处理
let app_handle = app.handle().clone();
AsyncHandler::spawn(move || async move {
logging!(info, Type::Setup, true, "异步执行应用设置...");
match timeout(
Duration::from_secs(30),
resolve::resolve_setup_async(&app_handle),
)
.await
{
Ok(_) => {
logging!(info, Type::Setup, true, "应用设置成功完成");
}
Err(_) => {
logging!(
error,
Type::Setup,
true,
"应用设置超时(30秒),继续执行后续流程"
);
}
}
});
logging!(info, Type::Setup, true, "执行主要设置操作...");
logging!(info, Type::Setup, true, "初始化AppHandleManager...");
AppHandleManager::global().init(app.handle().clone());
logging!(info, Type::Setup, true, "初始化核心句柄...");
core::handle::Handle::global().init(app.handle());
logging!(info, Type::Setup, true, "初始化配置...");
if let Err(e) = utils::init::init_config() {
logging!(error, Type::Setup, true, "初始化配置失败: {}", e);
}
logging!(info, Type::Setup, true, "初始化资源...");
if let Err(e) = utils::init::init_resources() {
logging!(error, Type::Setup, true, "初始化资源失败: {}", e);
}
logging!(info, Type::Setup, true, "初始化完成,继续执行");
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -148,18 +218,29 @@ pub fn run() {
cmd::open_core_dir,
cmd::get_portable_flag,
cmd::get_network_interfaces,
cmd::restart_core,
cmd::get_system_hostname,
cmd::restart_app,
// 添加新的命令
// 内核管理
cmd::start_core,
cmd::stop_core,
cmd::restart_core,
// 启动命令
cmd::notify_ui_ready,
cmd::update_ui_stage,
cmd::reset_ui_ready_state,
cmd::get_running_mode,
cmd::get_app_uptime,
cmd::get_auto_launch_status,
cmd::is_admin,
// 添加轻量模式相关命令
cmd::entry_lightweight_mode,
cmd::exit_lightweight_mode,
// service 管理
cmd::install_service,
cmd::uninstall_service,
cmd::reinstall_service,
cmd::repair_service,
cmd::is_service_available,
// clash
cmd::get_clash_info,
cmd::patch_clash_config,
@@ -177,6 +258,7 @@ pub fn run() {
cmd::apply_dns_config,
cmd::check_dns_config_exists,
cmd::get_dns_config_content,
cmd::validate_dns_config,
// verge
cmd::get_verge_config,
cmd::patch_verge_config,
@@ -200,6 +282,7 @@ pub fn run() {
cmd::delete_profile,
cmd::read_profile_file,
cmd::save_profile_file,
cmd::get_next_update_time,
// script validation
cmd::script_validate_notice,
cmd::validate_script_file,
@@ -239,6 +322,7 @@ pub fn run() {
app.run(|app_handle, e| match e {
tauri::RunEvent::Ready | tauri::RunEvent::Resumed => {
logging!(info, Type::System, true, "应用就绪或恢复");
AppHandleManager::global().init(app_handle.clone());
#[cfg(target_os = "macos")]
{
@@ -246,6 +330,7 @@ pub fn run() {
.get_handle()
.get_webview_window("main")
{
logging!(info, Type::Window, true, "设置macOS窗口标题");
let _ = window.set_title("Clash Verge");
}
}
@@ -265,6 +350,13 @@ pub fn run() {
api.prevent_exit();
}
}
tauri::RunEvent::Exit => {
// avoid duplicate cleanup
if core::handle::Handle::global().is_exiting() {
return;
}
feat::clean();
}
tauri::RunEvent::WindowEvent { label, event, .. } => {
if label == "main" {
match event {
@@ -276,8 +368,11 @@ pub fn run() {
}
println!("closing window...");
api.prevent_close();
let window = core::handle::Handle::global().get_window().unwrap();
let _ = window.hide();
if let Some(window) = core::handle::Handle::global().get_window() {
let _ = window.hide();
} else {
logging!(warn, Type::Window, true, "尝试隐藏窗口但窗口不存在");
}
}
tauri::WindowEvent::Focused(true) => {
#[cfg(target_os = "macos")]
@@ -293,15 +388,6 @@ pub fn run() {
hotkey::Hotkey::global().register("CMD+W", "hide")
);
}
#[cfg(not(target_os = "macos"))]
{
logging_error!(
Type::Hotkey,
true,
hotkey::Hotkey::global().register("Control+Q", "quit")
);
};
{
let is_enable_global_hotkey = Config::verge()
.latest()
@@ -326,14 +412,6 @@ pub fn run() {
hotkey::Hotkey::global().unregister("CMD+W")
);
}
#[cfg(not(target_os = "macos"))]
{
logging_error!(
Type::Hotkey,
true,
hotkey::Hotkey::global().unregister("Control+Q")
);
};
{
let is_enable_global_hotkey = Config::verge()
.latest()
@@ -358,15 +436,6 @@ pub fn run() {
hotkey::Hotkey::global().unregister("CMD+W")
);
}
#[cfg(not(target_os = "macos"))]
{
logging_error!(
Type::Hotkey,
true,
hotkey::Hotkey::global().unregister("Control+Q")
);
};
}
_ => {}
}
+81 -6
View File
@@ -1,17 +1,49 @@
use anyhow::{Context, Result};
use delay_timer::prelude::TaskBuilder;
use tauri::{Listener, Manager};
use crate::{
config::Config,
core::{handle, timer::Timer},
log_err, logging,
log_err, logging, logging_error,
utils::logging::Type,
AppHandleManager,
};
use anyhow::{Context, Result};
use delay_timer::prelude::TaskBuilder;
use once_cell::sync::OnceCell;
use parking_lot::{Mutex, RwLock};
use std::{
sync::Arc,
time::{Duration, Instant},
};
use tauri::{Listener, Manager};
const LIGHT_WEIGHT_TASK_UID: &str = "light_weight_task";
// 轻量模式状态标志
static IS_LIGHTWEIGHT_MODE: OnceCell<Arc<RwLock<bool>>> = OnceCell::new();
// 添加一个锁来防止并发退出轻量模式
static EXIT_LOCK: OnceCell<Mutex<(bool, Instant)>> = OnceCell::new();
fn get_lightweight_mode() -> &'static Arc<RwLock<bool>> {
IS_LIGHTWEIGHT_MODE.get_or_init(|| Arc::new(RwLock::new(false)))
}
fn get_exit_lock() -> &'static Mutex<(bool, Instant)> {
EXIT_LOCK.get_or_init(|| Mutex::new((false, Instant::now())))
}
// 检查是否处于轻量模式
pub fn is_in_lightweight_mode() -> bool {
*get_lightweight_mode().read()
}
// 设置轻量模式状态
fn set_lightweight_mode(value: bool) {
let mut mode = get_lightweight_mode().write();
*mode = value;
logging!(info, Type::Lightweight, true, "轻量模式状态: {}", value);
}
pub fn enable_auto_light_weight_mode() {
Timer::global().init().unwrap();
logging!(info, Type::Lightweight, true, "开启自动轻量模式");
@@ -37,9 +69,53 @@ pub fn entry_lightweight_mode() {
AppHandleManager::global().set_activation_policy_accessory();
logging!(info, Type::Lightweight, true, "轻量模式已开启");
}
// 标记已进入轻量模式
set_lightweight_mode(true);
let _ = cancel_light_weight_timer();
}
// 添加从轻量模式恢复的函数
pub fn exit_lightweight_mode() {
// 获取锁,检查是否已经有退出操作在进行中
let mut exit_lock = get_exit_lock().lock();
let (is_exiting, last_exit_time) = *exit_lock;
let now = Instant::now();
// 如果已经有一个退出操作在进行,并且距离上次退出时间不超过2秒,跳过本次退出
if is_exiting && now.duration_since(last_exit_time) < Duration::from_secs(2) {
logging!(
warn,
Type::Lightweight,
true,
"已有退出轻量模式操作正在进行中,跳过本次请求"
);
return;
}
*exit_lock = (true, now);
// 确保当前确实处于轻量模式才执行退出操作
if !is_in_lightweight_mode() {
logging!(info, Type::Lightweight, true, "当前不在轻量模式,无需退出");
exit_lock.0 = false;
return;
}
// 标记退出轻量模式
set_lightweight_mode(false);
logging!(info, Type::Lightweight, true, "正在退出轻量模式");
// 重置UI就绪状态
crate::utils::resolve::reset_ui_ready();
// 释放锁
exit_lock.0 = false;
}
pub fn add_light_weight_timer() {
logging_error!(Type::Lightweight, setup_light_weight_timer());
}
fn setup_window_close_listener() -> u32 {
if let Some(window) = handle::Handle::global().get_window() {
let handler = window.listen("tauri://close-requested", move |_event| {
@@ -63,7 +139,6 @@ fn setup_webview_focus_listener() -> u32 {
logging!(
info,
Type::Lightweight,
true,
"监听到窗口获得焦点,取消轻量模式计时"
);
});
+84 -21
View File
@@ -1,43 +1,92 @@
use crate::config::Config;
use mihomo_api;
use once_cell::sync::{Lazy, OnceCell};
use std::sync::Mutex;
use once_cell::sync::Lazy;
use parking_lot::{Mutex, RwLock};
use std::time::{Duration, Instant};
use tauri::http::{HeaderMap, HeaderValue};
#[cfg(target_os = "macos")]
use tokio_tungstenite::tungstenite::http;
// 缓存的最大有效期(5秒)
const CACHE_TTL: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Rate {
pub up: u64,
pub down: u64,
}
// 缓存MihomoManager实例
struct MihomoCache {
manager: mihomo_api::MihomoManager,
created_at: Instant,
server: String,
}
// 使用RwLock替代Mutex,允许多个读取操作并发进行
pub struct MihomoManager {
mihomo: Mutex<OnceCell<mihomo_api::MihomoManager>>,
mihomo_cache: RwLock<Option<MihomoCache>>,
create_lock: Mutex<()>,
}
impl MihomoManager {
fn __global() -> &'static MihomoManager {
static INSTANCE: Lazy<MihomoManager> = Lazy::new(|| MihomoManager {
mihomo: Mutex::new(OnceCell::new()),
mihomo_cache: RwLock::new(None),
create_lock: Mutex::new(()),
});
&INSTANCE
}
pub fn global() -> mihomo_api::MihomoManager {
let instance = MihomoManager::__global();
let (current_server, headers) = MihomoManager::get_clash_client_info().unwrap();
let lock = instance.mihomo.lock().unwrap();
if let Some(mihomo) = lock.get() {
if mihomo.get_mihomo_server() == current_server {
return mihomo.clone();
// 尝试从缓存读取(只需读锁)
{
let cache = instance.mihomo_cache.read();
if let Some(cache_entry) = &*cache {
let (current_server, _) = MihomoManager::get_clash_client_info()
.unwrap_or_else(|| (String::new(), HeaderMap::new()));
// 检查缓存是否有效
if cache_entry.server == current_server
&& cache_entry.created_at.elapsed() < CACHE_TTL
{
return cache_entry.manager.clone();
}
}
}
lock.set(mihomo_api::MihomoManager::new(current_server, headers))
.ok();
lock.get().unwrap().clone()
// 缓存无效,获取创建锁
let _create_guard = instance.create_lock.lock();
// 再次检查缓存(双重检查锁定模式)
{
let cache = instance.mihomo_cache.read();
if let Some(cache_entry) = &*cache {
let (current_server, _) = MihomoManager::get_clash_client_info()
.unwrap_or_else(|| (String::new(), HeaderMap::new()));
if cache_entry.server == current_server
&& cache_entry.created_at.elapsed() < CACHE_TTL
{
return cache_entry.manager.clone();
}
}
}
// 创建新实例
let (current_server, headers) = MihomoManager::get_clash_client_info()
.unwrap_or_else(|| (String::new(), HeaderMap::new()));
let manager = mihomo_api::MihomoManager::new(current_server.clone(), headers);
// 更新缓存
{
let mut cache = instance.mihomo_cache.write();
*cache = Some(MihomoCache {
manager: manager.clone(),
created_at: Instant::now(),
server: current_server,
});
}
manager
}
}
@@ -54,17 +103,31 @@ impl MihomoManager {
Some((server, headers))
}
// 提供默认值的版本,避免在connection_info为None时panic
fn get_clash_client_info_or_default() -> (String, HeaderMap) {
Self::get_clash_client_info().unwrap_or_else(|| {
let mut headers = HeaderMap::new();
headers.insert("Content-Type", "application/json".parse().unwrap());
("http://127.0.0.1:9090".to_string(), headers)
})
}
#[cfg(target_os = "macos")]
pub fn get_traffic_ws_url() -> (String, HeaderValue) {
let (url, headers) = MihomoManager::get_clash_client_info().unwrap();
let (url, headers) = MihomoManager::get_clash_client_info_or_default();
let ws_url = url.replace("http://", "ws://") + "/traffic";
let auth = headers
.get("Authorization")
.unwrap()
.to_str()
.unwrap()
.to_string();
let token = http::header::HeaderValue::from_str(&auth).unwrap();
.map(|val| val.to_str().unwrap_or("").to_string())
.unwrap_or_default();
// 创建默认的空HeaderValue而不是使用unwrap_or_default
let token = match HeaderValue::from_str(&auth) {
Ok(v) => v,
Err(_) => HeaderValue::from_static(""),
};
(ws_url, token)
}
}
+1 -2
View File
@@ -33,8 +33,7 @@ impl PlatformSpecification {
let system_arch = System::cpu_arch();
let handler = handle::Handle::global().app_handle().unwrap();
let config = handler.config();
let verge_version = config.version.clone().unwrap_or("Null".into());
let verge_version = handler.package_info().version.to_string();
// 使用默认值避免在同步上下文中执行异步操作
let running_mode = "NotRunning".to_string();
+30
View File
@@ -0,0 +1,30 @@
use std::future::Future;
use tauri::{async_runtime, async_runtime::JoinHandle};
pub struct AsyncHandler;
impl AsyncHandler {
pub fn spawn<F, Fut>(f: F) -> JoinHandle<()>
where
F: FnOnce() -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
async_runtime::spawn(f())
}
pub fn spawn_blocking<F, R>(f: F) -> JoinHandle<R>
where
F: FnOnce() -> R + Send + 'static,
R: Send + 'static,
{
async_runtime::spawn_blocking(f)
}
pub fn block_on<F, Fut, R>(f: F) -> R
where
F: FnOnce() -> Fut,
Fut: Future<Output = R>,
{
async_runtime::block_on(f())
}
}
+2
View File
@@ -0,0 +1,2 @@
mod async_handler;
pub use async_handler::AsyncHandler;
+116
View File
@@ -0,0 +1,116 @@
#[cfg(target_os = "windows")]
use anyhow::{anyhow, Result};
#[cfg(target_os = "windows")]
use log::info;
#[cfg(target_os = "windows")]
use std::{fs, path::Path, path::PathBuf};
/// Windows 下的开机启动文件夹路径
#[cfg(target_os = "windows")]
pub fn get_startup_dir() -> Result<PathBuf> {
let appdata = std::env::var("APPDATA").map_err(|_| anyhow!("无法获取 APPDATA 环境变量"))?;
let startup_dir = Path::new(&appdata)
.join("Microsoft")
.join("Windows")
.join("Start Menu")
.join("Programs")
.join("Startup");
if !startup_dir.exists() {
return Err(anyhow!("Startup 目录不存在: {:?}", startup_dir));
}
Ok(startup_dir)
}
/// 获取当前可执行文件路径
#[cfg(target_os = "windows")]
pub fn get_exe_path() -> Result<PathBuf> {
let exe_path =
std::env::current_exe().map_err(|e| anyhow!("无法获取当前可执行文件路径: {}", e))?;
Ok(exe_path)
}
/// 创建快捷方式
#[cfg(target_os = "windows")]
pub fn create_shortcut() -> Result<()> {
let exe_path = get_exe_path()?;
let startup_dir = get_startup_dir()?;
let shortcut_path = startup_dir.join("Clash-Verge.lnk");
// 如果快捷方式已存在,直接返回成功
if shortcut_path.exists() {
info!(target: "app", "启动快捷方式已存在");
return Ok(());
}
// 使用 PowerShell 创建快捷方式
let powershell_command = format!(
"$WshShell = New-Object -ComObject WScript.Shell; \
$Shortcut = $WshShell.CreateShortcut('{}'); \
$Shortcut.TargetPath = '{}'; \
$Shortcut.Save()",
shortcut_path.to_string_lossy().replace("\\", "\\\\"),
exe_path.to_string_lossy().replace("\\", "\\\\")
);
let output = std::process::Command::new("powershell")
.args(["-Command", &powershell_command])
.output()
.map_err(|e| anyhow!("执行 PowerShell 命令失败: {}", e))?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("创建快捷方式失败: {}", error_msg));
}
info!(target: "app", "成功创建启动快捷方式");
Ok(())
}
/// 删除快捷方式
#[cfg(target_os = "windows")]
pub fn remove_shortcut() -> Result<()> {
let startup_dir = get_startup_dir()?;
let shortcut_path = startup_dir.join("Clash-Verge.lnk");
// 如果快捷方式不存在,直接返回成功
if !shortcut_path.exists() {
info!(target: "app", "启动快捷方式不存在,无需删除");
return Ok(());
}
// 删除快捷方式
fs::remove_file(&shortcut_path).map_err(|e| anyhow!("删除快捷方式失败: {}", e))?;
info!(target: "app", "成功删除启动快捷方式");
Ok(())
}
/// 检查快捷方式是否存在
#[cfg(target_os = "windows")]
pub fn is_shortcut_enabled() -> Result<bool> {
let startup_dir = get_startup_dir()?;
let shortcut_path = startup_dir.join("Clash-Verge.lnk");
Ok(shortcut_path.exists())
}
// 非 Windows 平台使用的空方法
// #[cfg(not(target_os = "windows"))]
// pub fn create_shortcut() -> Result<()> {
// Ok(())
// }
// #[cfg(not(target_os = "windows"))]
// pub fn remove_shortcut() -> Result<()> {
// Ok(())
// }
// #[cfg(not(target_os = "windows"))]
// pub fn is_shortcut_enabled() -> Result<bool> {
// Ok(false)
// }
+69 -6
View File
@@ -49,12 +49,60 @@ pub fn app_home_dir() -> Result<PathBuf> {
.ok_or(anyhow::anyhow!("failed to get the portable app dir"))?;
return Ok(PathBuf::from(app_dir).join(".config").join(APP_ID));
}
let app_handle = handle::Handle::global().app_handle().unwrap();
// 避免在Handle未初始化时崩溃
let app_handle = match handle::Handle::global().app_handle() {
Some(handle) => handle,
None => {
log::warn!(target: "app", "app_handle not initialized, using default path");
// 使用可执行文件目录作为备用
let exe_path = tauri::utils::platform::current_exe()?;
let exe_dir = exe_path
.parent()
.ok_or(anyhow::anyhow!("failed to get executable directory"))?;
// 使用系统临时目录 + 应用ID
#[cfg(target_os = "windows")]
{
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
let path = PathBuf::from(local_app_data).join(APP_ID);
return Ok(path);
}
}
#[cfg(target_os = "macos")]
{
if let Some(home) = std::env::var_os("HOME") {
let path = PathBuf::from(home)
.join("Library")
.join("Application Support")
.join(APP_ID);
return Ok(path);
}
}
#[cfg(target_os = "linux")]
{
if let Some(home) = std::env::var_os("HOME") {
let path = PathBuf::from(home)
.join(".local")
.join("share")
.join(APP_ID);
return Ok(path);
}
}
// 如果无法获取系统目录,则回退到可执行文件目录
let fallback_dir = PathBuf::from(exe_dir).join(".config").join(APP_ID);
log::warn!(target: "app", "Using fallback data directory: {:?}", fallback_dir);
return Ok(fallback_dir);
}
};
match app_handle.path().data_dir() {
Ok(dir) => Ok(dir.join(APP_ID)),
Err(e) => {
log::error!(target:"app", "Failed to get the app home directory: {}", e);
log::error!(target: "app", "Failed to get the app home directory: {}", e);
Err(anyhow::anyhow!("Failed to get the app homedirectory"))
}
}
@@ -62,11 +110,24 @@ pub fn app_home_dir() -> Result<PathBuf> {
/// get the resources dir
pub fn app_resources_dir() -> Result<PathBuf> {
let app_handle = handle::Handle::global().app_handle().unwrap();
// 避免在Handle未初始化时崩溃
let app_handle = match handle::Handle::global().app_handle() {
Some(handle) => handle,
None => {
log::warn!(target: "app", "app_handle not initialized in app_resources_dir, using fallback");
// 使用可执行文件目录作为备用
let exe_dir = tauri::utils::platform::current_exe()?
.parent()
.ok_or(anyhow::anyhow!("failed to get executable directory"))?
.to_path_buf();
return Ok(exe_dir.join("resources"));
}
};
match app_handle.path().resource_dir() {
Ok(dir) => Ok(dir.join("resources")),
Err(e) => {
log::error!(target:"app", "Failed to get the resource directory: {}", e);
log::error!(target: "app", "Failed to get the resource directory: {}", e);
Err(anyhow::anyhow!("Failed to get the resource directory"))
}
}
@@ -126,12 +187,14 @@ pub fn profiles_path() -> Result<PathBuf> {
#[cfg(target_os = "macos")]
pub fn service_path() -> Result<PathBuf> {
Ok(app_resources_dir()?.join("clash-verge-service"))
let res_dir = app_resources_dir()?;
Ok(res_dir.join("clash-verge-service"))
}
#[cfg(windows)]
pub fn service_path() -> Result<PathBuf> {
Ok(app_resources_dir()?.join("clash-verge-service.exe"))
let res_dir = app_resources_dir()?;
Ok(res_dir.join("clash-verge-service.exe"))
}
pub fn service_log_file() -> Result<PathBuf> {
-40
View File
@@ -1,40 +0,0 @@
use crate::log_err;
use anyhow;
use std::{
backtrace::{Backtrace, BacktraceStatus},
thread,
};
pub fn redirect_panic_to_log() {
std::panic::set_hook(Box::new(move |panic_info| {
let thread = thread::current();
let thread_name = thread.name().unwrap_or("<unnamed>");
let payload = panic_info.payload();
let payload = if let Some(s) = payload.downcast_ref::<&str>() {
&**s
} else if let Some(s) = payload.downcast_ref::<String>() {
s
} else {
&format!("{:?}", payload)
};
let location = panic_info
.location()
.map(|l| l.to_string())
.unwrap_or("unknown location".to_string());
let backtrace = Backtrace::capture();
let backtrace = if backtrace.status() == BacktraceStatus::Captured {
&format!("stack backtrace:\n{}", backtrace)
} else {
"note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace"
};
let err: Result<(), anyhow::Error> = Err(anyhow::anyhow!(format!(
"thread '{}' panicked at {}:\n{}\n{}",
thread_name, location, payload, backtrace
)));
log_err!(err);
}));
}
+10 -2
View File
@@ -142,8 +142,8 @@ pub fn delete_log() -> Result<()> {
fn init_dns_config() -> Result<()> {
use serde_yaml::Value;
// 获取默认DNS配置
let default_dns_config = serde_yaml::Mapping::from_iter([
// 创建DNS子配置
let dns_config = serde_yaml::Mapping::from_iter([
("enable".into(), Value::Bool(true)),
("listen".into(), Value::String(":53".into())),
("enhanced-mode".into(), Value::String("fake-ip".into())),
@@ -180,6 +180,8 @@ fn init_dns_config() -> Result<()> {
Value::String("system".into()),
Value::String("223.6.6.6".into()),
Value::String("8.8.8.8".into()),
Value::String("2400:3200::1".into()),
Value::String("2001:4860:4860::8888".into()),
]),
),
(
@@ -229,6 +231,12 @@ fn init_dns_config() -> Result<()> {
),
]);
// 获取默认DNS和host配置
let default_dns_config = serde_yaml::Mapping::from_iter([
("dns".into(), Value::Mapping(dns_config)),
("hosts".into(), Value::Mapping(serde_yaml::Mapping::new())),
]);
// 检查DNS配置文件是否存在
let app_dir = dirs::app_home_dir()?;
let dns_path = app_dir.join("dns_config.yaml");
+4
View File
@@ -15,6 +15,8 @@ pub enum Type {
Frontend,
Backup,
Lightweight,
Network,
ProxyMode,
}
impl fmt::Display for Type {
@@ -33,6 +35,8 @@ impl fmt::Display for Type {
Type::Frontend => write!(f, "[Frontend]"),
Type::Backup => write!(f, "[Backup]"),
Type::Lightweight => write!(f, "[Lightweight]"),
Type::Network => write!(f, "[Network]"),
Type::ProxyMode => write!(f, "[ProxMode]"),
}
}
}
+2 -1
View File
@@ -1,9 +1,10 @@
pub mod autostart;
pub mod dirs;
pub mod error;
pub mod help;
pub mod i18n;
pub mod init;
pub mod logging;
pub mod network;
pub mod resolve;
pub mod server;
pub mod tmpl;
+426
View File
@@ -0,0 +1,426 @@
use anyhow::Result;
use lazy_static::lazy_static;
use reqwest::{Client, ClientBuilder, Proxy, RequestBuilder, Response};
use std::{
sync::{Arc, Mutex, Once},
time::{Duration, Instant},
};
use tokio::runtime::{Builder, Runtime};
use crate::{config::Config, logging, utils::logging::Type};
// HTTP2 相关
const H2_CONNECTION_WINDOW_SIZE: u32 = 1024 * 1024;
const H2_STREAM_WINDOW_SIZE: u32 = 1024 * 1024;
const H2_MAX_FRAME_SIZE: u32 = 16 * 1024;
const H2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(5);
const H2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(5);
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const POOL_MAX_IDLE_PER_HOST: usize = 5;
const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(15);
/// 网络管理器
pub struct NetworkManager {
runtime: Arc<Runtime>,
self_proxy_client: Arc<Mutex<Option<Client>>>,
system_proxy_client: Arc<Mutex<Option<Client>>>,
no_proxy_client: Arc<Mutex<Option<Client>>>,
init: Once,
last_connection_error: Arc<Mutex<Option<(Instant, String)>>>,
connection_error_count: Arc<Mutex<usize>>,
}
lazy_static! {
static ref NETWORK_MANAGER: NetworkManager = NetworkManager::new();
}
impl NetworkManager {
fn new() -> Self {
// 创建专用的异步运行时,线程数限制为4个
let runtime = Builder::new_multi_thread()
.worker_threads(4)
.thread_name("clash-verge-network")
.enable_io()
.enable_time()
.build()
.expect("Failed to create network runtime");
NetworkManager {
runtime: Arc::new(runtime),
self_proxy_client: Arc::new(Mutex::new(None)),
system_proxy_client: Arc::new(Mutex::new(None)),
no_proxy_client: Arc::new(Mutex::new(None)),
init: Once::new(),
last_connection_error: Arc::new(Mutex::new(None)),
connection_error_count: Arc::new(Mutex::new(0)),
}
}
pub fn global() -> &'static Self {
&NETWORK_MANAGER
}
/// 初始化网络客户端
pub fn init(&self) {
self.init.call_once(|| {
self.runtime.spawn(async {
logging!(info, Type::Network, true, "初始化网络管理器");
// 创建无代理客户端
let no_proxy_client = ClientBuilder::new()
.use_rustls_tls()
.no_proxy()
.pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
.pool_idle_timeout(POOL_IDLE_TIMEOUT)
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to build no_proxy client");
let mut no_proxy_guard = NETWORK_MANAGER.no_proxy_client.lock().unwrap();
*no_proxy_guard = Some(no_proxy_client);
logging!(info, Type::Network, true, "网络管理器初始化完成");
});
});
}
fn record_connection_error(&self, error: &str) {
let mut last_error = self.last_connection_error.lock().unwrap();
*last_error = Some((Instant::now(), error.to_string()));
let mut error_count = self.connection_error_count.lock().unwrap();
*error_count += 1;
}
fn should_reset_clients(&self) -> bool {
let error_count = *self.connection_error_count.lock().unwrap();
let last_error = self.last_connection_error.lock().unwrap();
if error_count > 5 {
return true;
}
if let Some((time, _)) = *last_error {
if time.elapsed() < Duration::from_secs(30) && error_count > 2 {
return true;
}
}
false
}
pub fn reset_clients(&self) {
logging!(info, Type::Network, true, "正在重置所有HTTP客户端");
{
let mut client = self.self_proxy_client.lock().unwrap();
*client = None;
}
{
let mut client = self.system_proxy_client.lock().unwrap();
*client = None;
}
{
let mut client = self.no_proxy_client.lock().unwrap();
*client = None;
}
{
let mut error_count = self.connection_error_count.lock().unwrap();
*error_count = 0;
}
}
/*
/// 获取或创建自代理客户端
fn get_or_create_self_proxy_client(&self) -> Client {
if self.should_reset_clients() {
self.reset_clients();
}
let mut client_guard = self.self_proxy_client.lock().unwrap();
if client_guard.is_none() {
let port = Config::verge()
.latest()
.verge_mixed_port
.unwrap_or(Config::clash().data().get_mixed_port());
let proxy_scheme = format!("http://127.0.0.1:{port}");
let mut builder = ClientBuilder::new()
.use_rustls_tls()
.pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
.pool_idle_timeout(POOL_IDLE_TIMEOUT)
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
.timeout(DEFAULT_REQUEST_TIMEOUT)
.http2_initial_stream_window_size(H2_STREAM_WINDOW_SIZE)
.http2_initial_connection_window_size(H2_CONNECTION_WINDOW_SIZE)
.http2_adaptive_window(true)
.http2_keep_alive_interval(Some(H2_KEEP_ALIVE_INTERVAL))
.http2_keep_alive_timeout(H2_KEEP_ALIVE_TIMEOUT)
.http2_max_frame_size(H2_MAX_FRAME_SIZE)
.tcp_keepalive(Some(Duration::from_secs(10)))
.http2_max_header_list_size(16 * 1024);
if let Ok(proxy) = Proxy::http(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = Proxy::https(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = Proxy::all(&proxy_scheme) {
builder = builder.proxy(proxy);
}
let client = builder.build().expect("Failed to build self_proxy client");
*client_guard = Some(client);
}
client_guard.as_ref().unwrap().clone()
}
/// 获取或创建系统代理客户端
fn get_or_create_system_proxy_client(&self) -> Client {
if self.should_reset_clients() {
self.reset_clients();
}
let mut client_guard = self.system_proxy_client.lock().unwrap();
if client_guard.is_none() {
use sysproxy::Sysproxy;
let mut builder = ClientBuilder::new()
.use_rustls_tls()
.pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
.pool_idle_timeout(POOL_IDLE_TIMEOUT)
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
.timeout(DEFAULT_REQUEST_TIMEOUT)
.http2_initial_stream_window_size(H2_STREAM_WINDOW_SIZE)
.http2_initial_connection_window_size(H2_CONNECTION_WINDOW_SIZE)
.http2_adaptive_window(true)
.http2_keep_alive_interval(Some(H2_KEEP_ALIVE_INTERVAL))
.http2_keep_alive_timeout(H2_KEEP_ALIVE_TIMEOUT)
.http2_max_frame_size(H2_MAX_FRAME_SIZE)
.tcp_keepalive(Some(Duration::from_secs(10)))
.http2_max_header_list_size(16 * 1024);
if let Ok(p @ Sysproxy { enable: true, .. }) = Sysproxy::get_system_proxy() {
let proxy_scheme = format!("http://{}:{}", p.host, p.port);
if let Ok(proxy) = Proxy::http(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = Proxy::https(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = Proxy::all(&proxy_scheme) {
builder = builder.proxy(proxy);
}
}
let client = builder
.build()
.expect("Failed to build system_proxy client");
*client_guard = Some(client);
}
client_guard.as_ref().unwrap().clone()
}
/// 根据代理设置选择合适的客户端
pub fn get_client(&self, proxy_type: ProxyType) -> Client {
match proxy_type {
ProxyType::NoProxy => {
let client_guard = self.no_proxy_client.lock().unwrap();
client_guard.as_ref().unwrap().clone()
}
ProxyType::SelfProxy => self.get_or_create_self_proxy_client(),
ProxyType::SystemProxy => self.get_or_create_system_proxy_client(),
}
}
*/
/// 创建带有自定义选项的HTTP请求
pub fn create_request(
&self,
url: &str,
proxy_type: ProxyType,
timeout_secs: Option<u64>,
user_agent: Option<String>,
accept_invalid_certs: bool,
) -> RequestBuilder {
if self.should_reset_clients() {
self.reset_clients();
}
let mut builder = ClientBuilder::new()
.use_rustls_tls()
.pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
.pool_idle_timeout(POOL_IDLE_TIMEOUT)
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
.http2_initial_stream_window_size(H2_STREAM_WINDOW_SIZE)
.http2_initial_connection_window_size(H2_CONNECTION_WINDOW_SIZE)
.http2_adaptive_window(true)
.http2_keep_alive_interval(Some(H2_KEEP_ALIVE_INTERVAL))
.http2_keep_alive_timeout(H2_KEEP_ALIVE_TIMEOUT)
.http2_max_frame_size(H2_MAX_FRAME_SIZE)
.tcp_keepalive(Some(Duration::from_secs(10)))
.http2_max_header_list_size(16 * 1024);
if let Some(timeout) = timeout_secs {
builder = builder.timeout(Duration::from_secs(timeout));
} else {
builder = builder.timeout(DEFAULT_REQUEST_TIMEOUT);
}
match proxy_type {
ProxyType::None => {
builder = builder.no_proxy();
}
ProxyType::Localhost => {
let port = Config::verge()
.latest()
.verge_mixed_port
.unwrap_or(Config::clash().data().get_mixed_port());
let proxy_scheme = format!("http://127.0.0.1:{port}");
if let Ok(proxy) = Proxy::http(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = Proxy::https(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = Proxy::all(&proxy_scheme) {
builder = builder.proxy(proxy);
}
}
ProxyType::System => {
use sysproxy::Sysproxy;
if let Ok(p @ Sysproxy { enable: true, .. }) = Sysproxy::get_system_proxy() {
let proxy_scheme = format!("http://{}:{}", p.host, p.port);
if let Ok(proxy) = Proxy::http(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = Proxy::https(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = Proxy::all(&proxy_scheme) {
builder = builder.proxy(proxy);
}
}
}
}
builder = builder.danger_accept_invalid_certs(accept_invalid_certs);
if let Some(ua) = user_agent {
builder = builder.user_agent(ua);
} else {
use crate::utils::resolve::VERSION;
let version = match VERSION.get() {
Some(v) => format!("clash-verge/v{}", v),
None => "clash-verge/unknown".to_string(),
};
builder = builder.user_agent(version);
}
let client = builder.build().expect("Failed to build custom HTTP client");
client.get(url)
}
/* /// 执行GET请求,添加错误跟踪
pub async fn get(
&self,
url: &str,
proxy_type: ProxyType,
timeout_secs: Option<u64>,
user_agent: Option<String>,
accept_invalid_certs: bool,
) -> Result<Response> {
let request = self.create_request(
url,
proxy_type,
timeout_secs,
user_agent,
accept_invalid_certs,
);
let timeout_duration = timeout_secs.unwrap_or(30);
match tokio::time::timeout(Duration::from_secs(timeout_duration), request.send()).await {
Ok(result) => match result {
Ok(response) => Ok(response),
Err(e) => {
self.record_connection_error(&e.to_string());
Err(anyhow::anyhow!("Failed to send HTTP request: {}", e))
}
},
Err(_) => {
self.record_connection_error("Request timeout");
Err(anyhow::anyhow!(
"HTTP request timed out after {} seconds",
timeout_duration
))
}
}
} */
pub async fn get_with_interrupt(
&self,
url: &str,
proxy_type: ProxyType,
timeout_secs: Option<u64>,
user_agent: Option<String>,
accept_invalid_certs: bool,
) -> Result<Response> {
let request = self.create_request(
url,
proxy_type,
timeout_secs,
user_agent,
accept_invalid_certs,
);
let timeout_duration = timeout_secs.unwrap_or(20);
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
let url_clone = url.to_string();
let watchdog = tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(timeout_duration)).await;
let _ = cancel_tx.send(());
logging!(warn, Type::Network, true, "请求超时取消: {}", url_clone);
});
let result = tokio::select! {
result = request.send() => result,
_ = cancel_rx => {
self.record_connection_error(&format!("Request interrupted for: {}", url));
return Err(anyhow::anyhow!("Request interrupted after {} seconds", timeout_duration));
}
};
watchdog.abort();
match result {
Ok(response) => Ok(response),
Err(e) => {
self.record_connection_error(&e.to_string());
Err(anyhow::anyhow!("Failed to send HTTP request: {}", e))
}
}
}
}
/// 代理类型
#[derive(Debug, Clone, Copy)]
pub enum ProxyType {
None,
Localhost,
System,
}
+303 -137
View File
@@ -1,19 +1,23 @@
#[cfg(target_os = "macos")]
use crate::AppHandleManager;
use crate::{
config::{Config, IVerge, PrfItem},
core::*,
logging, logging_error,
module::lightweight,
utils::{error, init, logging::Type, server},
process::AsyncHandler,
utils::{init, logging::Type, server},
wrap_err,
};
use anyhow::{bail, Result};
use once_cell::sync::OnceCell;
use parking_lot::{Mutex, RwLock};
use percent_encoding::percent_decode_str;
use serde_yaml::Mapping;
use std::net::TcpListener;
use tauri::{App, Manager};
use std::{
sync::Arc,
time::{Duration, Instant},
};
use tauri::{AppHandle, Manager};
use tokio::net::TcpListener;
use tauri::Url;
//#[cfg(not(target_os = "linux"))]
@@ -21,8 +25,106 @@ use tauri::Url;
pub static VERSION: OnceCell<String> = OnceCell::new();
pub fn find_unused_port() -> Result<u16> {
match TcpListener::bind("127.0.0.1:0") {
// 定义默认窗口尺寸常量
const DEFAULT_WIDTH: u32 = 940;
const DEFAULT_HEIGHT: u32 = 700;
// 添加全局UI准备就绪标志
static UI_READY: OnceCell<Arc<RwLock<bool>>> = OnceCell::new();
// 窗口创建锁,防止并发创建窗口
static WINDOW_CREATING: OnceCell<Mutex<(bool, Instant)>> = OnceCell::new();
// UI就绪阶段状态枚举
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum UiReadyStage {
NotStarted,
Loading,
DomReady,
ResourcesLoaded,
Ready,
}
// UI就绪详细状态
#[derive(Debug)]
struct UiReadyState {
stage: RwLock<UiReadyStage>,
last_update: RwLock<Instant>,
}
impl Default for UiReadyState {
fn default() -> Self {
Self {
stage: RwLock::new(UiReadyStage::NotStarted),
last_update: RwLock::new(Instant::now()),
}
}
}
// 获取UI就绪状态细节
static UI_READY_STATE: OnceCell<Arc<UiReadyState>> = OnceCell::new();
fn get_window_creating_lock() -> &'static Mutex<(bool, Instant)> {
WINDOW_CREATING.get_or_init(|| Mutex::new((false, Instant::now())))
}
fn get_ui_ready() -> &'static Arc<RwLock<bool>> {
UI_READY.get_or_init(|| Arc::new(RwLock::new(false)))
}
fn get_ui_ready_state() -> &'static Arc<UiReadyState> {
UI_READY_STATE.get_or_init(|| Arc::new(UiReadyState::default()))
}
// 更新UI准备阶段
pub fn update_ui_ready_stage(stage: UiReadyStage) {
let state = get_ui_ready_state();
let mut stage_lock = state.stage.write();
let mut time_lock = state.last_update.write();
*stage_lock = stage;
*time_lock = Instant::now();
logging!(
info,
Type::Window,
true,
"UI准备阶段更新: {:?}, 耗时: {:?}ms",
stage,
time_lock.elapsed().as_millis()
);
// 如果是最终阶段,标记UI完全就绪
if stage == UiReadyStage::Ready {
mark_ui_ready();
}
}
// 标记UI已准备就绪
pub fn mark_ui_ready() {
let mut ready = get_ui_ready().write();
*ready = true;
logging!(info, Type::Window, true, "UI已标记为完全就绪");
}
// 重置UI就绪状态
pub fn reset_ui_ready() {
{
let mut ready = get_ui_ready().write();
*ready = false;
}
{
let state = get_ui_ready_state();
let mut stage = state.stage.write();
let mut time = state.last_update.write();
*stage = UiReadyStage::NotStarted;
*time = Instant::now();
}
logging!(info, Type::Window, true, "UI就绪状态已重置");
}
pub async fn find_unused_port() -> Result<u16> {
match TcpListener::bind("127.0.0.1:0").await {
Ok(listener) => {
let port = listener.local_addr()?.port();
Ok(port)
@@ -38,40 +140,61 @@ pub fn find_unused_port() -> Result<u16> {
}
}
/// handle something when start app
pub async fn resolve_setup(app: &mut App) {
error::redirect_panic_to_log();
#[cfg(target_os = "macos")]
{
AppHandleManager::global().init(app.app_handle().clone());
AppHandleManager::global().set_activation_policy_accessory();
/// 异步方式处理启动后的额外任务
pub async fn resolve_setup_async(app_handle: &AppHandle) {
logging!(info, Type::Setup, true, "执行异步设置任务...");
if VERSION.get().is_none() {
let version = app_handle.package_info().version.to_string();
VERSION.get_or_init(|| {
logging!(info, Type::Setup, true, "初始化版本信息: {}", version);
version.clone()
});
}
let version = app.package_info().version.to_string();
handle::Handle::global().init(app.app_handle());
VERSION.get_or_init(|| version.clone());
logging_error!(Type::Config, true, init::init_config());
logging_error!(Type::Setup, true, init::init_resources());
logging_error!(Type::Setup, true, init::init_scheme());
logging_error!(Type::Setup, true, init::startup_script().await);
// 处理随机端口
logging_error!(Type::System, true, resolve_random_port_config());
// 启动核心
logging!(trace, Type::Config, true, "Initial config");
if let Err(err) = resolve_random_port_config().await {
logging!(
error,
Type::System,
true,
"Failed to resolve random port config: {}",
err
);
}
logging!(trace, Type::Config, true, "初始化配置...");
logging_error!(Type::Config, true, Config::init_config().await);
logging!(trace, Type::Core, "Starting CoreManager");
logging!(trace, Type::Core, true, "启动核心管理器...");
logging_error!(Type::Core, true, CoreManager::global().init().await);
// setup a simple http server for singleton
log::trace!(target: "app", "launch embed server");
log::trace!(target: "app", "启动内嵌服务器...");
server::embed_server();
log::trace!(target: "app", "Initial system tray");
logging_error!(Type::Tray, true, tray::Tray::global().init());
logging_error!(Type::Tray, true, tray::Tray::global().create_systray(app));
if let Some(app_handle) = handle::Handle::global().app_handle() {
logging!(info, Type::Tray, true, "创建系统托盘...");
let result = tray::Tray::global().create_tray_from_handle(&app_handle);
if result.is_ok() {
logging!(info, Type::Tray, true, "系统托盘创建成功");
} else if let Err(e) = result {
logging!(error, Type::Tray, true, "系统托盘创建失败: {}", e);
}
} else {
logging!(
error,
Type::Tray,
true,
"无法创建系统托盘: app_handle不存在"
);
}
// 更新系统代理
logging_error!(
Type::System,
true,
@@ -83,24 +206,29 @@ pub async fn resolve_setup(app: &mut App) {
sysopt::Sysopt::global().init_guard_sysproxy()
);
// 创建窗口
let is_silent_start = { Config::verge().data().enable_silent_start }.unwrap_or(false);
create_window(!is_silent_start);
// 初始化定时器
logging_error!(Type::System, true, timer::Timer::global().init());
let enable_auto_light_weight_mode = { Config::verge().data().enable_auto_light_weight_mode };
if enable_auto_light_weight_mode.unwrap_or(false) {
// 自动进入轻量模式
let enable_auto_light_weight_mode =
{ Config::verge().data().enable_auto_light_weight_mode }.unwrap_or(false);
if enable_auto_light_weight_mode && !is_silent_start {
lightweight::enable_auto_light_weight_mode();
}
logging_error!(Type::Tray, true, tray::Tray::global().update_part());
// 初始化热键
logging!(trace, Type::System, true, "Initial hotkeys");
logging!(trace, Type::System, true, "初始化热键...");
logging_error!(Type::System, true, hotkey::Hotkey::global().init());
logging!(info, Type::Setup, true, "异步设置任务完成");
}
/// reset system proxy (异步版)
/// reset system proxy (异步)
pub async fn resolve_reset_async() {
#[cfg(target_os = "macos")]
logging!(info, Type::Tray, true, "Unsubscribing from traffic updates");
@@ -120,122 +248,148 @@ pub async fn resolve_reset_async() {
}
}
/// create main window
pub fn create_window(is_showup: bool) {
logging!(info, Type::Window, true, "Creating window");
/// Create the main window
pub fn create_window(is_show: bool) -> bool {
logging!(
info,
Type::Window,
true,
"开始创建/显示主窗口, is_show={}",
is_show
);
let app_handle = handle::Handle::global().app_handle().unwrap();
#[cfg(target_os = "macos")]
AppHandleManager::global().set_activation_policy_regular();
if let Some(app_handle) = handle::Handle::global().app_handle() {
if let Some(window) = app_handle.get_webview_window("main") {
logging!(info, Type::Window, true, "主窗口已存在,将显示现有窗口");
if is_show {
let _ = window.show();
let _ = window.set_focus();
}
return true;
}
}
if let Some(window) = handle::Handle::global().get_window() {
let creating_lock = get_window_creating_lock();
let mut creating = creating_lock.lock();
let (is_creating, last_time) = *creating;
let elapsed = last_time.elapsed();
if is_creating && elapsed < Duration::from_secs(2) {
logging!(
info,
Type::Window,
true,
"Found existing window, attempting to restore visibility"
"窗口创建请求被忽略,因为最近创建过 ({:?}ms)",
elapsed.as_millis()
);
if window.is_minimized().unwrap_or(false) {
logging!(
info,
Type::Window,
true,
"Window is minimized, restoring window state"
);
let _ = window.unminimize();
}
let _ = window.show();
let _ = window.set_focus();
return;
return false;
}
logging!(info, Type::Window, true, "Creating new application window");
*creating = (true, Instant::now());
#[cfg(target_os = "windows")]
let window = tauri::WebviewWindowBuilder::new(
&app_handle,
"main".to_string(),
tauri::WebviewUrl::App("index.html".into()),
)
.title("Clash Verge")
.inner_size(890.0, 700.0)
.min_inner_size(620.0, 550.0)
.decorations(false)
.maximizable(true)
.additional_browser_args("--enable-features=msWebView2EnableDraggableRegions --disable-features=OverscrollHistoryNavigation,msExperimentalScrolling")
.transparent(true)
.shadow(true)
.build();
#[cfg(target_os = "macos")]
let window = tauri::WebviewWindowBuilder::new(
&app_handle,
"main".to_string(),
tauri::WebviewUrl::App("index.html".into()),
)
.decorations(true)
.hidden_title(true)
.title_bar_style(tauri::TitleBarStyle::Overlay)
.inner_size(890.0, 700.0)
.min_inner_size(620.0, 550.0)
.build();
#[cfg(target_os = "linux")]
let window = tauri::WebviewWindowBuilder::new(
&app_handle,
"main".to_string(),
match tauri::WebviewWindowBuilder::new(
&handle::Handle::global().app_handle().unwrap(),
"main", /* the unique window label */
tauri::WebviewUrl::App("index.html".into()),
)
.title("Clash Verge")
.decorations(false)
.inner_size(890.0, 700.0)
.min_inner_size(620.0, 550.0)
.transparent(true)
.build();
.center()
.decorations(true)
.fullscreen(false)
.inner_size(DEFAULT_WIDTH as f64, DEFAULT_HEIGHT as f64)
.min_inner_size(520.0, 520.0)
.visible(false)
.build()
{
Ok(newly_created_window) => {
logging!(debug, Type::Window, true, "主窗口实例创建成功");
match window {
Ok(window) => {
logging!(info, Type::Window, true, "Window created successfully");
if is_showup {
println!("is showup");
let _ = window.show();
let _ = window.set_focus();
} else {
let _ = window.hide();
#[cfg(target_os = "macos")]
AppHandleManager::global().set_activation_policy_accessory();
}
*creating = (false, Instant::now());
// 设置窗口状态监控,实时保存窗口位置和大小
// crate::feat::setup_window_state_monitor(&app_handle);
// 标记前端UI已准备就绪,向前端发送启动完成事件
let app_handle_clone = app_handle.clone();
tauri::async_runtime::spawn(async move {
use tauri::Emitter;
update_ui_ready_stage(UiReadyStage::NotStarted);
AsyncHandler::spawn(move || async move {
handle::Handle::global().mark_startup_completed();
logging!(
info,
debug,
Type::Window,
true,
"标记前端UI已准备就绪,开始处理启动错误队列"
"异步窗口任务开始 (启动已标记完成)"
);
handle::Handle::global().mark_startup_completed();
if let Some(window) = app_handle_clone.get_webview_window("main") {
let _ = window.emit("verge://startup-completed", ());
if is_show {
let window_clone = newly_created_window.clone();
// Attempt to show and focus the window first.
let _ = window_clone.show();
let _ = window_clone.set_focus();
logging!(debug, Type::Window, true, "窗口已尝试显示和聚焦");
tokio::time::sleep(Duration::from_millis(100)).await; // Crucial delay
logging!(
debug,
Type::Window,
true,
"延时后,尝试发送 verge://startup-completed 事件"
);
handle::Handle::notify_startup_completed();
let timeout_seconds = if crate::module::lightweight::is_in_lightweight_mode() {
2
} else {
5
};
logging!(
info,
Type::Window,
true,
"等待UI就绪 (最多{}秒)...",
timeout_seconds
);
let wait_result =
tokio::time::timeout(Duration::from_secs(timeout_seconds), async {
while !*get_ui_ready().read() {
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
.await;
match wait_result {
Ok(_) => {
logging!(info, Type::Window, true, "UI就绪");
}
Err(_) => {
logging!(
warn,
Type::Window,
true,
"等待UI就绪超时({}秒),强制标记就绪",
timeout_seconds
);
*get_ui_ready().write() = true;
}
}
logging!(info, Type::Window, true, "窗口显示流程完成");
} else {
logging!(
debug,
Type::Window,
true,
"is_show为false,窗口保持隐藏。尝试发送启动事件。"
);
handle::Handle::notify_startup_completed();
}
});
true
}
Err(e) => {
logging!(
error,
Type::Window,
true,
"Failed to create window: {:?}",
e
);
logging!(error, Type::Window, true, "主窗口构建失败: {}", e);
*creating = (false, Instant::now()); // Reset the creating state if window creation failed
false
}
}
}
@@ -265,7 +419,6 @@ pub async fn resolve_scheme(param: String) -> Result<()> {
.find(|(key, _)| key == "name")
.map(|(_, value)| value.into_owned());
// 通过直接获取查询部分并解析特定参数来避免 URL 转义问题
let url_param = if let Some(query) = link_parsed.query() {
let prefix = "url=";
if let Some(pos) = query.find(prefix) {
@@ -301,7 +454,7 @@ pub async fn resolve_scheme(param: String) -> Result<()> {
Ok(())
}
fn resolve_random_port_config() -> Result<()> {
async fn resolve_random_port_config() -> Result<()> {
let verge_config = Config::verge();
let clash_config = Config::clash();
let enable_random_port = verge_config.latest().enable_random_port.unwrap_or(false);
@@ -312,21 +465,34 @@ fn resolve_random_port_config() -> Result<()> {
.unwrap_or(clash_config.data().get_mixed_port());
let port = if enable_random_port {
find_unused_port().unwrap_or(default_port)
find_unused_port().await.unwrap_or(default_port)
} else {
default_port
};
verge_config.data().patch_config(IVerge {
verge_mixed_port: Some(port),
..IVerge::default()
});
verge_config.data().save_file()?;
let port_to_save = port;
tokio::task::spawn_blocking(move || {
let verge_config_accessor = Config::verge();
let mut verge_data = verge_config_accessor.data();
verge_data.patch_config(IVerge {
verge_mixed_port: Some(port_to_save),
..IVerge::default()
});
verge_data.save_file()
})
.await??; // First ? for spawn_blocking error, second for save_file Result
tokio::task::spawn_blocking(move || {
let clash_config_accessor = Config::clash(); // Extend lifetime of the accessor
let mut clash_data = clash_config_accessor.data(); // Access within blocking task, made mutable
let mut mapping = Mapping::new();
mapping.insert("mixed-port".into(), port_to_save.into());
clash_data.patch_config(mapping);
clash_data.save_config()
})
.await??;
let mut mapping = Mapping::new();
mapping.insert("mixed-port".into(), port.into());
clash_config.data().patch_config(mapping);
clash_config.data().save_config()?;
Ok(())
}
+2 -1
View File
@@ -4,6 +4,7 @@ use super::resolve;
use crate::{
config::{Config, IVerge, DEFAULT_PAC},
logging_error,
process::AsyncHandler,
utils::logging::Type,
};
use anyhow::{bail, Result};
@@ -47,7 +48,7 @@ pub async fn check_singleton() -> Result<()> {
pub fn embed_server() {
let port = IVerge::get_singleton_port();
tauri::async_runtime::spawn(async move {
AsyncHandler::spawn(move || async move {
let visible = warp::path!("commands" / "visible").map(move || {
resolve::create_window(false);
"ok"
-3
View File
@@ -15,9 +15,6 @@ pub const ITEM_MERGE: &str = "# Profile Enhancement Merge Template for Clash Ver
profile:
store-selected: true
dns:
use-system-hosts: false
";
pub const ITEM_MERGE_EMPTY: &str = "# Profile Enhancement Merge Template for Clash Verge
@@ -0,0 +1,2 @@
# LOCAL_SOCK="/Users/tunglies/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev/mihomo.sock"
LOCAL_SOCK="/Users/tunglies/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/mihomo.sock"
@@ -9,6 +9,6 @@ debug = []
reqwest = { version = "0.12.15", features = ["json"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
tokio = { version = "1.44.1", features = ["rt", "macros", "time"] }
[dev-dependencies]
tokio = { version = "1.44.1", features = ["rt", "macros"] }
@@ -9,23 +9,32 @@ pub use model::{MihomoData, MihomoManager};
impl MihomoManager {
pub fn new(mihomo_server: String, headers: HeaderMap) -> Self {
let client = reqwest::ClientBuilder::new()
.default_headers(headers)
.no_proxy()
.timeout(Duration::from_secs(15))
.pool_max_idle_per_host(5)
.pool_idle_timeout(Duration::from_secs(15))
.build()
.expect("Failed to build reqwest client");
Self {
mihomo_server,
data: Arc::new(Mutex::new(MihomoData {
proxies: serde_json::Value::Null,
providers_proxies: serde_json::Value::Null,
})),
headers,
client,
}
}
fn update_proxies(&self, proxies: serde_json::Value) {
let mut data = self.data.lock().unwrap();
let mut data = self.data.lock().expect("Mutex poisoned");
data.proxies = proxies;
}
fn update_providers_proxies(&self, providers_proxies: serde_json::Value) {
let mut data = self.data.lock().unwrap();
let mut data = self.data.lock().expect("Mutex poisoned");
data.providers_proxies = providers_proxies;
}
@@ -34,12 +43,12 @@ impl MihomoManager {
}
pub fn get_proxies(&self) -> serde_json::Value {
let data = self.data.lock().unwrap();
let data = self.data.lock().expect("Mutex poisoned");
data.proxies.clone()
}
pub fn get_providers_proxies(&self) -> serde_json::Value {
let data = self.data.lock().unwrap();
let data = self.data.lock().expect("Mutex poisoned");
data.providers_proxies.clone()
}
@@ -49,12 +58,8 @@ impl MihomoManager {
url: String,
data: Option<serde_json::Value>,
) -> Result<serde_json::Value, String> {
let client_response = reqwest::ClientBuilder::new()
.default_headers(self.headers.clone())
.no_proxy()
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| e.to_string())?
let client_response = self
.client
.request(method.clone(), &url)
.json(&data.unwrap_or(json!({})))
.send()
@@ -95,6 +100,19 @@ impl MihomoManager {
self.update_providers_proxies(providers_proxies);
Ok(self)
}
pub async fn close_all_connections(&self) -> Result<(), String> {
let url = format!("{}/connections", self.mihomo_server);
let response = self.send_request(Method::DELETE, url, None).await?;
if response["code"] == 204 {
Ok(())
} else {
Err(response["message"]
.as_str()
.unwrap_or("unknown error")
.to_string())
}
}
}
impl MihomoManager {
@@ -132,7 +150,7 @@ impl MihomoManager {
test_url: Option<String>,
timeout: i32,
) -> Result<serde_json::Value, String> {
let test_url = test_url.unwrap_or("http://cp.cloudflare.com/generate_204".to_string());
let test_url = test_url.unwrap_or("https://cp.cloudflare.com/generate_204".to_string());
let url = format!(
"{}/proxies/{}/delay?url={}&timeout={}",
self.mihomo_server, name, test_url, timeout
@@ -1,5 +1,4 @@
use std::sync::{Arc, Mutex};
use reqwest::header::HeaderMap;
use std::sync::{Arc, Mutex};
pub struct MihomoData {
pub(crate) proxies: serde_json::Value,
@@ -10,7 +9,7 @@ pub struct MihomoData {
pub struct MihomoManager {
pub(crate) mihomo_server: String,
pub(crate) data: Arc<Mutex<MihomoData>>,
pub(crate) headers: HeaderMap,
pub(crate) client: reqwest::Client,
}
#[cfg(feature = "debug")]
@@ -25,5 +24,4 @@ impl Drop for MihomoManager {
fn drop(&mut self) {
println!("Dropping MihomoManager");
}
}
}
@@ -26,4 +26,4 @@ async fn test_refresh_providers_proxies() {
let providers = manager.get_providers_proxies();
assert_eq!(proxies, serde_json::Value::Null);
assert_ne!(providers, serde_json::Value::Null);
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "2.2.3",
"version": "2.3.0",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"active": true,
+1 -2
View File
@@ -1,5 +1,4 @@
import { AppDataProvider } from "./providers/app-data-provider";
import React from "react";
import Layout from "./pages/_layout";
function App() {
@@ -10,4 +9,4 @@ function App() {
);
}
export default App;
export default App;
+4 -4
View File
@@ -129,14 +129,14 @@
flex: 1 0 58px;
margin-top: 10px;
margin-left: 10px;
padding-top: 30px;
padding-top: 5px;
padding-left: 10px;
padding-right: 20px;
padding-bottom: 16px;
}
.layout__right .the-content {
top: 24px;
top: 5px;
}
}
}
@@ -144,11 +144,11 @@
.macos {
&.layout {
.layout__left {
padding-top: 24px;
padding-top: 5px;
}
.layout__right .the-content {
top: 24px;
top: 5px;
}
.layout__left .the-newbtn {
+71
View File
@@ -0,0 +1,71 @@
import React, { useState, useEffect } from 'react';
import { Snackbar, Alert, IconButton, Box } from '@mui/material';
import { CloseRounded } from '@mui/icons-material';
import { subscribeNotices, hideNotice, NoticeItem } from '@/services/noticeService';
export const NoticeManager: React.FC = () => {
const [currentNotices, setCurrentNotices] = useState<NoticeItem[]>([]);
useEffect(() => {
const unsubscribe = subscribeNotices((notices) => {
setCurrentNotices(notices);
});
return () => {
unsubscribe();
};
}, []);
const handleClose = (id: number) => {
hideNotice(id);
};
return (
<Box
sx={{
position: 'fixed',
top: '20px',
right: '20px',
zIndex: 1500,
display: 'flex',
flexDirection: 'column',
gap: '10px',
maxWidth: '360px',
}}
>
{currentNotices.map((notice) => (
<Snackbar
key={notice.id}
open={true}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{
position: 'relative',
transform: 'none',
top: 'auto',
right: 'auto',
bottom: 'auto',
left: 'auto',
width: '100%',
}}
>
<Alert
severity={notice.type}
variant="filled"
sx={{ width: '100%' }}
action={
<IconButton
size="small"
color="inherit"
onClick={() => handleClose(notice.id)}
>
<CloseRounded fontSize="inherit" />
</IconButton>
}
>
{notice.message}
</Alert>
</Snackbar>
))}
</Box>
);
};
-161
View File
@@ -1,161 +0,0 @@
import { createRoot } from "react-dom/client";
import { ReactNode, useState, useEffect } from "react";
import { Box, IconButton, Slide, Snackbar, Typography } from "@mui/material";
import {
CloseRounded,
CheckCircleRounded,
ErrorRounded,
} from "@mui/icons-material";
import { useVerge } from "@/hooks/use-verge";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
const appWindow = getCurrentWebviewWindow();
interface InnerProps {
type: string;
duration?: number;
message: ReactNode;
isDark?: boolean;
onClose: () => void;
}
const NoticeInner = (props: InnerProps) => {
const { type, message, duration, onClose } = props;
const [visible, setVisible] = useState(true);
const [isDark, setIsDark] = useState(false);
const { verge } = useVerge();
const { theme_mode } = verge ?? {};
const onBtnClose = () => {
setVisible(false);
onClose();
};
const onAutoClose = (_e: any, reason: string) => {
if (reason !== "clickaway") onBtnClose();
};
useEffect(() => {
const themeMode = ["light", "dark", "system"].includes(theme_mode!)
? theme_mode!
: "light";
if (themeMode !== "system") {
setIsDark(themeMode === "dark");
return;
}
appWindow.theme().then((m) => m && setIsDark(m === "dark"));
const unlisten = appWindow.onThemeChanged((e) =>
setIsDark(e.payload === "dark"),
);
return () => {
unlisten.then((fn) => fn());
};
}, [theme_mode]);
const msgElement =
type === "info" ? (
message
) : (
<Box sx={{ width: 328, display: "flex", alignItems: "center" }}>
{type === "error" && <ErrorRounded color="error" />}
{type === "success" && <CheckCircleRounded color="success" />}
<Typography
component="span"
sx={{ ml: 1, wordWrap: "break-word", width: "calc(100% - 35px)" }}
>
{message}
</Typography>
</Box>
);
return (
<Snackbar
open={visible}
anchorOrigin={{ vertical: "top", horizontal: "right" }}
autoHideDuration={duration === -1 ? null : duration || 1500}
onClose={onAutoClose}
message={msgElement}
sx={{
maxWidth: 360,
".MuiSnackbarContent-root": {
bgcolor: isDark ? "#50515C" : "#ffffff",
color: isDark ? "#ffffff" : "#000000",
},
}}
TransitionComponent={(p) => <Slide {...p} direction="left" />}
transitionDuration={200}
action={
<IconButton size="small" color="inherit" onClick={onBtnClose}>
<CloseRounded fontSize="inherit" />
</IconButton>
}
/>
);
};
interface NoticeInstance {
(props: Omit<InnerProps, "onClose">): void;
info(message: ReactNode, duration?: number, isDark?: boolean): void;
error(message: ReactNode, duration?: number, isDark?: boolean): void;
success(message: ReactNode, duration?: number, isDark?: boolean): void;
}
let parent: HTMLDivElement = null!;
export const Notice: NoticeInstance = (props) => {
const { type, message, duration } = props;
// 验证必要的参数
if (!message) {
return;
}
if (!parent) {
parent = document.createElement("div");
parent.setAttribute("id", "notice-container"); // 添加 id 便于调试
document.body.appendChild(parent);
}
const container = document.createElement("div");
parent.appendChild(container);
const root = createRoot(container);
const onUnmount = () => {
root.unmount();
if (parent && container.parentNode === parent) {
setTimeout(() => {
parent.removeChild(container);
}, 500);
}
};
root.render(
<NoticeInner
type={type}
message={message}
duration={duration || 1500}
onClose={onUnmount}
/>,
);
};
const createNoticeTypeFactory =
(type: keyof NoticeInstance) => (message: ReactNode, duration?: number) => {
// 确保消息不为空
if (!message) {
return;
}
Notice({
type,
message,
// 错误类型通知显示 8 秒,其他类型默认 1.5 秒
duration: type === "error" ? 8000 : duration || 1500,
});
};
Notice.info = createNoticeTypeFactory("info");
Notice.error = createNoticeTypeFactory("error");
Notice.success = createNoticeTypeFactory("success");
+40 -38
View File
@@ -118,44 +118,46 @@ export const BaseSearchBox = (props: SearchProps) => {
placeholder={props.placeholder ?? t("Filter conditions")}
sx={{ input: { py: 0.65, px: 1.25 } }}
onChange={onChange}
InputProps={{
sx: { pr: 1 },
endAdornment: (
<Box display="flex">
<Tooltip title={t("Match Case")}>
<div>
<SvgIcon
component={matchCaseIcon}
{...iconStyle}
aria-label={matchCase ? "active" : "inactive"}
onClick={() => setMatchCase(!matchCase)}
/>
</div>
</Tooltip>
<Tooltip title={t("Match Whole Word")}>
<div>
<SvgIcon
component={matchWholeWordIcon}
{...iconStyle}
aria-label={matchWholeWord ? "active" : "inactive"}
onClick={() => setMatchWholeWord(!matchWholeWord)}
/>
</div>
</Tooltip>
<Tooltip title={t("Use Regular Expression")}>
<div>
<SvgIcon
component={useRegularExpressionIcon}
aria-label={useRegularExpression ? "active" : "inactive"}
{...iconStyle}
onClick={() =>
setUseRegularExpression(!useRegularExpression)
}
/>{" "}
</div>
</Tooltip>
</Box>
),
slotProps={{
input: {
sx: { pr: 1 },
endAdornment: (
<Box display="flex">
<Tooltip title={t("Match Case")}>
<div>
<SvgIcon
component={matchCaseIcon}
{...iconStyle}
aria-label={matchCase ? "active" : "inactive"}
onClick={() => setMatchCase(!matchCase)}
/>
</div>
</Tooltip>
<Tooltip title={t("Match Whole Word")}>
<div>
<SvgIcon
component={matchWholeWordIcon}
{...iconStyle}
aria-label={matchWholeWord ? "active" : "inactive"}
onClick={() => setMatchWholeWord(!matchWholeWord)}
/>
</div>
</Tooltip>
<Tooltip title={t("Use Regular Expression")}>
<div>
<SvgIcon
component={useRegularExpressionIcon}
aria-label={useRegularExpression ? "active" : "inactive"}
{...iconStyle}
onClick={() =>
setUseRegularExpression(!useRegularExpression)
}
/>{" "}
</div>
</Tooltip>
</Box>
),
}
}}
/>
</Tooltip>
+1 -1
View File
@@ -3,6 +3,6 @@ export { BasePage } from "./base-page";
export { BaseEmpty } from "./base-empty";
export { BaseLoading } from "./base-loading";
export { BaseErrorBoundary } from "./base-error-boundary";
export { Notice } from "./base-notice";
export { Switch } from "./base-switch";
export { BaseLoadingOverlay } from "./base-loading-overlay";
export { NoticeManager } from "./NoticeManager";
+12 -12
View File
@@ -15,7 +15,7 @@ import { useAppData } from "@/providers/app-data-provider";
export const ClashModeCard = () => {
const { t } = useTranslation();
const { verge } = useVerge();
const { clashConfig, refreshProxy } = useAppData();
const { clashConfig, refreshClashConfig } = useAppData();
// 支持的模式列表
const modeList = useMemo(() => ["rule", "global", "direct"] as const, []);
@@ -40,7 +40,7 @@ export const ClashModeCard = () => {
try {
await patchClashMode(mode);
// 使用共享的刷新方法
refreshProxy();
refreshClashConfig();
} catch (error) {
console.error("Failed to change mode:", error);
}
@@ -70,15 +70,15 @@ export const ClashModeCard = () => {
},
"&::after": mode === currentMode
? {
content: '""',
position: "absolute",
bottom: -16,
left: "50%",
width: 2,
height: 16,
bgcolor: "primary.main",
transform: "translateX(-50%)",
}
content: '""',
position: "absolute",
bottom: -16,
left: "50%",
width: 2,
height: 16,
bgcolor: "primary.main",
transform: "translateX(-50%)",
}
: {},
});
@@ -148,7 +148,7 @@ export const ClashModeCard = () => {
component="div"
sx={descriptionStyles}
>
{t(`${currentMode} Mode Description`)}
{t(`${currentMode?.charAt(0).toUpperCase()}${currentMode?.slice(1)} Mode Description`)}
</Typography>
</Box>
</Box>
+159 -65
View File
@@ -12,6 +12,7 @@ import {
InputLabel,
SelectChangeEvent,
Tooltip,
IconButton,
} from "@mui/material";
import { useEffect, useState, useMemo, useCallback } from "react";
import {
@@ -22,6 +23,9 @@ import {
SignalWifi0Bar as SignalNone,
WifiOff as SignalError,
ChevronRight,
SortRounded,
AccessTimeRounded,
SortByAlphaRounded,
} from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
import { EnhancedCard } from "@/components/home/enhanced-card";
@@ -33,18 +37,20 @@ import { useAppData } from "@/providers/app-data-provider";
// 本地存储的键名
const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group";
const STORAGE_KEY_PROXY = "clash-verge-selected-proxy";
const STORAGE_KEY_SORT_TYPE = "clash-verge-proxy-sort-type";
// 代理节点信息接口
interface ProxyOption {
name: string;
}
// 将delayManager返回的颜色格式转换为MUI Chip组件需要的格式
// 排序类型: 默认 | 按延迟 | 按字母
export type ProxySortType = 0 | 1 | 2;
function convertDelayColor(delayValue: number) {
const colorStr = delayManager.formatDelayColor(delayValue);
if (!colorStr) return "default";
// 从"error.main"这样的格式转为"error"
const mainColor = colorStr.split(".")[0];
switch (mainColor) {
@@ -61,7 +67,6 @@ function convertDelayColor(delayValue: number) {
}
}
// 根据延迟值获取合适的WiFi信号图标
function getSignalIcon(delay: number) {
if (delay < 0)
return { icon: <SignalNone />, text: "未测试", color: "text.secondary" };
@@ -96,7 +101,13 @@ export const CurrentProxyCard = () => {
const mode = clashConfig?.mode?.toLowerCase() || "rule";
const isGlobalMode = mode === "global";
const isDirectMode = mode === "direct";
// 添加排序类型状态
const [sortType, setSortType] = useState<ProxySortType>(() => {
const savedSortType = localStorage.getItem(STORAGE_KEY_SORT_TYPE);
return savedSortType ? Number(savedSortType) as ProxySortType : 0;
});
// 定义状态类型
type ProxyState = {
proxyData: {
@@ -112,13 +123,12 @@ export const CurrentProxyCard = () => {
displayProxy: any;
};
// 合并状态,减少状态更新次数
const [state, setState] = useState<ProxyState>({
proxyData: {
groups: [],
records: {},
globalProxy: "",
directProxy: null,
directProxy: { name: "DIRECT" }, // 默认值避免 undefined
},
selection: {
group: "",
@@ -130,12 +140,10 @@ export const CurrentProxyCard = () => {
// 初始化选择的组
useEffect(() => {
if (!proxies) return;
// 提取primaryGroupName
const getPrimaryGroupName = () => {
if (!proxies?.groups?.length) return "";
// 查找主要的代理组(优先级:包含关键词 > 第一个非GLOBAL组)
const primaryKeywords = [
"auto",
"select",
@@ -152,9 +160,9 @@ export const CurrentProxyCard = () => {
return primaryGroup?.name || "";
};
const primaryGroupName = getPrimaryGroupName();
// 根据模式确定初始组
if (isGlobalMode) {
setState((prev) => ({
@@ -187,18 +195,17 @@ export const CurrentProxyCard = () => {
// 监听代理数据变化,更新状态
useEffect(() => {
if (!proxies) return;
// 使用函数式更新确保状态更新的原子性
setState((prev) => {
// 过滤和格式化组
// 只保留 Selector 类型的组用于选择
const filteredGroups = proxies.groups
.filter((g: { name: string }) => g.name !== "DIRECT" && g.name !== "REJECT")
.filter((g: { name: string; type?: string }) => g.type === "Selector")
.map((g: { name: string; now: string; all: Array<{ name: string }> }) => ({
name: g.name,
now: g.now || "",
all: g.all.map((p: { name: string }) => p.name),
}));
let newProxy = "";
let newDisplayProxy = null;
let newGroup = prev.selection.group;
@@ -207,34 +214,33 @@ export const CurrentProxyCard = () => {
if (isDirectMode) {
newGroup = "DIRECT";
newProxy = "DIRECT";
newDisplayProxy = proxies.records?.DIRECT || null;
newDisplayProxy = proxies.records?.DIRECT || { name: "DIRECT" }; // 确保非空
} else if (isGlobalMode && proxies.global) {
newGroup = "GLOBAL";
newProxy = proxies.global.now || "";
newDisplayProxy = proxies.records?.[newProxy] || null;
} else {
// 普通模式 - 检查当前选择的组是否存在
const currentGroup = filteredGroups.find(
(g: { name: string }) => g.name === prev.selection.group,
);
// 如果当前组不存在或为空,自动选择第一个组
// 如果当前组不存在或为空,自动选择第一个 selector 类型的组
if (!currentGroup && filteredGroups.length > 0) {
newGroup = filteredGroups[0].name;
const firstGroup = filteredGroups[0];
newProxy = firstGroup.now;
const selectorGroup = filteredGroups[0];
if (selectorGroup) {
newGroup = selectorGroup.name;
newProxy = selectorGroup.now || selectorGroup.all[0] || "";
newDisplayProxy = proxies.records?.[newProxy] || null;
// 保存到本地存储
if (!isGlobalMode && !isDirectMode) {
localStorage.setItem(STORAGE_KEY_GROUP, newGroup);
if (newProxy) {
localStorage.setItem(STORAGE_KEY_PROXY, newProxy);
}
}
}
} else if (currentGroup) {
// 使用当前组的代理
newProxy = currentGroup.now;
newProxy = currentGroup.now || currentGroup.all[0] || "";
newDisplayProxy = proxies.records?.[newProxy] || null;
}
}
@@ -245,7 +251,7 @@ export const CurrentProxyCard = () => {
groups: filteredGroups,
records: proxies.records || {},
globalProxy: proxies.global?.now || "",
directProxy: proxies.records?.DIRECT || null,
directProxy: proxies.records?.DIRECT || { name: "DIRECT" },
},
selection: {
group: newGroup,
@@ -256,7 +262,7 @@ export const CurrentProxyCard = () => {
});
}, [proxies, isGlobalMode, isDirectMode]);
// 使用防抖包装状态更新,避免快速连续更新,增加防抖时间
// 使用防抖包装状态更新
const debouncedSetState = useCallback(
debounce((updateFn: (prev: ProxyState) => ProxyState) => {
setState(updateFn);
@@ -271,10 +277,8 @@ export const CurrentProxyCard = () => {
const newGroup = event.target.value;
// 保存到本地存储
localStorage.setItem(STORAGE_KEY_GROUP, newGroup);
// 获取该组当前选中的代理
setState((prev) => {
const group = prev.proxyData.groups.find((g: { name: string }) => g.name === newGroup);
if (group) {
@@ -308,7 +312,6 @@ export const CurrentProxyCard = () => {
const currentGroup = state.selection.group;
const previousProxy = state.selection.proxy;
// 立即更新UI,优化体验
debouncedSetState((prev: ProxyState) => ({
...prev,
selection: {
@@ -318,13 +321,11 @@ export const CurrentProxyCard = () => {
displayProxy: prev.proxyData.records[newProxy] || null,
}));
// 非特殊模式下保存到本地存储
if (!isGlobalMode && !isDirectMode) {
localStorage.setItem(STORAGE_KEY_PROXY, newProxy);
}
try {
// 更新代理设置
await updateProxy(currentGroup, newProxy);
// 自动关闭连接设置
@@ -363,17 +364,18 @@ export const CurrentProxyCard = () => {
// 获取要显示的代理节点
const currentProxy = useMemo(() => {
// 从state中获取当前代理信息
return state.displayProxy;
}, [state.displayProxy]);
// 获取当前节点的延迟
const currentDelay = currentProxy
// 获取当前节点的延迟(增加非空校验)
const currentDelay = currentProxy && state.selection.group
? delayManager.getDelayFix(currentProxy, state.selection.group)
: -1;
// 获取信号图标
const signalInfo = getSignalIcon(currentDelay);
// 信号图标(增加非空校验)
const signalInfo = currentProxy && state.selection.group
? getSignalIcon(currentDelay)
: { icon: <SignalNone />, text: "未初始化", color: "text.secondary" };
// 自定义渲染选择框中的值
const renderProxyValue = useCallback(
@@ -382,7 +384,7 @@ export const CurrentProxyCard = () => {
const delayValue = delayManager.getDelayFix(
state.proxyData.records[selected],
state.selection.group,
state.selection.group
);
return (
@@ -399,27 +401,105 @@ export const CurrentProxyCard = () => {
[state.proxyData.records, state.selection.group],
);
// 计算要显示的代理选项 - 使用 useMemo 优化
// 排序类型变更
const handleSortTypeChange = useCallback(() => {
const newSortType = ((sortType + 1) % 3) as ProxySortType;
setSortType(newSortType);
localStorage.setItem(STORAGE_KEY_SORT_TYPE, newSortType.toString());
}, [sortType]);
// 排序代理函数(增加非空校验)
const sortProxies = useCallback(
(proxies: ProxyOption[]) => {
if (!proxies || sortType === 0) return proxies;
// 确保数据存在
if (!state.proxyData.records || !state.selection.group) return proxies;
const list = [...proxies];
if (sortType === 1) {
list.sort((a, b) => {
const recordA = state.proxyData.records[a.name];
const recordB = state.proxyData.records[b.name];
// 处理 record 不存在的情况
if (!recordA) return 1;
if (!recordB) return -1;
const ad = delayManager.getDelayFix(recordA, state.selection.group);
const bd = delayManager.getDelayFix(recordB, state.selection.group);
if (ad === -1 || ad === -2) return 1;
if (bd === -1 || bd === -2) return -1;
return ad - bd;
});
} else {
list.sort((a, b) => a.name.localeCompare(b.name));
}
return list;
},
[sortType, state.proxyData.records, state.selection.group]
);
// 计算要显示的代理选项(增加非空校验)
const proxyOptions = useMemo(() => {
if (isDirectMode) {
return [{ name: "DIRECT" }];
}
if (isGlobalMode && state.proxyData.records) {
// 全局模式下的选项
return Object.keys(state.proxyData.records)
.filter((name) => name !== "DIRECT" && name !== "REJECT")
.map((name) => ({ name }));
if (isGlobalMode && proxies?.global) {
const options = proxies.global.all
.filter((p: any) => {
const name = typeof p === 'string' ? p : p.name;
return name !== "DIRECT" && name !== "REJECT";
})
.map((p: any) => ({
name: typeof p === 'string' ? p : p.name
}));
return sortProxies(options);
}
// 普通模式
const group = state.proxyData.groups.find(
(g: { name: string }) => g.name === state.selection.group,
);
// 规则模式
const group = state.selection.group
? state.proxyData.groups.find(g => g.name === state.selection.group)
: null;
if (group) {
return group.all.map((name) => ({ name }));
const options = group.all.map((name) => ({ name }));
return sortProxies(options);
}
return [];
}, [isDirectMode, isGlobalMode, state.proxyData, state.selection.group]);
}, [isDirectMode, isGlobalMode, proxies, state.proxyData, state.selection.group, sortProxies]);
// 获取排序图标
const getSortIcon = () => {
switch (sortType) {
case 1:
return <AccessTimeRounded fontSize="small" />;
case 2:
return <SortByAlphaRounded fontSize="small" />;
default:
return <SortRounded fontSize="small" />;
}
};
// 获取排序提示文本
const getSortTooltip = () => {
switch (sortType) {
case 0:
return t("Sort by default");
case 1:
return t("Sort by delay");
case 2:
return t("Sort by name");
default:
return "";
}
};
return (
<EnhancedCard
@@ -439,15 +519,27 @@ export const CurrentProxyCard = () => {
}
iconColor={currentProxy ? "primary" : undefined}
action={
<Button
variant="outlined"
size="small"
onClick={goToProxies}
sx={{ borderRadius: 1.5 }}
endIcon={<ChevronRight fontSize="small" />}
>
{t("Label-Proxies")}
</Button>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Tooltip title={getSortTooltip()}>
<IconButton
size="small"
color="inherit"
onClick={handleSortTypeChange}
sx={{ mr: 1 }}
>
{getSortIcon()}
</IconButton>
</Tooltip>
<Button
variant="outlined"
size="small"
onClick={goToProxies}
sx={{ borderRadius: 1.5 }}
endIcon={<ChevronRight fontSize="small" />}
>
{t("Label-Proxies")}
</Button>
</Box>
}
>
{currentProxy ? (
@@ -568,10 +660,12 @@ export const CurrentProxyCard = () => {
{isDirectMode
? null
: proxyOptions.map((proxy, index) => {
const delayValue = delayManager.getDelayFix(
state.proxyData.records[proxy.name],
state.selection.group,
);
const delayValue = state.proxyData.records[proxy.name] && state.selection.group
? delayManager.getDelayFix(
state.proxyData.records[proxy.name],
state.selection.group,
)
: -1;
return (
<MenuItem
key={`${proxy.name}-${index}`}
@@ -612,4 +706,4 @@ export const CurrentProxyCard = () => {
)}
</EnhancedCard>
);
};
};
+265 -193
View File
@@ -11,17 +11,27 @@ import {
import { Box, useTheme } from "@mui/material";
import parseTraffic from "@/utils/parse-traffic";
import { useTranslation } from "react-i18next";
import { Line as ChartJsLine } from "react-chartjs-2";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
ResponsiveContainer,
AreaChart,
Area,
} from "recharts";
Filler,
Scale,
Tick,
} from "chart.js";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Filler
);
// 流量数据项接口
export interface ITrafficItem {
@@ -30,13 +40,12 @@ export interface ITrafficItem {
timestamp?: number;
}
// 组件对外暴露的方法
// 对外暴露的接口
export interface EnhancedTrafficGraphRef {
appendData: (data: ITrafficItem) => void;
toggleStyle: () => void;
}
// 时间范围类型
type TimeRange = 1 | 5 | 10; // 分钟
// 数据点类型
@@ -76,23 +85,30 @@ export const EnhancedTrafficGraph = memo(forwardRef<EnhancedTrafficGraphRef>(
up: theme.palette.secondary.main,
down: theme.palette.primary.main,
grid: theme.palette.divider,
tooltip: theme.palette.background.paper,
tooltipBg: theme.palette.background.paper,
text: theme.palette.text.primary,
tooltipBorder: theme.palette.divider,
}),
[theme]
);
// 切换时间范围
const handleTimeRangeClick = useCallback(() => {
const handleTimeRangeClick = useCallback((event: React.MouseEvent<SVGTextElement>) => {
event.stopPropagation();
setTimeRange((prevRange) => {
// 在1、5、10分钟之间循环切换
return prevRange === 1 ? 5 : prevRange === 5 ? 10 : 1;
});
}, []);
// 点击图表主体或图例时切换样式
const handleToggleStyleClick = useCallback((event: React.MouseEvent<SVGTextElement | HTMLDivElement>) => {
event.stopPropagation();
setChartStyle((prev) => (prev === "line" ? "area" : "line"));
}, []);
// 初始化数据缓冲区
useEffect(() => {
// 创建初始空数据
const now = Date.now();
const tenMinutesAgo = now - 10 * 60 * 1000;
@@ -102,17 +118,29 @@ export const EnhancedTrafficGraph = memo(forwardRef<EnhancedTrafficGraphRef>(
const pointTime =
tenMinutesAgo + index * ((10 * 60 * 1000) / MAX_BUFFER_SIZE);
const date = new Date(pointTime);
let nameValue: string;
try {
if (isNaN(date.getTime())) {
console.warn(`Initial data generation: Invalid date for timestamp ${pointTime}`);
nameValue = "??:??:??";
} else {
nameValue = date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
} catch (e) {
console.error("Error in toLocaleTimeString during initial data gen:", e, "Date:", date, "Timestamp:", pointTime);
nameValue = "Err:Time";
}
return {
up: 0,
down: 0,
timestamp: pointTime,
name: date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}),
name: nameValue,
};
}
);
@@ -123,44 +151,53 @@ export const EnhancedTrafficGraph = memo(forwardRef<EnhancedTrafficGraphRef>(
const pointsToShow = getMaxPointsByTimeRange(timeRange);
setDisplayData(initialBuffer.slice(-pointsToShow));
}, [MAX_BUFFER_SIZE, getMaxPointsByTimeRange]);
// 添加数据点方法
const appendData = useCallback((data: ITrafficItem) => {
// 安全处理数据
const safeData = {
up: typeof data.up === "number" && !isNaN(data.up) ? data.up : 0,
down: typeof data.down === "number" && !isNaN(data.down) ? data.down : 0,
};
// 使用提供的时间戳或当前时间
const timestamp = data.timestamp || Date.now();
const date = new Date(timestamp);
let nameValue: string;
try {
if (isNaN(date.getTime())) {
console.warn(`appendData: Invalid date for timestamp ${timestamp}`);
nameValue = "??:??:??";
} else {
nameValue = date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
} catch (e) {
console.error("Error in toLocaleTimeString in appendData:", e, "Date:", date, "Timestamp:", timestamp);
nameValue = "Err:Time";
}
// 带时间标签的新数据点
const newPoint: DataPoint = {
...safeData,
name: date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}),
name: nameValue,
timestamp: timestamp,
};
// 更新缓冲区,保持原数组大小
const newBuffer = [...dataBufferRef.current.slice(1), newPoint];
dataBufferRef.current = newBuffer;
// 更新显示数据
const pointsToShow = getMaxPointsByTimeRange(timeRange);
setDisplayData(newBuffer.slice(-pointsToShow));
}, [timeRange, getMaxPointsByTimeRange]);
// 监听时间范围变化,更新显示数据
// 监听时间范围变化
useEffect(() => {
const pointsToShow = getMaxPointsByTimeRange(timeRange);
setDisplayData(dataBufferRef.current.slice(-pointsToShow));
if (dataBufferRef.current.length > 0) {
setDisplayData(dataBufferRef.current.slice(-pointsToShow));
}
}, [timeRange, getMaxPointsByTimeRange]);
// 切换图表样式
@@ -178,47 +215,166 @@ export const EnhancedTrafficGraph = memo(forwardRef<EnhancedTrafficGraphRef>(
[appendData, toggleStyle]
);
// 格式化工具提示内容
const formatTooltip = useCallback((value: number, name: string, props: any) => {
const [num, unit] = parseTraffic(value);
return [`${num} ${unit}/s`, props?.dataKey === "up" ? t("Upload") : t("Download")];
}, [t]);
// Y轴刻度格式化
const formatYAxis = useCallback((value: number) => {
const formatYAxis = useCallback((value: number | string): string => {
if (typeof value !== 'number') return String(value);
const [num, unit] = parseTraffic(value);
return `${num}${unit}`;
}, []);
// 格式化X轴标签
const formatXLabel = useCallback((value: string) => {
if (!value) return "";
const parts = value.split(":");
return `${parts[0]}:${parts[1]}`;
}, []);
const formatXLabel = useCallback((tickValue: string | number, index: number, ticks: any[]) => {
const dataPoint = displayData[index as number];
if (dataPoint && dataPoint.name) {
const parts = dataPoint.name.split(":");
return `${parts[0]}:${parts[1]}`;
}
if(typeof tickValue === 'string') {
const parts = tickValue.split(":");
if (parts.length >= 2) return `${parts[0]}:${parts[1]}`;
return tickValue;
}
return '';
}, [displayData]);
// 获取当前时间范围文本
const getTimeRangeText = useCallback(() => {
return t("{{time}} Minutes", { time: timeRange });
}, [timeRange, t]);
// 共享图表配置
const chartConfig = useMemo(() => ({
data: displayData,
margin: { top: 20, right: 10, left: 0, bottom: -10 },
}), [displayData]);
const chartData = useMemo(() => {
const labels = displayData.map(d => d.name);
return {
labels,
datasets: [
{
label: t("Upload"),
data: displayData.map(d => d.up),
borderColor: colors.up,
backgroundColor: chartStyle === "area" ? colors.up : colors.up,
fill: chartStyle === "area",
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 2,
},
{
label: t("Download"),
data: displayData.map(d => d.down),
borderColor: colors.down,
backgroundColor: chartStyle === "area" ? colors.down : colors.down,
fill: chartStyle === "area",
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 2,
}
]
};
}, [displayData, colors.up, colors.down, t, chartStyle]);
// 共享的线条/区域配置
const commonLineProps = useMemo(() => ({
dot: false,
strokeWidth: 2,
connectNulls: false,
activeDot: { r: 4, strokeWidth: 1 },
isAnimationActive: false, // 禁用动画以减少CPU使用
}), []);
const chartOptions = useMemo(() => ({
responsive: true,
maintainAspectRatio: false,
animation: false as false,
scales: {
x: {
display: true,
type: 'category' as const,
labels: displayData.map(d => d.name),
ticks: {
display: true,
color: colors.text,
font: { size: 10 },
callback: function(this: Scale, tickValue: string | number, index: number, ticks: Tick[]): string | undefined {
let labelToFormat: string | undefined = undefined;
const currentDisplayTick = ticks[index];
if (currentDisplayTick && typeof currentDisplayTick.label === 'string') {
labelToFormat = currentDisplayTick.label;
} else {
const sourceLabels = displayData.map(d => d.name);
if (typeof tickValue === 'number' && tickValue >= 0 && tickValue < sourceLabels.length) {
labelToFormat = sourceLabels[tickValue];
} else if (typeof tickValue === 'string') {
labelToFormat = tickValue;
}
}
if (typeof labelToFormat !== 'string') {
return undefined;
}
const parts: string[] = labelToFormat.split(':');
return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : labelToFormat;
},
autoSkip: true,
maxTicksLimit: Math.max(5, Math.floor(displayData.length / (timeRange * 2))),
minRotation: 0,
maxRotation: 0,
},
grid: {
display: true,
drawOnChartArea: false,
drawTicks: true,
tickLength: 2,
color: colors.text,
},
},
y: {
beginAtZero: true,
ticks: {
color: colors.text,
font: { size: 10 },
callback: formatYAxis,
},
grid: {
display: true,
drawTicks: true,
tickLength: 3,
color: colors.grid,
},
}
},
plugins: {
tooltip: {
enabled: true,
mode: 'index' as const,
intersect: false,
backgroundColor: colors.tooltipBg,
titleColor: colors.text,
bodyColor: colors.text,
borderColor: colors.tooltipBorder,
borderWidth: 1,
cornerRadius: 4,
padding: 8,
callbacks: {
title: (tooltipItems: any[]) => {
return `${t("Time")}: ${tooltipItems[0].label}`;
},
label: (context: any): string => {
const label = context.dataset.label || '';
const value = context.parsed.y;
const [num, unit] = parseTraffic(value);
return `${label}: ${num} ${unit}/s`;
}
}
},
legend: {
display: false
}
},
layout: {
padding: {
top: 16,
right: 7,
left: 3,
}
}
}), [colors, t, formatYAxis, timeRange, displayData]);
// 曲线类型
const curveType = "monotone";
return (
<Box
@@ -230,141 +386,57 @@ export const EnhancedTrafficGraph = memo(forwardRef<EnhancedTrafficGraphRef>(
borderRadius: 1,
cursor: "pointer",
}}
onClick={toggleStyle}
onClick={handleToggleStyleClick}
>
<ResponsiveContainer width="100%" height="100%">
{/* 根据chartStyle动态选择图表类型 */}
{(() => {
// 创建共享的图表组件
const commonChartComponents = (
<>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={colors.grid} opacity={0.3} />
<XAxis
dataKey="name"
tick={{ fontSize: 10, fill: colors.text }}
tickLine={{ stroke: colors.grid }}
axisLine={{ stroke: colors.grid }}
interval="preserveStart"
tickFormatter={formatXLabel}
minTickGap={30}
/>
<YAxis
tickFormatter={formatYAxis}
tick={{ fontSize: 10, fill: colors.text }}
tickLine={{ stroke: colors.grid }}
axisLine={{ stroke: colors.grid }}
width={44}
domain={[0, "auto"]}
padding={{ top: 8, bottom: 0 }}
/>
<Tooltip
formatter={formatTooltip}
labelFormatter={(label) => `${t("Time")}: ${label}`}
contentStyle={{
backgroundColor: colors.tooltip,
borderColor: colors.grid,
borderRadius: 4,
}}
itemStyle={{ color: colors.text }}
isAnimationActive={false}
/>
{/* 可点击的时间范围标签 */}
<text
x="1%"
y="11%"
textAnchor="start"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight="bold"
onClick={handleTimeRangeClick}
style={{ cursor: "pointer" }}
>
{getTimeRangeText()}
</text>
{/* 上传标签 - 右上角 */}
<text
x="99%"
y="11%"
textAnchor="end"
fill={colors.up}
fontSize={12}
fontWeight="bold"
onClick={toggleStyle}
style={{ cursor: "pointer" }}
>
{t("Upload")}
</text>
<div style={{ width: "100%", height: "100%", position: "relative" }}>
{displayData.length > 0 && (
<ChartJsLine data={chartData} options={chartOptions} />
)}
<svg width="100%" height="100%" style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}>
<text
x="3.5%"
y="10%"
textAnchor="start"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight="bold"
onClick={handleTimeRangeClick}
style={{ cursor: "pointer", pointerEvents: 'all' }}
>
{getTimeRangeText()}
</text>
<text
x="99%"
y="10%"
textAnchor="end"
fill={colors.up}
fontSize={12}
fontWeight="bold"
onClick={handleToggleStyleClick}
style={{ cursor: "pointer", pointerEvents: 'all' }}
>
{t("Upload")}
</text>
{/* 下载标签 - 右上角下方 */}
<text
x="99%"
y="19%"
textAnchor="end"
fill={colors.down}
fontSize={12}
fontWeight="bold"
onClick={toggleStyle}
style={{ cursor: "pointer" }}
>
{t("Download")}
</text>
</>
);
// 根据chartStyle返回相应的图表类型
if (chartStyle === "line") {
return (
<LineChart {...chartConfig}>
{commonChartComponents}
<Line
type={curveType}
{...commonLineProps}
dataKey="up"
name={t("Upload")}
stroke={colors.up}
/>
<Line
type={curveType}
{...commonLineProps}
dataKey="down"
name={t("Download")}
stroke={colors.down}
/>
</LineChart>
);
} else {
return (
<AreaChart {...chartConfig}>
{commonChartComponents}
<Area
type={curveType}
{...commonLineProps}
dataKey="up"
name={t("Upload")}
stroke={colors.up}
fill={colors.up}
fillOpacity={0.2}
/>
<Area
type={curveType}
{...commonLineProps}
dataKey="down"
name={t("Download")}
stroke={colors.down}
fill={colors.down}
fillOpacity={0.3}
/>
</AreaChart>
);
}
})()}
</ResponsiveContainer>
<text
x="99%"
y="19%"
textAnchor="end"
fill={colors.down}
fontSize={12}
fontWeight="bold"
onClick={handleToggleStyleClick}
style={{ cursor: "pointer", pointerEvents: 'all' }}
>
{t("Download")}
</text>
</svg>
</div>
</Box>
);
},
));
// 添加显示名称以便调试
EnhancedTrafficGraph.displayName = "EnhancedTrafficGraph";
+75 -36
View File
@@ -6,8 +6,8 @@ import {
alpha,
useTheme,
PaletteColor,
Grid,
} from "@mui/material";
import Grid from "@mui/material/Grid2";
import {
ArrowUpwardRounded,
ArrowDownwardRounded,
@@ -75,7 +75,7 @@ const CompactStatCard = memo(({
onClick,
}: StatCardProps) => {
const theme = useTheme();
// 获取调色板颜色 - 使用useMemo避免重复计算
const colorValue = useMemo(() => {
const palette = theme.palette;
@@ -88,7 +88,7 @@ const CompactStatCard = memo(({
}
return palette.primary.main;
}, [theme.palette, color]);
return (
<Paper
elevation={0}
@@ -157,10 +157,10 @@ export const EnhancedTrafficStats = () => {
const trafficRef = useRef<EnhancedTrafficGraphRef>(null);
const pageVisible = useVisibility();
const [isDebug, setIsDebug] = useState(false);
// 使用AppDataProvider
const { connections, uptime } = useAppData();
// 使用单一状态对象减少状态更新次数
const [stats, setStats] = useState({
traffic: { up: 0, down: 0 },
@@ -169,14 +169,17 @@ export const EnhancedTrafficStats = () => {
// 创建一个标记来追踪最后更新时间,用于节流
const lastUpdateRef = useRef({ traffic: 0 });
// 是否显示流量图表
const trafficGraph = verge?.traffic_graph ?? true;
// WebSocket引用
const socketRefs = useRef({
traffic: null as ReturnType<typeof createAuthSockette> | null,
memory: null as ReturnType<typeof createAuthSockette> | null,
const socketRefs = useRef<{
traffic: ReturnType<typeof createAuthSockette> | null;
memory: ReturnType<typeof createAuthSockette> | null;
}>({
traffic: null,
memory: null,
});
// 检查是否支持调试
@@ -196,41 +199,34 @@ export const EnhancedTrafficStats = () => {
// 使用节流控制更新频率
const now = Date.now();
if (now - lastUpdateRef.current.traffic < THROTTLE_TRAFFIC_UPDATE) {
// 如果距离上次更新时间小于阈值,只更新图表不更新状态
if (trafficRef.current) {
trafficRef.current.appendData({
try {
trafficRef.current?.appendData({
up: data.up,
down: data.down,
timestamp: now,
});
}
} catch { }
return;
}
// 更新最后更新时间
lastUpdateRef.current.traffic = now;
// 验证数据有效性,防止NaN
const safeUp = isNaN(data.up) ? 0 : data.up;
const safeDown = isNaN(data.down) ? 0 : data.down;
// 批量更新状态
setStats(prev => ({
...prev,
traffic: { up: safeUp, down: safeDown }
}));
// 更新图表数据
if (trafficRef.current) {
trafficRef.current.appendData({
try {
setStats(prev => ({
...prev,
traffic: { up: safeUp, down: safeDown }
}));
} catch { }
try {
trafficRef.current?.appendData({
up: safeUp,
down: safeDown,
timestamp: now,
});
}
} catch { }
}
} catch (err) {
console.error("[Traffic] 解析数据错误:", err);
console.error("[Traffic] 解析数据错误:", err, event.data);
}
}, []);
@@ -248,7 +244,7 @@ export const EnhancedTrafficStats = () => {
}));
}
} catch (err) {
console.error("[Memory] 解析数据错误:", err);
console.error("[Memory] 解析数据错误:", err, event.data);
}
}, []);
@@ -273,17 +269,59 @@ export const EnhancedTrafficStats = () => {
cleanupSockets();
// 创建新连接
console.log(`[Traffic][${EnhancedTrafficStats.name}] 正在连接: ${server}/traffic`);
socketRefs.current.traffic = createAuthSockette(`${server}/traffic`, secret, {
onmessage: handleTrafficUpdate,
onopen: (event) => {
console.log(`[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接已建立`, event);
},
onerror: (event) => {
console.error(`[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接错误或达到最大重试次数`, event);
setStats(prev => ({ ...prev, traffic: { up: 0, down: 0 } }));
},
onclose: (event) => {
console.log(`[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接关闭`, event.code, event.reason);
if (event.code !== 1000 && event.code !== 1001) {
console.warn(`[Traffic][${EnhancedTrafficStats.name}] 连接非正常关闭,重置状态`);
setStats(prev => ({ ...prev, traffic: { up: 0, down: 0 } }));
}
},
});
console.log(`[Memory][${EnhancedTrafficStats.name}] 正在连接: ${server}/memory`);
socketRefs.current.memory = createAuthSockette(`${server}/memory`, secret, {
onmessage: handleMemoryUpdate,
onopen: (event) => {
console.log(`[Memory][${EnhancedTrafficStats.name}] WebSocket 连接已建立`, event);
},
onerror: (event) => {
console.error(`[Memory][${EnhancedTrafficStats.name}] WebSocket 连接错误或达到最大重试次数`, event);
setStats(prev => ({ ...prev, memory: { inuse: 0, oslimit: undefined } }));
},
onclose: (event) => {
console.log(`[Memory][${EnhancedTrafficStats.name}] WebSocket 连接关闭`, event.code, event.reason);
if (event.code !== 1000 && event.code !== 1001) {
console.warn(`[Memory][${EnhancedTrafficStats.name}] 连接非正常关闭,重置状态`);
setStats(prev => ({ ...prev, memory: { inuse: 0, oslimit: undefined } }));
}
},
});
return cleanupSockets;
}, [clashInfo, pageVisible, handleTrafficUpdate, handleMemoryUpdate]);
// 组件卸载时清理所有定时器/引用
useEffect(() => {
return () => {
try {
Object.values(socketRefs.current).forEach(socket => {
if (socket) socket.close();
});
socketRefs.current = { traffic: null, memory: null };
} catch { }
};
}, []);
// 执行垃圾回收
const handleGarbageCollection = useCallback(async () => {
if (isDebug) {
@@ -303,12 +341,13 @@ export const EnhancedTrafficStats = () => {
const [inuse, inuseUnit] = parseTraffic(stats.memory.inuse);
const [uploadTotal, uploadTotalUnit] = parseTraffic(connections.uploadTotal);
const [downloadTotal, downloadTotalUnit] = parseTraffic(connections.downloadTotal);
return {
up, upUnit, down, downUnit, inuse, inuseUnit,
uploadTotal, uploadTotalUnit, downloadTotal, downloadTotalUnit
uploadTotal, uploadTotalUnit, downloadTotal, downloadTotalUnit,
connectionsCount: connections.count
};
}, [stats, connections.uploadTotal, connections.downloadTotal]);
}, [stats, connections]);
// 渲染流量图表 - 使用useMemo缓存渲染结果
const trafficGraphComponent = useMemo(() => {
@@ -371,7 +410,7 @@ export const EnhancedTrafficStats = () => {
{
icon: <LinkRounded fontSize="small" />,
title: t("Active Connections"),
value: connections.count,
value: parsedData.connectionsCount,
unit: "",
color: "success" as const,
},
@@ -397,7 +436,7 @@ export const EnhancedTrafficStats = () => {
color: "error" as const,
onClick: isDebug ? handleGarbageCollection : undefined,
},
], [t, parsedData, connections.count, isDebug, handleGarbageCollection]);
], [t, parsedData, isDebug, handleGarbageCollection]);
return (
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
+5 -5
View File
@@ -25,7 +25,7 @@ import parseTraffic from "@/utils/parse-traffic";
import { useMemo, useCallback, useState } from "react";
import { openWebUrl, updateProfile } from "@/services/cmds";
import { useLockFn } from "ahooks";
import { Notice } from "@/components/base";
import { showNotice } from "@/services/noticeService";
import { EnhancedCard } from "./enhanced-card";
import { useAppData } from "@/providers/app-data-provider";
@@ -272,7 +272,7 @@ export const HomeProfileCard = ({ current, onProfileUpdated }: HomeProfileCardPr
const { t } = useTranslation();
const navigate = useNavigate();
const { refreshAll } = useAppData();
// 更新当前订阅
const [updating, setUpdating] = useState(false);
@@ -281,14 +281,14 @@ export const HomeProfileCard = ({ current, onProfileUpdated }: HomeProfileCardPr
setUpdating(true);
try {
await updateProfile(current.uid);
Notice.success(t("Update subscription successfully"));
await updateProfile(current.uid, current.option);
showNotice('success', t("Update subscription successfully"), 1000);
onProfileUpdated?.();
// 刷新首页数据
refreshAll();
} catch (err: any) {
Notice.error(err?.message || err.toString());
showNotice('error', err.message || err.toString(), 3000);
} finally {
setUpdating(false);
}
+30 -17
View File
@@ -9,9 +9,8 @@ import {
useTheme,
Fade,
} from "@mui/material";
import { useState, useMemo, memo, FC } from "react";
import { useState, useMemo, memo, FC, useEffect } from "react";
import ProxyControlSwitches from "@/components/shared/ProxyControlSwitches";
import { Notice } from "@/components/base";
import {
ComputerRounded,
TroubleshootRounded,
@@ -20,6 +19,9 @@ import {
} from "@mui/icons-material";
import { useVerge } from "@/hooks/use-verge";
import { useSystemState } from "@/hooks/use-system-state";
import { showNotice } from "@/services/noticeService";
import { getRunningMode } from "@/services/cmds";
import { mutate } from "swr";
const LOCAL_STORAGE_TAB_KEY = "clash-verge-proxy-active-tab";
@@ -31,7 +33,7 @@ interface TabButtonProps {
hasIndicator?: boolean;
}
// 抽取Tab组件以减少重复代码
// Tab组件
const TabButton: FC<TabButtonProps> = memo(
({ isActive, onClick, icon: Icon, label, hasIndicator = false }) => (
<Paper
@@ -96,7 +98,7 @@ interface TabDescriptionProps {
tooltipTitle: string;
}
// 抽取描述文本组件
// 描述文本组件
const TabDescription: FC<TabDescriptionProps> = memo(
({ description, tooltipTitle }) => (
<Fade in={true} timeout={200}>
@@ -139,28 +141,42 @@ export const ProxyTunCard: FC = () => {
() => localStorage.getItem(LOCAL_STORAGE_TAB_KEY) || "system",
);
// 获取代理状态信息
const [localServiceOk, setLocalServiceOk] = useState(false);
const { verge } = useVerge();
const { isSidecarMode, isAdminMode } = useSystemState();
const { isAdminMode } = useSystemState();
// 从verge配置中获取开关状态
const { enable_system_proxy, enable_tun_mode } = verge ?? {};
// 判断Tun模式是否可用 - 当处于服务模式或管理员模式时可用
const isTunAvailable = !isSidecarMode || isAdminMode;
// 处理错误
const updateLocalStatus = async () => {
try {
const runningMode = await getRunningMode();
const serviceStatus = runningMode === "Service";
setLocalServiceOk(serviceStatus);
mutate("isServiceAvailable", serviceStatus, false);
} catch (error) {
console.error("更新TUN状态失败:", error);
}
};
useEffect(() => {
updateLocalStatus();
}, []);
const isTunAvailable = localServiceOk || isAdminMode;
const handleError = (err: Error) => {
Notice.error(err.message || err.toString(), 3000);
showNotice("error", err.message || err.toString());
};
// 处理标签切换并保存到localStorage
const handleTabChange = (tab: string) => {
setActiveTab(tab);
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, tab);
if (tab === "tun") {
updateLocalStatus();
}
};
// 用户提示文本 - 使用useMemo避免重复计算
const tabDescription = useMemo(() => {
if (activeTab === "system") {
return {
@@ -183,7 +199,6 @@ export const ProxyTunCard: FC = () => {
return (
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
{/* 选项卡 */}
<Stack
direction="row"
spacing={1}
@@ -210,7 +225,6 @@ export const ProxyTunCard: FC = () => {
/>
</Stack>
{/* 说明文本区域 */}
<Box
sx={{
width: "100%",
@@ -227,7 +241,6 @@ export const ProxyTunCard: FC = () => {
/>
</Box>
{/* 控制开关部分 */}
<Box
sx={{
mt: 0,
+39 -45
View File
@@ -11,43 +11,51 @@ import {
import { useVerge } from "@/hooks/use-verge";
import { EnhancedCard } from "./enhanced-card";
import useSWR from "swr";
import { getSystemInfo, installService } from "@/services/cmds";
import {
getSystemInfo,
} from "@/services/cmds";
import { useNavigate } from "react-router-dom";
import { version as appVersion } from "@root/package.json";
import { useCallback, useEffect, useMemo, useState } from "react";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { useLockFn } from "ahooks";
import { Notice } from "@/components/base";
import { showNotice } from "@/services/noticeService";
import { useSystemState } from "@/hooks/use-system-state";
import { useServiceInstaller } from "@/hooks/useServiceInstaller";
export const SystemInfoCard = () => {
const { t } = useTranslation();
const { verge, patchVerge } = useVerge();
const navigate = useNavigate();
const { isAdminMode, isSidecarMode, mutateRunningMode } = useSystemState();
const { installServiceAndRestartCore } = useServiceInstaller();
// 系统信息状态
const [systemState, setSystemState] = useState({
osInfo: "",
lastCheckUpdate: "-",
});
// 系统信息状态
const [systemState, setSystemState] = useState({
osInfo: "",
lastCheckUpdate: "-",
});
// 初始化系统信息
useEffect(() => {
// 获取系统信息
getSystemInfo()
.then((info) => {
const lines = info.split("\n");
if (lines.length > 0) {
const sysName = lines[0].split(": ")[1] || "";
const sysVersion = lines[1].split(": ")[1] || "";
setSystemState((prev) => ({
...prev,
osInfo: `${sysName} ${sysVersion}`,
}));
// 初始化系统信息
useEffect(() => {
getSystemInfo()
.then((info) => {
const lines = info.split("\n");
if (lines.length > 0) {
const sysName = lines[0].split(": ")[1] || "";
let sysVersion = lines[1].split(": ")[1] || "";
if (sysName && sysVersion.toLowerCase().startsWith(sysName.toLowerCase())) {
sysVersion = sysVersion.substring(sysName.length).trim();
}
})
.catch(console.error);
setSystemState((prev) => ({
...prev,
osInfo: `${sysName} ${sysVersion}`,
}));
}
})
.catch(console.error);
// 获取最后检查更新时间
const lastCheck = localStorage.getItem("last_check_update");
@@ -106,47 +114,34 @@ export const SystemInfoCard = () => {
// 切换自启动状态
const toggleAutoLaunch = useCallback(async () => {
if (!verge || isAdminMode) return;
if (!verge) return;
try {
await patchVerge({ enable_auto_launch: !verge.enable_auto_launch });
} catch (err) {
console.error("切换开机自启动状态失败:", err);
}
}, [verge, patchVerge, isAdminMode]);
}, [verge, patchVerge]);
// 安装系统服务
const onInstallService = useLockFn(async () => {
try {
Notice.info(t("Installing Service..."), 1000);
await installService();
Notice.success(t("Service Installed Successfully"), 2000);
await mutateRunningMode();
} catch (err: any) {
Notice.error(err.message || err.toString(), 3000);
}
});
// 点击运行模式处理,Sidecar或纯管理员模式允许安装服务
const handleRunningModeClick = useCallback(() => {
if (isSidecarMode || (isAdminMode && isSidecarMode)) {
onInstallService();
installServiceAndRestartCore();
}
}, [isSidecarMode, isAdminMode, onInstallService]);
}, [isSidecarMode, isAdminMode, installServiceAndRestartCore]);
// 检查更新
const onCheckUpdate = useLockFn(async () => {
try {
const info = await checkUpdate();
if (!info?.available) {
Notice.success(t("Currently on the Latest Version"));
showNotice('success', t("Currently on the Latest Version"));
} else {
Notice.info(t("Update Available"), 2000);
showNotice('info', t("Update Available"), 2000);
goToSettings();
}
} catch (err: any) {
Notice.error(err.message || err.toString());
showNotice('error', err.message || err.toString());
}
});
@@ -258,7 +253,7 @@ export const SystemInfoCard = () => {
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
{isAdminMode && (
<Tooltip title={t("Administrator mode does not support auto launch")}>
<Tooltip title={t("Administrator mode may not support auto launch")}>
<WarningOutlined sx={{ color: "warning.main", fontSize: 20 }} />
</Tooltip>
)}
@@ -268,8 +263,7 @@ export const SystemInfoCard = () => {
color={autoLaunchEnabled ? "success" : "default"}
variant={autoLaunchEnabled ? "filled" : "outlined"}
onClick={toggleAutoLaunch}
disabled={isAdminMode}
sx={{ cursor: isAdminMode ? "not-allowed" : "pointer" }}
sx={{ cursor: "pointer" }}
/>
</Stack>
</Stack>
+18 -9
View File
@@ -1,7 +1,6 @@
import { useEffect, useRef, useMemo, useCallback } from "react";
import { useVerge } from "@/hooks/use-verge";
import { Box, IconButton, Tooltip, alpha, styled } from "@mui/material";
import Grid from "@mui/material/Grid2";
import { Box, IconButton, Tooltip, alpha, styled, Grid } from "@mui/material";
import {
DndContext,
closestCenter,
@@ -86,11 +85,11 @@ export const TestCard = () => {
mutateVerge();
return;
}
const newList = testList.map((x) =>
const newList = testList.map((x) =>
x.uid === uid ? { ...x, ...patch } : x
);
mutateVerge({ ...verge, test_list: newList }, false);
},
[testList, verge, mutateVerge]
@@ -109,17 +108,27 @@ export const TestCard = () => {
async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const old_index = testList.findIndex((x) => x.uid === active.id);
const new_index = testList.findIndex((x) => x.uid === over.id);
if (old_index >= 0 && new_index >= 0) {
const newList = [...testList];
const [removed] = newList.splice(old_index, 1);
newList.splice(new_index, 0, removed);
await mutateVerge({ ...verge, test_list: newList }, false);
await patchVerge({ test_list: newList });
// 优化:先本地更新,再异步 patch,避免UI卡死
mutateVerge({ ...verge, test_list: newList }, false);
const patchFn = () => {
try {
patchVerge({ test_list: newList });
} catch { }
};
if (window.requestIdleCallback) {
window.requestIdleCallback(patchFn);
} else {
setTimeout(patchFn, 0);
}
}
},
[testList, verge, mutateVerge, patchVerge]
-104
View File
@@ -1,104 +0,0 @@
import { Button, ButtonGroup } from "@mui/material";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import {
CloseRounded,
CropSquareRounded,
FilterNoneRounded,
HorizontalRuleRounded,
PushPinOutlined,
PushPinRounded,
} from "@mui/icons-material";
import { useEffect, useState } from "react";
const appWindow = getCurrentWebviewWindow();
export const LayoutControl = () => {
const minWidth = 40;
const [isMaximized, setIsMaximized] = useState(false);
const [isPined, setIsPined] = useState(false);
useEffect(() => {
const unlistenResized = appWindow.onResized(() => {
appWindow.isMaximized().then((maximized) => {
setIsMaximized(() => maximized);
});
});
appWindow.isMaximized().then((maximized) => {
setIsMaximized(() => maximized);
});
return () => {
unlistenResized.then((fn) => fn());
};
}, []);
return (
<ButtonGroup
variant="text"
sx={{
zIndex: 1000,
height: "100%",
".MuiButtonGroup-grouped": {
borderRadius: "0px",
borderRight: "0px",
},
}}
>
<Button
size="small"
sx={{ minWidth, svg: { transform: "scale(0.9)" } }}
onClick={() => {
appWindow.setAlwaysOnTop(!isPined);
setIsPined((isPined) => !isPined);
}}
>
{isPined ? (
<PushPinRounded fontSize="small" />
) : (
<PushPinOutlined fontSize="small" />
)}
</Button>
<Button
size="small"
sx={{ minWidth, svg: { transform: "scale(0.9)" } }}
onClick={() => appWindow.minimize()}
>
<HorizontalRuleRounded fontSize="small" />
</Button>
<Button
size="small"
sx={{ minWidth, svg: { transform: "scale(0.9)" } }}
onClick={() => {
setIsMaximized((isMaximized) => !isMaximized);
appWindow.toggleMaximize();
}}
>
{isMaximized ? (
<FilterNoneRounded
fontSize="small"
style={{
transform: "rotate(180deg) scale(0.7)",
}}
/>
) : (
<CropSquareRounded fontSize="small" />
)}
</Button>
<Button
size="small"
sx={{
minWidth,
svg: { transform: "scale(1.05)" },
":hover": { bgcolor: "#ff000090" },
}}
onClick={() => appWindow.close()}
>
<CloseRounded fontSize="small" />
</Button>
</ButtonGroup>
);
};
+84 -46
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react";
import { alpha, createTheme, Shadows, Theme } from "@mui/material";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { alpha, createTheme, Shadows, Theme as MuiTheme } from "@mui/material";
import { getCurrentWebviewWindow, WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useSetThemeMode, useThemeMode } from "@/services/states";
import { defaultTheme, defaultDarkTheme } from "@/pages/_theme";
import { useVerge } from "@/hooks/use-verge";
@@ -12,7 +12,7 @@ import {
arSD as arXDataGrid,
} from "@mui/x-data-grid/locales";
import { useTranslation } from "react-i18next";
const appWindow = getCurrentWebviewWindow();
import { Theme as TauriOsTheme } from "@tauri-apps/api/window";
const languagePackMap: Record<string, any> = {
zh: { ...zhXDataGrid },
@@ -29,6 +29,7 @@ const getLanguagePackMap = (key: string) =>
* custom theme
*/
export const useCustomTheme = () => {
const appWindow: WebviewWindow = useMemo(() => getCurrentWebviewWindow(), []);
const { verge } = useVerge();
const { i18n } = useTranslation();
const { theme_mode, theme_setting } = verge ?? {};
@@ -36,31 +37,71 @@ export const useCustomTheme = () => {
const setMode = useSetThemeMode();
useEffect(() => {
const themeMode = ["light", "dark", "system"].includes(theme_mode!)
? theme_mode!
: "light";
if (theme_mode === "light" || theme_mode === "dark") {
setMode(theme_mode);
}
}, [theme_mode, setMode]);
if (themeMode !== "system") {
setMode(themeMode);
useEffect(() => {
if (theme_mode !== "system") {
return;
}
appWindow.theme().then((m) => m && setMode(m));
const unlisten = appWindow.onThemeChanged((e) => setMode(e.payload));
let isMounted = true;
const timerId = setTimeout(() => {
if (!isMounted) return;
appWindow.theme().then((systemTheme) => {
if (isMounted && systemTheme) {
setMode(systemTheme);
}
}).catch(err => {
console.error("Failed to get initial system theme:", err);
});
}, 0);
const unlistenPromise = appWindow.onThemeChanged(({ payload }) => {
if (isMounted) {
setMode(payload);
}
});
return () => {
unlisten.then((fn) => fn());
isMounted = false;
clearTimeout(timerId);
unlistenPromise.then((unlistenFn) => {
if (typeof unlistenFn === 'function') {
unlistenFn();
}
}).catch(err => {
console.error("Failed to unlisten from theme changes:", err);
});
};
}, [theme_mode]);
}, [theme_mode, appWindow, setMode]);
useEffect(() => {
if (theme_mode === undefined) {
return;
}
if (theme_mode === "system") {
appWindow.setTheme(null).catch((err) => {
console.error("Failed to set window theme to follow system (setTheme(null)):", err);
});
} else if (mode) {
appWindow.setTheme(mode as TauriOsTheme).catch((err) => {
console.error(`Failed to set window theme to ${mode}:`, err);
});
}
}, [mode, appWindow, theme_mode]);
const theme = useMemo(() => {
const setting = theme_setting || {};
const dt = mode === "light" ? defaultTheme : defaultDarkTheme;
let theme: Theme;
let muiTheme: MuiTheme;
try {
theme = createTheme(
muiTheme = createTheme(
{
breakpoints: {
values: { xs: 0, sm: 650, md: 900, lg: 1200, xl: 1536 },
@@ -83,7 +124,6 @@ export const useCustomTheme = () => {
},
shadows: Array(25).fill("none") as Shadows,
typography: {
// todo
fontFamily: setting.font_family
? `${setting.font_family}, ${dt.font_family}`
: dt.font_family,
@@ -91,9 +131,9 @@ export const useCustomTheme = () => {
},
getLanguagePackMap(i18n.language),
);
} catch {
// fix #294
theme = createTheme({
} catch (e) {
console.error("Error creating MUI theme, falling back to defaults:", e);
muiTheme = createTheme({
breakpoints: {
values: { xs: 0, sm: 650, md: 900, lg: 1200, xl: 1536 },
},
@@ -111,38 +151,36 @@ export const useCustomTheme = () => {
});
}
// css
const backgroundColor = mode === "light" ? "#ECECEC" : "#2e303d";
const selectColor = mode === "light" ? "#f5f5f5" : "#d5d5d5";
const scrollColor = mode === "light" ? "#90939980" : "#3E3E3Eee";
const dividerColor =
mode === "light" ? "rgba(0, 0, 0, 0.06)" : "rgba(255, 255, 255, 0.06)";
const rootEle = document.documentElement;
rootEle.style.setProperty("--divider-color", dividerColor);
rootEle.style.setProperty("--background-color", backgroundColor);
rootEle.style.setProperty("--selection-color", selectColor);
rootEle.style.setProperty("--scroller-color", scrollColor);
rootEle.style.setProperty("--primary-main", theme.palette.primary.main);
rootEle.style.setProperty(
"--background-color-alpha",
alpha(theme.palette.primary.main, 0.1),
);
if (rootEle) {
const backgroundColor = mode === "light" ? "#ECECEC" : "#2e303d";
const selectColor = mode === "light" ? "#f5f5f5" : "#d5d5d5";
const scrollColor = mode === "light" ? "#90939980" : "#3E3E3Eee";
const dividerColor =
mode === "light" ? "rgba(0, 0, 0, 0.06)" : "rgba(255, 255, 255, 0.06)";
rootEle.style.setProperty("--divider-color", dividerColor);
rootEle.style.setProperty("--background-color", backgroundColor);
rootEle.style.setProperty("--selection-color", selectColor);
rootEle.style.setProperty("--scroller-color", scrollColor);
rootEle.style.setProperty("--primary-main", muiTheme.palette.primary.main);
rootEle.style.setProperty(
"--background-color-alpha",
alpha(muiTheme.palette.primary.main, 0.1),
);
}
// inject css
let style = document.querySelector("style#verge-theme");
if (!style) {
style = document.createElement("style");
style.id = "verge-theme";
document.head.appendChild(style!);
let styleElement = document.querySelector("style#verge-theme");
if (!styleElement) {
styleElement = document.createElement("style");
styleElement.id = "verge-theme";
document.head.appendChild(styleElement!);
}
if (style) {
style.innerHTML = setting.css_injection || "";
if (styleElement) {
styleElement.innerHTML = setting.css_injection || "";
}
// update svg icon
const { palette } = theme;
const { palette } = muiTheme;
setTimeout(() => {
const dom = document.querySelector("#Gradient2");
if (dom) {
@@ -154,7 +192,7 @@ export const useCustomTheme = () => {
}
}, 0);
return theme;
return muiTheme;
}, [mode, theme_setting, i18n.language]);
return { theme };
+5 -5
View File
@@ -16,9 +16,9 @@ import {
CloseFullscreenRounded,
} from "@mui/icons-material";
import { useThemeMode } from "@/services/states";
import { Notice } from "@/components/base";
import { nanoid } from "nanoid";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system";
import debounce from "@/utils/debounce";
@@ -97,7 +97,7 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
onClose,
} = props;
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>();
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>(undefined);
const prevData = useRef<string | undefined>("");
const currData = useRef<string | undefined>("");
@@ -127,7 +127,7 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
currData.current = value;
onChange?.(prevData.current, currData.current);
} catch (err: any) {
Notice.error(err.message || err.toString());
showNotice('error', err.message || err.toString());
}
});
@@ -136,7 +136,7 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
!readOnly && onSave?.(prevData.current, currData.current);
onClose();
} catch (err: any) {
Notice.error(err.message || err.toString());
showNotice('error', err.message || err.toString());
}
});
@@ -144,7 +144,7 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
try {
onClose();
} catch (err: any) {
Notice.error(err.message || err.toString());
showNotice('error', err.message || err.toString());
}
});
+1 -1
View File
@@ -12,7 +12,7 @@ export const FileInput = (props: Props) => {
const { t } = useTranslation();
// file input
const inputRef = useRef<any>();
const inputRef = useRef<any>(undefined);
const [loading, setLoading] = useState(false);
const [fileName, setFileName] = useState("");
+46 -30
View File
@@ -40,13 +40,14 @@ import {
readProfileFile,
saveProfileFile,
} from "@/services/cmds";
import { Notice, Switch } from "@/components/base";
import { Switch } from "@/components/base";
import getSystem from "@/utils/get-system";
import { BaseSearchBox } from "../base/base-search-box";
import { Virtuoso } from "react-virtuoso";
import MonacoEditor from "react-monaco-editor";
import { useThemeMode } from "@/services/states";
import { Controller, useForm } from "react-hook-form";
import { showNotice } from "@/services/noticeService";
interface Props {
proxiesUid: string;
@@ -179,16 +180,27 @@ export const GroupsEditorViewer = (props: Props) => {
setDeleteSeq(obj?.delete || []);
}, [visualization]);
// 优化:异步处理大数据yaml.dump,避免UI卡死
useEffect(() => {
if (prependSeq && appendSeq && deleteSeq)
setCurrData(
yaml.dump(
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
{
forceQuotes: true,
}
)
);
if (prependSeq && appendSeq && deleteSeq) {
const serialize = () => {
try {
setCurrData(
yaml.dump(
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
{ forceQuotes: true }
)
);
} catch (e) {
// 防止异常导致UI卡死
}
};
if (window.requestIdleCallback) {
window.requestIdleCallback(serialize);
} else {
setTimeout(serialize, 0);
}
}
}, [prependSeq, appendSeq, deleteSeq]);
const fetchProxyPolicy = async () => {
@@ -285,10 +297,11 @@ export const GroupsEditorViewer = (props: Props) => {
const handleSave = useLockFn(async () => {
try {
await saveProfileFile(property, currData);
showNotice('success', t("Saved Successfully"));
onSave?.(prevData, currData);
onClose();
} catch (err: any) {
Notice.error(err.message || err.toString());
showNotice('error', err.toString());
}
});
@@ -441,7 +454,7 @@ export const GroupsEditorViewer = (props: Props) => {
<ListItemText primary={t("Health Check Url")} />
<TextField
autoComplete="new-password"
placeholder="https://www.gstatic.com/generate_204"
placeholder="https://cp.cloudflare.com/generate_204"
size="small"
sx={{ width: "calc(100% - 150px)" }}
{...field}
@@ -482,12 +495,14 @@ export const GroupsEditorViewer = (props: Props) => {
onChange={(e) => {
field.onChange(parseInt(e.target.value));
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
{t("seconds")}
</InputAdornment>
),
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t("seconds")}
</InputAdornment>
),
}
}}
/>
</Item>
@@ -508,12 +523,14 @@ export const GroupsEditorViewer = (props: Props) => {
onChange={(e) => {
field.onChange(parseInt(e.target.value));
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
{t("millis")}
</InputAdornment>
),
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t("millis")}
</InputAdornment>
),
}
}}
/>
</Item>
@@ -725,7 +742,7 @@ export const GroupsEditorViewer = (props: Props) => {
}
setPrependSeq([formIns.getValues(), ...prependSeq]);
} catch (err: any) {
Notice.error(err.message || err.toString());
showNotice('error', err.message || err.toString());
}
}}
>
@@ -747,7 +764,7 @@ export const GroupsEditorViewer = (props: Props) => {
}
setAppendSeq([...appendSeq, formIns.getValues()]);
} catch (err: any) {
Notice.error(err.message || err.toString());
showNotice('error', err.message || err.toString());
}
}}
>
@@ -889,10 +906,9 @@ export const GroupsEditorViewer = (props: Props) => {
padding: {
top: 33, // 顶部padding防止遮挡snippets
},
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
getSystem() === "windows" ? ", twemoji mozilla" : ""
}`,
fontLigatures: true, // 连字符
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${getSystem() === "windows" ? ", twemoji mozilla" : ""
}`,
fontLigatures: false, // 连字符
smoothScrolling: true, // 平滑滚动
}}
onChange={(value) => setCurrData(value)}
+161 -40
View File
@@ -22,8 +22,9 @@ import {
readProfileFile,
updateProfile,
saveProfileFile,
getNextUpdateTime,
} from "@/services/cmds";
import { Notice } from "@/components/base";
import { showNotice } from "@/services/noticeService";
import { GroupsEditorViewer } from "@/components/profile/groups-editor-viewer";
import { RulesEditorViewer } from "@/components/profile/rules-editor-viewer";
import { EditorViewer } from "@/components/profile/editor-viewer";
@@ -66,8 +67,106 @@ export const ProfileItem = (props: Props) => {
const loadingCache = useLoadingCache();
const setLoadingCache = useSetLoadingCache();
// 新增状态:是否显示下次更新时间
const [showNextUpdate, setShowNextUpdate] = useState(false);
const [nextUpdateTime, setNextUpdateTime] = useState("");
const { uid, name = "Profile", extra, updated = 0, option } = itemData;
// 获取下次更新时间的函数
const fetchNextUpdateTime = useLockFn(async (forceRefresh = false) => {
if (itemData.option?.update_interval && itemData.option.update_interval > 0) {
try {
console.log(`尝试获取配置 ${itemData.uid} 的下次更新时间`);
// 如果需要强制刷新,先触发Timer.refresh()
if (forceRefresh) {
// 这里可以通过一个新的API来触发刷新,但目前我们依赖patch_profile中的刷新
console.log(`强制刷新定时器任务`);
}
const nextUpdate = await getNextUpdateTime(itemData.uid);
console.log(`获取到下次更新时间结果:`, nextUpdate);
if (nextUpdate) {
const nextUpdateDate = dayjs(nextUpdate * 1000);
const now = dayjs();
// 如果已经过期,显示"更新失败"
if (nextUpdateDate.isBefore(now)) {
setNextUpdateTime(t("Last Update failed"));
} else {
// 否则显示剩余时间
const diffMinutes = nextUpdateDate.diff(now, 'minute');
if (diffMinutes < 60) {
if (diffMinutes <= 0) {
setNextUpdateTime(`${t("Next Up")} <1m`);
} else {
setNextUpdateTime(`${t("Next Up")} ${diffMinutes}m`);
}
} else {
const hours = Math.floor(diffMinutes / 60);
const mins = diffMinutes % 60;
setNextUpdateTime(`${t("Next Up")} ${hours}h ${mins}m`);
}
}
} else {
console.log(`返回的下次更新时间为空`);
setNextUpdateTime(t("No schedule"));
}
} catch (err) {
console.error(`获取下次更新时间出错:`, err);
setNextUpdateTime(t("Unknown"));
}
} else {
console.log(`该配置未设置更新间隔或间隔为0`);
setNextUpdateTime(t("Auto update disabled"));
}
});
// 切换显示模式的函数
const toggleUpdateTimeDisplay = (e: React.MouseEvent) => {
e.stopPropagation();
if (!showNextUpdate) {
fetchNextUpdateTime();
}
setShowNextUpdate(!showNextUpdate);
};
// 当组件加载或更新间隔变化时更新下次更新时间
useEffect(() => {
if (showNextUpdate) {
fetchNextUpdateTime();
}
}, [showNextUpdate, itemData.option?.update_interval, updated]);
// 订阅定时器更新事件
useEffect(() => {
// 处理定时器更新事件 - 这个事件专门用于通知定时器变更
const handleTimerUpdate = (event: any) => {
const updatedUid = event.payload as string;
// 只有当更新的是当前配置时才刷新显示
if (updatedUid === itemData.uid && showNextUpdate) {
console.log(`收到定时器更新事件: uid=${updatedUid}`);
setTimeout(() => {
fetchNextUpdateTime(true);
}, 1000);
}
};
// 只注册定时器更新事件监听
window.addEventListener('verge://timer-updated', handleTimerUpdate as EventListener);
return () => {
// 清理事件监听
window.removeEventListener('verge://timer-updated', handleTimerUpdate as EventListener);
};
}, [showNextUpdate, itemData.uid]);
// local file mode
// remote file mode
// remote file mode
@@ -172,7 +271,7 @@ export const ProfileItem = (props: Props) => {
try {
await viewProfile(itemData.uid);
} catch (err: any) {
Notice.error(err?.message || err.toString());
showNotice('error', err?.message || err.toString());
}
});
@@ -183,12 +282,6 @@ export const ProfileItem = (props: Props) => {
setAnchorEl(null);
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
// 存储原始设置以便回退后恢复
const originalOptions = {
with_proxy: itemData.option?.with_proxy,
self_proxy: itemData.option?.self_proxy
};
// 根据类型设置初始更新选项
const option: Partial<IProfileOption> = {};
if (type === 0) {
@@ -205,31 +298,15 @@ export const ProfileItem = (props: Props) => {
}
try {
// 尝试正常更新
// 调用后端更新(后端会自动处理回退逻辑)
await updateProfile(itemData.uid, option);
Notice.success(t("Update subscription successfully"));
// 更新成功,刷新列表
showNotice('success', t("Update subscription successfully"));
mutate("getProfiles");
} catch (err: any) {
// 更新失败,尝试使用自身代理
const errmsg = err?.message || err.toString();
Notice.info(t("Update failed, retrying with Clash proxy..."));
try {
await updateProfile(itemData.uid, {
with_proxy: false,
self_proxy: true
});
Notice.success(t("Update with Clash proxy successfully"));
await updateProfile(itemData.uid, originalOptions);
mutate("getProfiles");
} catch (retryErr: any) {
const retryErrmsg = retryErr?.message || retryErr.toString();
Notice.error(
`${t("Update failed even with Clash proxy")}: ${retryErrmsg.replace(/error sending request for url (\S+?): /, "")}`,
);
}
// 更新完全失败(包括后端的回退尝试)
// 不需要做处理,后端会通过事件通知系统发送错误
} finally {
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));
}
@@ -268,7 +345,7 @@ export const ProfileItem = (props: Props) => {
},
{ label: "Open File", handler: onOpenFile, disabled: false },
{ label: "Update", handler: () => onUpdate(0), disabled: false },
{ label: "Update(Proxy)", handler: () => onUpdate(2), disabled: false },
{ label: "Update via proxy", handler: () => onUpdate(2), disabled: false },
{
label: "Delete",
handler: () => {
@@ -325,6 +402,35 @@ export const ProfileItem = (props: Props) => {
justifyContent: "space-between",
};
// 监听自动更新事件
useEffect(() => {
const handleUpdateStarted = (event: CustomEvent) => {
if (event.detail.uid === itemData.uid) {
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
}
};
const handleUpdateCompleted = (event: CustomEvent) => {
if (event.detail.uid === itemData.uid) {
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));
// 更新完成后刷新显示
if (showNextUpdate) {
fetchNextUpdateTime();
}
}
};
// 注册事件监听
window.addEventListener('profile-update-started', handleUpdateStarted as EventListener);
window.addEventListener('profile-update-completed', handleUpdateCompleted as EventListener);
return () => {
// 清理事件监听
window.removeEventListener('profile-update-started', handleUpdateStarted as EventListener);
window.removeEventListener('profile-update-completed', handleUpdateCompleted as EventListener);
};
}, [itemData.uid, showNextUpdate]);
return (
<Box
sx={{
@@ -435,15 +541,30 @@ export const ProfileItem = (props: Props) => {
)
)}
{hasUrl && (
<Typography
noWrap
flex="1 0 auto"
fontSize={14}
textAlign="right"
title={`${t("Update Time")}: ${parseExpire(updated)}`}
>
{updated > 0 ? dayjs(updated * 1000).fromNow() : ""}
</Typography>
<Box sx={{ display: "flex", justifyContent: "flex-end", ml: "auto" }}>
<Typography
noWrap
component="span"
fontSize={14}
textAlign="right"
title={showNextUpdate ? t("Click to show last update time") : `${t("Update Time")}: ${parseExpire(updated)}\n${t("Click to show next update")}`}
sx={{
cursor: "pointer",
display: "inline-block",
borderBottom: "1px dashed transparent",
transition: "all 0.2s",
"&:hover": {
borderBottomColor: "primary.main",
color: "primary.main",
}
}}
onClick={toggleUpdateTimeDisplay}
>
{showNextUpdate
? nextUpdateTime
: (updated > 0 ? dayjs(updated * 1000).fromNow() : "")}
</Typography>
</Box>
)}
</>
}
+2 -2
View File
@@ -12,10 +12,10 @@ import {
} from "@mui/material";
import { FeaturedPlayListRounded } from "@mui/icons-material";
import { viewProfile, readProfileFile, saveProfileFile } from "@/services/cmds";
import { Notice } from "@/components/base";
import { EditorViewer } from "@/components/profile/editor-viewer";
import { ProfileBox } from "./profile-box";
import { LogViewer } from "./log-viewer";
import { showNotice } from "@/services/noticeService";
interface Props {
logInfo?: [string, string][];
@@ -43,7 +43,7 @@ export const ProfileMore = (props: Props) => {
try {
await viewProfile(id);
} catch (err: any) {
Notice.error(err?.message || err.toString());
showNotice('error', err?.message || err.toString());
}
});
+78 -27
View File
@@ -19,12 +19,14 @@ import {
TextField,
} from "@mui/material";
import { createProfile, patchProfile } from "@/services/cmds";
import { BaseDialog, Notice, Switch } from "@/components/base";
import { BaseDialog, Switch } from "@/components/base";
import { version } from "@root/package.json";
import { FileInput } from "./file-input";
import { useProfiles } from "@/hooks/use-profiles";
import { showNotice } from "@/services/noticeService";
interface Props {
onChange: () => void;
onChange: (isActivating?: boolean) => void;
}
export interface ProfileViewerRef {
@@ -40,6 +42,7 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
const [open, setOpen] = useState(false);
const [openType, setOpenType] = useState<"new" | "edit">("new");
const [loading, setLoading] = useState(false);
const { profiles } = useProfiles();
// file input
const fileDataRef = useRef<string | null>(null);
@@ -86,6 +89,10 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
const handleOk = useLockFn(
formIns.handleSubmit(async (form) => {
if (form.option?.timeout_seconds) {
form.option.timeout_seconds = +form.option.timeout_seconds;
}
setLoading(true);
try {
// 基本验证
@@ -103,17 +110,22 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
if (form.option?.user_agent === "") {
delete form.option.user_agent;
}
const name = form.name || `${form.type} file`;
const item = { ...form, name };
const isRemote = form.type === "remote";
const isUpdate = openType === "edit";
// 判断是否是当前激活的配置
const isActivating =
isUpdate && form.uid === (profiles?.current ?? "");
// 保存原始代理设置以便回退成功后恢复
const originalOptions = {
const originalOptions = {
with_proxy: form.option?.with_proxy,
self_proxy: form.option?.self_proxy
self_proxy: form.option?.self_proxy,
};
// 执行创建或更新操作,本地配置不需要回退机制
if (!isRemote) {
if (openType === "new") {
@@ -134,50 +146,62 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
}
} catch (err) {
// 首次创建/更新失败,尝试使用自身代理
Notice.info(t("Profile creation failed, retrying with Clash proxy..."));
showNotice(
"info",
t("Profile creation failed, retrying with Clash proxy..."),
);
// 使用自身代理的配置
const retryItem = {
...item,
option: {
...item.option,
with_proxy: false,
self_proxy: true
}
self_proxy: true,
},
};
// 使用自身代理再次尝试
if (openType === "new") {
await createProfile(retryItem, fileDataRef.current);
} else {
if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, retryItem);
// 编辑模式下恢复原始代理设置
await patchProfile(form.uid, { option: originalOptions });
}
Notice.success(t("Profile creation succeeded with Clash proxy"));
showNotice(
"success",
t("Profile creation succeeded with Clash proxy"),
);
}
}
// 成功后的操作
setOpen(false);
setTimeout(() => formIns.reset(), 500);
fileDataRef.current = null;
props.onChange();
// 优化:UI先关闭,异步通知父组件
setTimeout(() => {
props.onChange(isActivating);
}, 0);
} catch (err: any) {
Notice.error(err.message || err.toString());
showNotice("error", err.message || err.toString());
} finally {
setLoading(false);
}
})
}),
);
const handleClose = () => {
setOpen(false);
fileDataRef.current = null;
setTimeout(() => formIns.reset(), 500);
try {
setOpen(false);
fileDataRef.current = null;
setTimeout(() => formIns.reset(), 500);
} catch { }
};
const text = {
@@ -262,6 +286,29 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
/>
)}
/>
<Controller
name="option.timeout_seconds"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
type="number"
placeholder="60"
label={t("HTTP Request Timeout")}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t("seconds")}
</InputAdornment>
),
},
}}
/>
)}
/>
</>
)}
@@ -275,10 +322,14 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
{...field}
type="number"
label={t("Update Interval")}
InputProps={{
endAdornment: (
<InputAdornment position="end">{t("mins")}</InputAdornment>
),
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t("mins")}
</InputAdornment>
),
},
}}
/>
)}
@@ -332,7 +383,7 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
)}
</BaseDialog>
);
}
},
);
const StyledBox = styled(Box)(() => ({
@@ -33,13 +33,13 @@ import {
} from "@mui/icons-material";
import { ProxyItem } from "@/components/profile/proxy-item";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import { Notice } from "@/components/base";
import getSystem from "@/utils/get-system";
import { BaseSearchBox } from "../base/base-search-box";
import { Virtuoso } from "react-virtuoso";
import MonacoEditor from "react-monaco-editor";
import { useThemeMode } from "@/services/states";
import parseUri from "@/utils/uri-parser";
import { showNotice } from "@/services/noticeService";
interface Props {
profileUid: string;
@@ -130,8 +130,9 @@ export const ProxiesEditorViewer = (props: Props) => {
}
}
};
const handleParse = () => {
let proxies = [] as IProxyConfig[];
// 优化:异步分片解析,避免主线程阻塞,解析完成后批量setState
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
let proxies: IProxyConfig[] = [];
let names: string[] = [];
let uris = "";
try {
@@ -139,10 +140,13 @@ export const ProxiesEditorViewer = (props: Props) => {
} catch {
uris = proxyUri;
}
uris
.trim()
.split("\n")
.forEach((uri) => {
const lines = uris.trim().split("\n");
let idx = 0;
const batchSize = 50;
function parseBatch() {
const end = Math.min(idx + batchSize, lines.length);
for (; idx < end; idx++) {
const uri = lines[idx];
try {
let proxy = parseUri(uri.trim());
if (!names.includes(proxy.name)) {
@@ -150,10 +154,16 @@ export const ProxiesEditorViewer = (props: Props) => {
names.push(proxy.name);
}
} catch (err: any) {
Notice.error(err.message || err.toString());
// 不阻塞主流程
}
});
return proxies;
}
if (idx < lines.length) {
setTimeout(parseBatch, 0);
} else {
cb(proxies);
}
}
parseBatch();
};
const fetchProfile = async () => {
let data = await readProfileFile(profileUid);
@@ -192,15 +202,25 @@ export const ProxiesEditorViewer = (props: Props) => {
}, [visualization]);
useEffect(() => {
if (prependSeq && appendSeq && deleteSeq)
setCurrData(
yaml.dump(
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
{
forceQuotes: true,
}
)
);
if (prependSeq && appendSeq && deleteSeq) {
const serialize = () => {
try {
setCurrData(
yaml.dump(
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
{ forceQuotes: true }
)
);
} catch (e) {
// 防止异常导致UI卡死
}
};
if (window.requestIdleCallback) {
window.requestIdleCallback(serialize);
} else {
setTimeout(serialize, 0);
}
}
}, [prependSeq, appendSeq, deleteSeq]);
useEffect(() => {
@@ -212,10 +232,11 @@ export const ProxiesEditorViewer = (props: Props) => {
const handleSave = useLockFn(async () => {
try {
await saveProfileFile(property, currData);
showNotice('success', t("Saved Successfully"));
onSave?.(prevData, currData);
onClose();
} catch (err: any) {
Notice.error(err.message || err.toString());
showNotice('error', err.toString());
}
});
@@ -275,8 +296,9 @@ export const ProxiesEditorViewer = (props: Props) => {
variant="contained"
startIcon={<VerticalAlignTopRounded />}
onClick={() => {
let proxies = handleParse();
setPrependSeq([...proxies, ...prependSeq]);
handleParseAsync((proxies) => {
setPrependSeq((prev) => [...proxies, ...prev]);
});
}}
>
{t("Prepend Proxy")}
@@ -288,8 +310,9 @@ export const ProxiesEditorViewer = (props: Props) => {
variant="contained"
startIcon={<VerticalAlignBottomRounded />}
onClick={() => {
let proxies = handleParse();
setAppendSeq([...appendSeq, ...proxies]);
handleParseAsync((proxies) => {
setAppendSeq((prev) => [...prev, ...proxies]);
});
}}
>
{t("Append Proxy")}
@@ -430,10 +453,9 @@ export const ProxiesEditorViewer = (props: Props) => {
padding: {
top: 33, // 顶部padding防止遮挡snippets
},
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
getSystem() === "windows" ? ", twemoji mozilla" : ""
}`,
fontLigatures: true, // 连字符
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${getSystem() === "windows" ? ", twemoji mozilla" : ""
}`,
fontLigatures: false, // 连字符
smoothScrolling: true, // 平滑滚动
}}
onChange={(value) => setCurrData(value)}
+186 -175
View File
@@ -34,13 +34,14 @@ import {
VerticalAlignBottomRounded,
} from "@mui/icons-material";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import { Notice, Switch } from "@/components/base";
import { Switch } from "@/components/base";
import getSystem from "@/utils/get-system";
import { RuleItem } from "@/components/profile/rule-item";
import { BaseSearchBox } from "../base/base-search-box";
import { Virtuoso } from "react-virtuoso";
import MonacoEditor from "react-monaco-editor";
import { useThemeMode } from "@/services/states";
import { showNotice } from "@/services/noticeService";
interface Props {
groupsUid: string;
@@ -75,161 +76,161 @@ const rules: {
noResolve?: boolean;
validator?: (value: string) => boolean;
}[] = [
{
name: "DOMAIN",
example: "example.com",
},
{
name: "DOMAIN-SUFFIX",
example: "example.com",
},
{
name: "DOMAIN-KEYWORD",
example: "example",
},
{
name: "DOMAIN-REGEX",
example: "example.*",
},
{
name: "GEOSITE",
example: "youtube",
},
{
name: "GEOIP",
example: "CN",
noResolve: true,
},
{
name: "SRC-GEOIP",
example: "CN",
},
{
name: "IP-ASN",
example: "13335",
noResolve: true,
validator: (value) => (+value ? true : false),
},
{
name: "SRC-IP-ASN",
example: "9808",
validator: (value) => (+value ? true : false),
},
{
name: "IP-CIDR",
example: "127.0.0.0/8",
noResolve: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
},
{
name: "IP-CIDR6",
example: "2620:0:2d0:200::7/32",
noResolve: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
},
{
name: "SRC-IP-CIDR",
example: "192.168.1.201/32",
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
},
{
name: "IP-SUFFIX",
example: "8.8.8.8/24",
noResolve: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
},
{
name: "SRC-IP-SUFFIX",
example: "192.168.1.201/8",
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
},
{
name: "SRC-PORT",
example: "7777",
validator: (value) => portValidator(value),
},
{
name: "DST-PORT",
example: "80",
validator: (value) => portValidator(value),
},
{
name: "IN-PORT",
example: "7890",
validator: (value) => portValidator(value),
},
{
name: "DSCP",
example: "4",
},
{
name: "PROCESS-NAME",
example: getSystem() === "windows" ? "chrome.exe" : "curl",
},
{
name: "PROCESS-PATH",
example:
getSystem() === "windows"
? "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
: "/usr/bin/wget",
},
{
name: "PROCESS-NAME-REGEX",
example: ".*telegram.*",
},
{
name: "PROCESS-PATH-REGEX",
example:
getSystem() === "windows" ? "(?i).*Application\\chrome.*" : ".*bin/wget",
},
{
name: "NETWORK",
example: "udp",
validator: (value) => ["tcp", "udp"].includes(value),
},
{
name: "UID",
example: "1001",
validator: (value) => (+value ? true : false),
},
{
name: "IN-TYPE",
example: "SOCKS/HTTP",
},
{
name: "IN-USER",
example: "mihomo",
},
{
name: "IN-NAME",
example: "ss",
},
{
name: "SUB-RULE",
example: "(NETWORK,tcp)",
},
{
name: "RULE-SET",
example: "providername",
noResolve: true,
},
{
name: "AND",
example: "((DOMAIN,baidu.com),(NETWORK,UDP))",
},
{
name: "OR",
example: "((NETWORK,UDP),(DOMAIN,baidu.com))",
},
{
name: "NOT",
example: "((DOMAIN,baidu.com))",
},
{
name: "MATCH",
required: false,
},
];
{
name: "DOMAIN",
example: "example.com",
},
{
name: "DOMAIN-SUFFIX",
example: "example.com",
},
{
name: "DOMAIN-KEYWORD",
example: "example",
},
{
name: "DOMAIN-REGEX",
example: "example.*",
},
{
name: "GEOSITE",
example: "youtube",
},
{
name: "GEOIP",
example: "CN",
noResolve: true,
},
{
name: "SRC-GEOIP",
example: "CN",
},
{
name: "IP-ASN",
example: "13335",
noResolve: true,
validator: (value) => (+value ? true : false),
},
{
name: "SRC-IP-ASN",
example: "9808",
validator: (value) => (+value ? true : false),
},
{
name: "IP-CIDR",
example: "127.0.0.0/8",
noResolve: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
},
{
name: "IP-CIDR6",
example: "2620:0:2d0:200::7/32",
noResolve: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
},
{
name: "SRC-IP-CIDR",
example: "192.168.1.201/32",
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
},
{
name: "IP-SUFFIX",
example: "8.8.8.8/24",
noResolve: true,
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
},
{
name: "SRC-IP-SUFFIX",
example: "192.168.1.201/8",
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
},
{
name: "SRC-PORT",
example: "7777",
validator: (value) => portValidator(value),
},
{
name: "DST-PORT",
example: "80",
validator: (value) => portValidator(value),
},
{
name: "IN-PORT",
example: "7890",
validator: (value) => portValidator(value),
},
{
name: "DSCP",
example: "4",
},
{
name: "PROCESS-NAME",
example: getSystem() === "windows" ? "chrome.exe" : "curl",
},
{
name: "PROCESS-PATH",
example:
getSystem() === "windows"
? "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
: "/usr/bin/wget",
},
{
name: "PROCESS-NAME-REGEX",
example: ".*telegram.*",
},
{
name: "PROCESS-PATH-REGEX",
example:
getSystem() === "windows" ? "(?i).*Application\\chrome.*" : ".*bin/wget",
},
{
name: "NETWORK",
example: "udp",
validator: (value) => ["tcp", "udp"].includes(value),
},
{
name: "UID",
example: "1001",
validator: (value) => (+value ? true : false),
},
{
name: "IN-TYPE",
example: "SOCKS/HTTP",
},
{
name: "IN-USER",
example: "mihomo",
},
{
name: "IN-NAME",
example: "ss",
},
{
name: "SUB-RULE",
example: "(NETWORK,tcp)",
},
{
name: "RULE-SET",
example: "providername",
noResolve: true,
},
{
name: "AND",
example: "((DOMAIN,baidu.com),(NETWORK,UDP))",
},
{
name: "OR",
example: "((NETWORK,UDP),(DOMAIN,baidu.com))",
},
{
name: "NOT",
example: "((DOMAIN,baidu.com))",
},
{
name: "MATCH",
required: false,
},
];
const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
@@ -324,16 +325,27 @@ export const RulesEditorViewer = (props: Props) => {
setDeleteSeq(obj?.delete || []);
}, [visualization]);
// 优化:异步处理大数据yaml.dump,避免UI卡死
useEffect(() => {
if (prependSeq && appendSeq && deleteSeq)
setCurrData(
yaml.dump(
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
{
forceQuotes: true,
}
)
);
if (prependSeq && appendSeq && deleteSeq) {
const serialize = () => {
try {
setCurrData(
yaml.dump(
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
{ forceQuotes: true }
)
);
} catch (e: any) {
showNotice('error', e?.message || e?.toString() || 'YAML dump error');
}
};
if (window.requestIdleCallback) {
window.requestIdleCallback(serialize);
} else {
setTimeout(serialize, 0);
}
}
}, [prependSeq, appendSeq, deleteSeq]);
const fetchProfile = async () => {
@@ -406,18 +418,18 @@ export const RulesEditorViewer = (props: Props) => {
}
const condition = ruleType.required ?? true ? ruleContent : "";
return `${ruleType.name}${condition ? "," + condition : ""},${proxyPolicy}${
ruleType.noResolve && noResolve ? ",no-resolve" : ""
}`;
return `${ruleType.name}${condition ? "," + condition : ""},${proxyPolicy}${ruleType.noResolve && noResolve ? ",no-resolve" : ""
}`;
};
const handleSave = useLockFn(async () => {
try {
await saveProfileFile(property, currData);
showNotice('success', t("Saved Successfully"));
onSave?.(prevData, currData);
onClose();
} catch (err: any) {
Notice.error(err.message || err.toString());
showNotice('error', err.toString());
}
});
@@ -545,7 +557,7 @@ export const RulesEditorViewer = (props: Props) => {
if (prependSeq.includes(raw)) return;
setPrependSeq([raw, ...prependSeq]);
} catch (err: any) {
Notice.error(err.message || err.toString());
showNotice('error', err.message || err.toString());
}
}}
>
@@ -563,7 +575,7 @@ export const RulesEditorViewer = (props: Props) => {
if (appendSeq.includes(raw)) return;
setAppendSeq([...appendSeq, raw]);
} catch (err: any) {
Notice.error(err.message || err.toString());
showNotice('error', err.message || err.toString());
}
}}
>
@@ -699,10 +711,9 @@ export const RulesEditorViewer = (props: Props) => {
padding: {
top: 33, // 顶部padding防止遮挡snippets
},
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
getSystem() === "windows" ? ", twemoji mozilla" : ""
}`,
fontLigatures: true, // 连字符
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${getSystem() === "windows" ? ", twemoji mozilla" : ""
}`,
fontLigatures: false, // 连字符
smoothScrolling: true, // 平滑滚动
}}
onChange={(value) => setCurrData(value)}
+6 -6
View File
@@ -21,7 +21,7 @@ import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import { proxyProviderUpdate } from "@/services/api";
import { useAppData } from "@/providers/app-data-provider";
import { Notice } from "@/components/base";
import { showNotice } from "@/services/noticeService";
import { StorageOutlined, RefreshRounded } from "@mui/icons-material";
import dayjs from "dayjs";
import parseTraffic from "@/utils/parse-traffic";
@@ -81,9 +81,9 @@ export const ProviderButton = () => {
await refreshProxy();
await refreshProxyProviders();
Notice.success(`${name} 更新成功`);
showNotice('success', `${name} 更新成功`);
} catch (err: any) {
Notice.error(`${name} 更新失败: ${err?.message || err.toString()}`);
showNotice('error', `${name} 更新失败: ${err?.message || err.toString()}`);
} finally {
// 清除更新状态
setUpdating(prev => ({ ...prev, [name]: false }));
@@ -96,7 +96,7 @@ export const ProviderButton = () => {
// 获取所有provider的名称
const allProviders = Object.keys(proxyProviders || {});
if (allProviders.length === 0) {
Notice.info("没有可更新的代理提供者");
showNotice('info', "没有可更新的代理提供者");
return;
}
@@ -123,9 +123,9 @@ export const ProviderButton = () => {
await refreshProxy();
await refreshProxyProviders();
Notice.success("全部代理提供者更新成功");
showNotice('success', "全部代理提供者更新成功");
} catch (err: any) {
Notice.error(`更新失败: ${err?.message || err.toString()}`);
showNotice('error', `更新失败: ${err?.message || err.toString()}`);
} finally {
// 清除所有更新状态
setUpdating({});
+1 -1
View File
@@ -117,7 +117,7 @@ const LetterItem = memo(
top: 0,
right: 0,
});
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const updateTooltipPosition = useCallback(() => {
if (!letterRef.current) return;
+2
View File
@@ -198,3 +198,5 @@ export const useRenderList = (mode: string) => {
currentColumns: col,
};
};
// 优化建议:如有大数据量,建议用虚拟滚动(已在 ProxyGroups 组件中实现),此处无需额外处理。
+6 -6
View File
@@ -19,10 +19,10 @@ import {
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import { ruleProviderUpdate } from "@/services/api";
import { Notice } from "@/components/base";
import { StorageOutlined, RefreshRounded } from "@mui/icons-material";
import { useAppData } from "@/providers/app-data-provider";
import dayjs from "dayjs";
import { showNotice } from "@/services/noticeService";
// 定义规则提供者类型
interface RuleProviderItem {
@@ -67,9 +67,9 @@ export const ProviderButton = () => {
await refreshRules();
await refreshRuleProviders();
Notice.success(`${name} 更新成功`);
showNotice('success', `${name} 更新成功`);
} catch (err: any) {
Notice.error(`${name} 更新失败: ${err?.message || err.toString()}`);
showNotice('error', `${name} 更新失败: ${err?.message || err.toString()}`);
} finally {
// 清除更新状态
setUpdating(prev => ({ ...prev, [name]: false }));
@@ -82,7 +82,7 @@ export const ProviderButton = () => {
// 获取所有provider的名称
const allProviders = Object.keys(ruleProviders || {});
if (allProviders.length === 0) {
Notice.info("没有可更新的规则提供者");
showNotice('info', "没有可更新的规则提供者");
return;
}
@@ -109,9 +109,9 @@ export const ProviderButton = () => {
await refreshRules();
await refreshRuleProviders();
Notice.success("全部规则提供者更新成功");
showNotice('success', "全部规则提供者更新成功");
} catch (err: any) {
Notice.error(`更新失败: ${err?.message || err.toString()}`);
showNotice('error', `更新失败: ${err?.message || err.toString()}`);
} finally {
// 清除所有更新状态
setUpdating({});
@@ -2,14 +2,12 @@ import { useState, useRef, memo, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { useVerge } from "@/hooks/use-verge";
import { Notice } from "@/components/base";
import { isValidUrl } from "@/utils/helper";
import { useLockFn } from "ahooks";
import {
TextField,
Button,
Grid2,
Box,
Grid,
Stack,
IconButton,
InputAdornment,
@@ -17,6 +15,7 @@ import {
import Visibility from "@mui/icons-material/Visibility";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import { saveWebdavConfig, createWebdavBackup } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
export interface BackupConfigViewerProps {
onBackupSuccess: () => Promise<void>;
@@ -83,21 +82,21 @@ export const BackupConfigViewer = memo(
if (!url) {
urlRef.current?.focus();
Notice.error(t("WebDAV URL Required"));
showNotice('error', t("WebDAV URL Required"));
throw new Error(t("WebDAV URL Required"));
} else if (!isValidUrl(url)) {
urlRef.current?.focus();
Notice.error(t("Invalid WebDAV URL"));
showNotice('error', t("Invalid WebDAV URL"));
throw new Error(t("Invalid WebDAV URL"));
}
if (!username) {
usernameRef.current?.focus();
Notice.error(t("WebDAV URL Required"));
showNotice('error', t("WebDAV URL Required"));
throw new Error(t("Username Required"));
}
if (!password) {
passwordRef.current?.focus();
Notice.error(t("WebDAV URL Required"));
showNotice('error', t("WebDAV URL Required"));
throw new Error(t("Password Required"));
}
};
@@ -111,11 +110,11 @@ export const BackupConfigViewer = memo(
data.username.trim(),
data.password,
).then(() => {
Notice.success(t("WebDAV Config Saved"));
showNotice('success', t("WebDAV Config Saved"));
onSaveSuccess();
});
} catch (error) {
Notice.error(t("WebDAV Config Save Failed", { error }), 3000);
showNotice('error', t("WebDAV Config Save Failed", { error }), 3000);
} finally {
setLoading(false);
}
@@ -126,11 +125,11 @@ export const BackupConfigViewer = memo(
try {
setLoading(true);
await createWebdavBackup().then(async () => {
showNotice('success', t("Backup Created"));
await onBackupSuccess();
Notice.success(t("Backup Created"));
});
} catch (error) {
Notice.error(t("Backup Failed", { error }));
showNotice('error', t("Backup Failed", { error }));
} finally {
setLoading(false);
}
@@ -138,10 +137,10 @@ export const BackupConfigViewer = memo(
return (
<form onSubmit={(e) => e.preventDefault()}>
<Grid2 container spacing={2}>
<Grid2 size={{ xs: 12, sm: 9 }}>
<Grid2 container spacing={2}>
<Grid2 size={{ xs: 12 }}>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 9 }}>
<Grid container spacing={2}>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
label={t("WebDAV Server URL")}
@@ -153,8 +152,8 @@ export const BackupConfigViewer = memo(
spellCheck="false"
inputRef={urlRef}
/>
</Grid2>
<Grid2 size={{ xs: 6 }}>
</Grid>
<Grid size={{ xs: 6 }}>
<TextField
label={t("Username")}
variant="outlined"
@@ -165,8 +164,8 @@ export const BackupConfigViewer = memo(
spellCheck="false"
inputRef={usernameRef}
/>
</Grid2>
<Grid2 size={{ xs: 6 }}>
</Grid>
<Grid size={{ xs: 6 }}>
<TextField
label={t("Password")}
type={showPassword ? "text" : "password"}
@@ -192,10 +191,10 @@ export const BackupConfigViewer = memo(
},
}}
/>
</Grid2>
</Grid2>
</Grid2>
<Grid2 size={{ xs: 12, sm: 3 }}>
</Grid>
</Grid>
</Grid>
<Grid size={{ xs: 12, sm: 3 }}>
<Stack
direction="column"
justifyContent="space-between"
@@ -237,8 +236,8 @@ export const BackupConfigViewer = memo(
</>
)}
</Stack>
</Grid2>
</Grid2>
</Grid>
</Grid>
</form>
);
},
@@ -12,7 +12,6 @@ import {
TableRow,
TablePagination,
} from "@mui/material";
import { Notice } from "@/components/base";
import { Typography } from "@mui/material";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
@@ -24,6 +23,7 @@ import {
} from "@/services/cmds";
import DeleteIcon from "@mui/icons-material/Delete";
import RestoreIcon from "@mui/icons-material/Restore";
import { showNotice } from "@/services/noticeService";
export type BackupFile = IWebDavFile & {
platform: string;
@@ -61,7 +61,7 @@ export const BackupTableViewer = memo(
const handleRestore = useLockFn(async (filename: string) => {
await restoreWebDavBackup(filename).then(() => {
Notice.success(t("Restore Success, App will restart in 1s"));
showNotice('success', t("Restore Success, App will restart in 1s"));
});
await restartApp();
});
@@ -79,7 +79,7 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
}
const backupTime = dayjs(fileBackupTimeStr[0], DATE_FORMAT);
const allowApply = OS === platform;
const allowApply = true;
return {
...file,
platform,
@@ -1,6 +1,6 @@
import { mutate } from "swr";
import { forwardRef, useImperativeHandle, useState } from "react";
import { BaseDialog, DialogRef, Notice } from "@/components/base";
import { BaseDialog, DialogRef } from "@/components/base";
import { useTranslation } from "react-i18next";
import { useVerge } from "@/hooks/use-verge";
import { useLockFn } from "ahooks";
@@ -11,14 +11,15 @@ import {
} from "@mui/icons-material";
import {
Box,
Button,
Chip,
CircularProgress,
List,
ListItemButton,
ListItemText,
} from "@mui/material";
import { changeClashCore, restartCore } from "@/services/cmds";
import { closeAllConnections, upgradeCore } from "@/services/api";
import { showNotice } from "@/services/noticeService";
const VALID_CORE = [
{ name: "Mihomo", core: "verge-mihomo", chip: "Release Version" },
@@ -32,6 +33,8 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
const [open, setOpen] = useState(false);
const [upgrading, setUpgrading] = useState(false);
const [restarting, setRestarting] = useState(false);
const [changingCore, setChangingCore] = useState<string | null>(null);
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
@@ -44,25 +47,37 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
if (core === clash_core) return;
try {
setChangingCore(core);
closeAllConnections();
await changeClashCore(core);
const errorMsg = await changeClashCore(core);
if (errorMsg) {
showNotice('error', errorMsg);
setChangingCore(null);
return;
}
mutateVerge();
setTimeout(() => {
mutate("getClashConfig");
mutate("getVersion");
}, 100);
// Notice.success(t("Switched to _clash Core", { core: `${core}` }), 1000);
setChangingCore(null);
}, 500);
} catch (err: any) {
Notice.error(err?.message || err.toString());
setChangingCore(null);
showNotice('error', err.message || err.toString());
}
});
const onRestart = useLockFn(async () => {
try {
setRestarting(true);
await restartCore();
Notice.success(t(`Clash Core Restarted`), 1000);
showNotice('success', t(`Clash Core Restarted`));
setRestarting(false);
} catch (err: any) {
Notice.error(err?.message || err.toString());
setRestarting(false);
showNotice('error', err.message || err.toString());
}
});
@@ -71,10 +86,14 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
setUpgrading(true);
await upgradeCore();
setUpgrading(false);
Notice.success(t(`Core Version Updated`), 1000);
showNotice('success', t(`Core Version Updated`));
} catch (err: any) {
setUpgrading(false);
Notice.error(err?.response.data.message || err.toString());
const errMsg = err.response?.data?.message || err.toString();
const showMsg = errMsg.includes("already using latest version")
? "Already Using Latest Core Version"
: errMsg;
showNotice('error', t(showMsg));
}
});
@@ -91,19 +110,23 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
startIcon={<SwitchAccessShortcutRounded />}
loadingPosition="start"
loading={upgrading}
disabled={restarting || changingCore !== null}
sx={{ marginRight: "8px" }}
onClick={onUpgrade}
>
{t("Upgrade")}
</LoadingButton>
<Button
<LoadingButton
variant="contained"
size="small"
onClick={onRestart}
startIcon={<RestartAltRounded />}
loadingPosition="start"
loading={restarting}
disabled={upgrading}
onClick={onRestart}
>
{t("Restart")}
</Button>
</LoadingButton>
</Box>
</Box>
}
@@ -126,9 +149,14 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
key={each.core}
selected={each.core === clash_core}
onClick={() => onCoreChange(each.core)}
disabled={changingCore !== null || restarting || upgrading}
>
<ListItemText primary={each.name} secondary={`/${each.core}`} />
<Chip label={t(`${each.chip}`)} size="small" />
{changingCore === each.core ? (
<CircularProgress size={20} sx={{ mr: 1 }} />
) : (
<Chip label={t(`${each.chip}`)} size="small" />
)}
</ListItemButton>
))}
</List>

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