Compare commits

...
242 Commits
Author SHA1 Message Date
GyDi afa244dcb0 v1.1.2 2022-11-02 00:54:51 +08:00
GyDi 4ea5bb2390 fix: check remote profile 2022-11-02 00:51:25 +08:00
GyDi e545d552f6 chore: update log 2022-11-02 00:44:45 +08:00
GyDi 56fe7b3596 feat: add version on tray 2022-11-01 23:29:59 +08:00
GyDi eb28ec866a feat: add animation 2022-10-29 23:36:10 +08:00
GyDi 4649454282 fix: remove smoother 2022-10-29 20:05:55 +08:00
angrylid a45dc6efda feat: add animation to ProfileNew component (#252)
* chore: add .vscode to .gitignore

* feat: add animation to ProfileNew component
2022-10-29 20:02:51 +08:00
GyDi 9e56b9fbb5 fix: icon button color 2022-10-29 19:22:15 +08:00
GyDi 5504994cb9 feat: check remote profile field 2022-10-28 13:00:52 +08:00
GyDi acff6d0432 fix: init system proxy correctly 2022-10-28 01:33:21 +08:00
GyDi eea9cb7c5b fix: open file 2022-10-28 01:26:45 +08:00
GyDi c0ddddfb1f fix: reset proxy 2022-10-28 01:06:54 +08:00
GyDi f7dab3ca56 fix: init config error 2022-10-28 01:02:47 +08:00
GyDi e11b4038a3 feat: system tray support zh language 2022-10-28 00:40:29 +08:00
GyDi b635e64803 fix: adjust reset proxy 2022-10-27 21:14:14 +08:00
GyDi 20a194b49a fix: adjust code 2022-10-27 21:13:27 +08:00
GyDi 33a5fb8837 fix: add https proxy 2022-10-26 17:11:54 +08:00
GyDi 59dae640db fix: auto scroll into view when sorted proxies changed 2022-10-26 01:24:06 +08:00
GyDi a6ac75e97b feat: display delay check result timely 2022-10-26 01:11:02 +08:00
GyDi cc5b33a8ec fix: refresh proxies interval, close #235 2022-10-26 01:08:34 +08:00
GyDi 6e1a627b84 fix: style 2022-10-25 23:54:21 +08:00
GyDi 62d4c65e1c chore: update deps 2022-10-25 23:54:10 +08:00
GyDi 600134a3ac fix: fetch profile with system proxy, close #249 2022-10-25 23:45:24 +08:00
LooSheng df14af7337 fix: The profile is replaced when the request fails. (#246) 2022-10-23 17:22:26 +08:00
GyDi 2f740b570d fix: default dns config 2022-10-22 22:26:40 +08:00
GyDi 294d980b52 fix: kill clash when exit in service mode, close #241 2022-10-22 13:55:06 +08:00
GyDi c9d9909d74 chore: update mmdb 2022-10-22 13:30:08 +08:00
GyDi 90eeabae7b feat: update profile with system proxy/clash proxy 2022-10-18 23:19:21 +08:00
GyDi a32c77c5f1 chore: adjust code 2022-10-17 23:26:25 +08:00
GyDi 910846f2ce fix: icon button color inherit 2022-10-16 14:40:45 +08:00
GyDi 35d0438261 fix: app version to string 2022-10-16 14:38:57 +08:00
GyDi 515af472ce fix: break loop when core terminated 2022-10-14 14:46:15 +08:00
GyDi f062f7f9fe feat: change global mode ui, close #226 2022-10-13 11:54:52 +08:00
GyDi f68378041f feat: default user agent same with app version 2022-10-12 11:28:47 +08:00
GyDi 30ef3057ac v1.1.1 2022-10-11 23:03:12 +08:00
GyDi 48f3a934c9 chore: update log 2022-10-11 23:02:49 +08:00
GyDi 38d1fde84f fix: api error handle 2022-10-11 22:51:18 +08:00
GyDi dd78670a4b fix: clash meta not load geoip, close #212 2022-10-11 22:24:54 +08:00
GyDi b8a8190a43 fix: sort proxy during loading, close #221 2022-10-11 22:06:55 +08:00
GyDi 2d0989342f fix: not create windows when enable slient start 2022-10-11 21:50:00 +08:00
GyDi 15ff9b06a1 fix: root background color 2022-10-11 20:49:03 +08:00
GyDi 41b19f69de fix: create window correctly 2022-10-11 00:57:34 +08:00
GyDi f2bd6f1fce fix: set_activation_policy 2022-10-10 22:15:21 +08:00
GyDi ec94218a4b chore: rm aarch64 ci 2022-09-28 18:50:33 +08:00
GyDi c2e5c7cf38 chore: test ci 2022-09-28 17:26:32 +08:00
GyDi bac5734527 chore: test ci 2022-09-28 17:12:27 +08:00
GyDi bef4033d94 chore: fix ci 2022-09-28 16:04:05 +08:00
GyDi 64216cba67 chore: fix test ci 2022-09-28 15:19:50 +08:00
Particle_G a9d6167a9f chore: add support for windows arm64, close #216 (#209) 2022-09-28 15:13:24 +08:00
GyDi 6c6b40548f fix: disable spell check 2022-09-28 14:15:22 +08:00
苏业钦 a916d88e85 chore: add support linux arm64 (#215) 2022-09-28 10:40:41 +08:00
GyDi ab2f0548a3 chore: clash meta linux use compatible version 2022-09-27 00:16:22 +08:00
GyDi e30ba07285 feat: optimize config feedback 2022-09-26 20:46:29 +08:00
GyDi 1b336d973d fix: adjust init launch on dev 2022-09-26 01:35:19 +08:00
GyDi 7b1866737f v1.1.0 2022-09-26 01:27:56 +08:00
GyDi eedc4ab648 chore: update log 2022-09-26 01:27:32 +08:00
GyDi 5e429c7a94 fix: ignore disable auto launch error 2022-09-26 01:09:05 +08:00
GyDi 57d23eb043 fix(macos): set auto launch path to application 2022-09-25 23:36:55 +08:00
GyDi 5ebd9be89a fix: i18n 2022-09-25 01:41:46 +08:00
GyDi 5a743779e2 feat: show connections with table layout 2022-09-25 01:35:21 +08:00
GyDi 0495062110 fix: style 2022-09-24 20:55:40 +08:00
GyDi d522191f69 fix: save enable log on localstorage 2022-09-24 19:03:14 +08:00
Priestch bbbdc8b7a6 fix: typo in api.ts (#207) 2022-09-24 18:48:11 +08:00
GyDi c00ed8aa5a chore: use ubuntu latest 2022-09-24 16:08:39 +08:00
GyDi 0beaa94068 feat: show loading on proxy group delay check 2022-09-24 15:31:09 +08:00
GyDi 96e76665d6 fix: refresh clash ui await patch 2022-09-24 14:01:28 +08:00
Shun Li 6423a29600 feat: add chains[0] and process to connections display (#205)
* add chains[0] display

* add metadata.process to connections
2022-09-24 13:06:32 +08:00
GyDi 8bddf30dcf refactor(hotkey): use tauri global shortcut 2022-09-23 15:31:01 +08:00
GyDi a6b2db182d feat: adjust connection page ui 2022-09-23 00:08:52 +08:00
GyDi b9f3f9d859 Merge pull request #197 from FoundTheWOUT/main
Support ordering connection
2022-09-22 22:55:46 +08:00
GyDi 6331447dcd feat: yaml merge key 2022-09-21 22:15:24 +08:00
GyDi 4213ee660f feat: toggle log ws 2022-09-20 22:15:28 +08:00
GyDi 3bd9287c5d chore: update deps 2022-09-20 21:26:38 +08:00
GyDi b23d3f7c8b feat: add rule page 2022-09-18 23:19:02 +08:00
GyDi f8d9e5e027 feat: hotkey viewer 2022-09-18 15:52:53 +08:00
GyDi 8fa7fb3b1f feat: refresh ui when hotkey clicked 2022-09-18 15:50:03 +08:00
FoundTheWOUT 63d92a0872 Support ordering connection 2022-09-15 15:52:12 +08:00
GyDi 509d83365e feat: support hotkey (wip) 2022-09-14 01:19:02 +08:00
GyDi d6ab73c905 fix: remove dead code 2022-09-12 13:42:21 +08:00
GyDi 5a93ba05d5 fix: style 2022-09-12 13:42:13 +08:00
GyDi 2d2fdf0b1e fix: handle is none 2022-09-12 00:45:19 +08:00
GyDi cf96622261 fix: unused 2022-09-12 00:02:25 +08:00
GyDi 47c8ccb0e5 refactor: optimize 2022-09-11 20:58:55 +08:00
GyDi 02fdb8778b fix: style 2022-09-11 18:38:51 +08:00
GyDi 6bed7f0e66 fix: windows logo size 2022-09-11 17:50:58 +08:00
GyDi 7597d335b9 feat: hide window on macos 2022-09-09 16:42:35 +08:00
GyDi f32c5ba244 fix: do not kill sidecar during updating 2022-09-09 16:33:04 +08:00
GyDi ab53ab21e2 fix: delay update config 2022-09-09 16:30:27 +08:00
GyDi acfe5dbb49 fix: reduce logo size 2022-09-09 16:19:13 +08:00
GyDi f9b91fa189 feat: system proxy setting 2022-09-07 01:51:43 +08:00
GyDi 2462e68ba1 fix: window center 2022-09-06 22:18:06 +08:00
GyDi 019b2a1681 chore: update resource 2022-09-06 15:32:22 +08:00
GyDi e94a07b677 fix: log level warn value 2022-09-06 14:42:58 +08:00
GyDi c058c29755 feat: change default singleton port and support to change the port 2022-09-06 00:45:01 +08:00
GyDi 9e7c7ac163 feat: log info 2022-09-05 20:30:39 +08:00
GyDi fcee41f00d feat: kill clash by pid 2022-09-05 16:30:29 +08:00
GyDi ff2c1bf8ed feat: change clash port in dialog 2022-09-05 02:12:25 +08:00
GyDi a2cf26e7ed feat: add proxy item check loading 2022-09-04 23:53:48 +08:00
GyDi 71e6900375 feat: compatible with proxy providers health check 2022-09-04 22:55:54 +08:00
GyDi 3bdc98bd12 v1.0.6 2022-09-03 00:40:50 +08:00
GyDi 68d2a6e951 chore: update log 2022-09-03 00:39:39 +08:00
GyDi acf47ac947 chore: update deps 2022-09-02 23:06:00 +08:00
GyDi fda24e5f5a chore: update deps 2022-09-02 01:35:06 +08:00
GyDi 05eca8e4d8 chore: use ubuntu 18.04 2022-09-02 01:27:06 +08:00
GyDi b915f3b1a9 feat: add empty ui 2022-09-02 01:09:38 +08:00
GyDi ab58968f4d feat: complete i18n 2022-09-02 01:05:45 +08:00
GyDi 820d1e7570 fix: increase delay checker concurrency 2022-09-02 00:41:43 +08:00
GyDi a120c8cf98 fix: external controller allow lan 2022-09-02 00:24:19 +08:00
GyDi 5a35ea116f chore: update clash meta version 2022-09-02 00:11:52 +08:00
GyDi e72ad1f030 fix: remove useless optimizations 2022-08-31 21:44:23 +08:00
GyDi a17362437a chore: test ci 2022-08-29 17:26:09 +08:00
GyDi 8643dc43b1 chore: update clash 2022-08-29 11:11:55 +08:00
GyDi e1c869a358 chore: add test ci os version 2022-08-29 11:02:14 +08:00
GyDi 2f5b8d9abe Merge branch 'main' of github.com:zzzgydi/clash-verge 2022-08-24 22:41:36 +08:00
GyDi db324f54eb chore: update auto launch 2022-08-24 22:41:12 +08:00
GyDi 3242efb1a2 fix: reduce unsafe unwrap 2022-08-23 15:19:04 +08:00
FoundTheWOUT 30f9f1a021 fix: timer restore at app launch 2022-08-23 10:35:36 +08:00
GyDi 3f58d05aa7 fix: adjust log text 2022-08-19 17:11:59 +08:00
GyDi c611a51575 fix: only script profile can display console 2022-08-17 01:45:37 +08:00
GyDi 73458dcd28 v1.0.5 2022-08-16 23:56:29 +08:00
GyDi 23ebeb1cc0 chore: update log 2022-08-16 23:56:04 +08:00
GyDi a7ba9f1886 fix: fill button title attr 2022-08-16 23:46:33 +08:00
GyDi d5192e2244 feat: windows portable version do not check update 2022-08-16 01:53:40 +08:00
GyDi 7eb595170f fix: do not reset system proxy when consistent 2022-08-16 01:27:32 +08:00
GyDi bd576ca808 fix: adjust web ui item style 2022-08-16 01:08:54 +08:00
GyDi 2f8146b11f fix: clash field state error 2022-08-16 00:48:06 +08:00
GyDi 7f321c89cb feat: adjust clash info parsing logs 2022-08-15 20:21:43 +08:00
GyDi fa65f606b8 fix: badge color error 2022-08-15 20:15:44 +08:00
GyDi 7a3285adaf fix: web ui port value error 2022-08-15 20:14:33 +08:00
GyDi 8bf78fef10 fix: delay show window 2022-08-15 01:37:26 +08:00
GyDi 78f97ce4df feat: adjust runtime config 2022-08-15 01:30:37 +08:00
GyDi 66ccbf70f8 feat: support restart app on tray 2022-08-15 01:22:39 +08:00
GyDi cb48600b40 fix: adjust dialog action button variant 2022-08-15 00:55:35 +08:00
GyDi 33ce235713 feat: optimize profile page 2022-08-14 23:10:19 +08:00
GyDi f1a68ece01 fix: script code error 2022-08-12 23:41:25 +08:00
GyDi dd563360af fix: script exception handle 2022-08-12 11:14:34 +08:00
GyDi 7f6dac4271 feat: refactor 2022-08-12 03:20:55 +08:00
GyDi 178fd8e828 feat: adjust tun mode config 2022-08-11 03:26:08 +08:00
GyDi 1641e02a7d feat: reimplement enhanced mode 2022-08-11 02:55:10 +08:00
GyDi cfd04e9bb4 feat: use rquickjs crate 2022-08-10 13:05:48 +08:00
GyDi 38effaf740 feat: reimplement enhanced mode 2022-08-09 21:15:55 +08:00
GyDi a68eb4a73e chore: format 2022-08-09 14:33:47 +08:00
GyDi c02990ef98 fix: change fields 2022-08-09 14:31:59 +08:00
FoundTheWOUT 5aa7d5ffe9 fix: silent start (#150) 2022-08-09 14:14:06 +08:00
GyDi 4942b0fca5 fix: save profile when update 2022-08-08 23:17:22 +08:00
GyDi aed1bdff5a fix: list compare wrong 2022-08-08 23:16:28 +08:00
GyDi 929c840006 fix: button color 2022-08-08 23:15:05 +08:00
GyDi 57aef1d3c2 chore: rm code 2022-08-08 22:30:13 +08:00
GyDi c1734a094c fix: limit theme mode value 2022-08-08 22:28:44 +08:00
GyDi 99c46685ac feat: finish clash field control 2022-08-08 22:14:03 +08:00
GyDi 066b08040a feat: clash field viewer wip 2022-08-08 01:51:30 +08:00
GyDi 35de2334fb fix: add valid clash field 2022-08-07 20:52:50 +08:00
GyDi 5564c966a5 feat: support web ui 2022-08-06 21:56:54 +08:00
GyDi 0891b5e7b7 feat: adjust setting page style 2022-08-06 03:48:03 +08:00
GyDi f3341f201f refactor: ts path alias 2022-08-06 02:35:11 +08:00
GyDi bf0dafabe2 fix: icon style 2022-08-06 01:44:33 +08:00
GyDi 60f6587169 fix: reduce unwrap 2022-07-30 23:32:52 +08:00
GyDi a9bf32919e feat: runtime config viewer 2022-07-25 01:20:13 +08:00
GyDi 7633f9f88b chore: fix updater script 2022-07-18 12:55:59 +08:00
GyDi 9b56233938 v1.0.4 2022-07-17 17:56:07 +08:00
GyDi 65074264b8 chore: update log 2022-07-17 17:52:41 +08:00
GyDi 4f6fceb87f feat: improve log rule 2022-07-17 17:39:44 +08:00
GyDi 659fdd1d37 fix: import mod 2022-07-17 16:51:54 +08:00
GyDi 8bce2ce040 feat: theme mode support follows system 2022-07-17 16:02:17 +08:00
GyDi 55cc83a5d4 chore: update deps 2022-07-17 14:54:09 +08:00
GyDi 6a51e93ded refactor: mode manage on tray 2022-07-13 02:26:54 +08:00
GyDi fcf570e96e chore: rm code 2022-07-13 01:07:29 +08:00
GyDi cbc184e953 fix: add tray separator 2022-07-13 01:05:53 +08:00
GyDi 98f9063352 Merge pull request #128 from Limsanity/main
feat: support switch proxy mode
2022-07-13 01:02:14 +08:00
limsanity c278f1af00 style: resolve formatting problem 2022-07-13 00:54:47 +08:00
limsanity fbb17a0ba5 feat(system tray): support switch rule/global/direct/script mode in system tray 2022-07-13 00:43:27 +08:00
GyDi 8637a9823e chore: update deps 2022-07-11 00:41:21 +08:00
GyDi d717fe7e8c chore: update clash version 2022-07-08 01:42:14 +08:00
GyDi 5e8dfe7267 chore: update aarch rule 2022-07-07 02:54:20 +08:00
GyDi aaa4fbcdbd chore: aarch upload script 2022-07-07 02:53:53 +08:00
GyDi 150f0cf486 fix: instantiate core after init app, close #122 2022-07-06 15:14:33 +08:00
GyDi 711b220a05 chore: update clash version 2022-07-06 00:54:01 +08:00
GyDi b615c485f7 chore: update deps 2022-07-06 00:49:09 +08:00
GyDi 2f3b6b29ae fix: rm macOS transition props 2022-07-05 01:24:23 +08:00
GyDi 7aecd83c4a chore: update deps 2022-07-05 00:52:22 +08:00
GyDi 3b460ab91f chore: change tray icon 2022-06-22 01:26:25 +08:00
GyDi 661c0eb970 v1.0.3 2022-06-20 02:09:12 +08:00
GyDi f4a1f1fdc8 chore: update log 2022-06-20 02:06:46 +08:00
GyDi b5432c3728 chore: update clash version 2022-06-20 01:47:15 +08:00
GyDi 6d0625c409 fix: improve external-controller parse and log 2022-06-20 01:36:56 +08:00
GyDi 2f1ea08b8a chore: update macos icon 2022-06-18 18:41:53 +08:00
GyDi a092da1943 fix: show windows on click 2022-06-18 17:28:37 +08:00
GyDi 43ef3cc562 chore: new logo for mac 2022-06-18 17:28:05 +08:00
GyDi 047774475c chore: update alpha ci 2022-06-17 01:18:19 +08:00
GyDi 91b8504df5 chore: update tauri 2022-06-17 01:16:46 +08:00
GyDi a4fb2dfcf8 fix: adjust update profile notice error 2022-06-15 02:42:04 +08:00
GyDi ba16ec02e5 fix: style issue on mac 2022-06-15 02:41:37 +08:00
GyDi 55b7af2623 chore: update version 2022-06-15 01:15:05 +08:00
GyDi b428eff10e feat: improve yaml file error log 2022-06-14 01:40:02 +08:00
GyDi 35aee15b6d chore: update deps 2022-06-14 01:23:58 +08:00
GyDi 6f53c1bfde Merge pull request #104 from FoundTheWOUT/main
fix: check script run on all OS
2022-06-12 22:25:05 +08:00
FoundTheWOUT db0230ed75 fix: check script run on all OS 2022-06-05 23:55:41 +08:00
GyDi 7fa3c1e12a feat: save proxy page state 2022-06-04 18:55:39 +08:00
GyDi 3b5993652f chore: portable script 2022-06-02 00:56:05 +08:00
GyDi be4ad8947f chore: updater script 2022-06-01 10:06:08 +08:00
GyDi 2ead15e78e v1.0.2 2022-06-01 01:26:05 +08:00
GyDi aa0740ff94 chore: update log 2022-06-01 01:25:39 +08:00
GyDi 6d3f837820 fix: macOS disable transparent 2022-06-01 01:04:46 +08:00
GyDi 359b82c29c chore: rm some files 2022-06-01 00:49:52 +08:00
GyDi b54171bc2c chore: rust cache 2022-06-01 00:47:04 +08:00
GyDi 936b2131e0 chore: adjust ci and script 2022-06-01 00:43:57 +08:00
GyDi aaef6a9e9c chore: enable meta by default 2022-06-01 00:31:12 +08:00
GyDi 9327006e61 chore: rm file 2022-05-30 00:58:48 +08:00
GyDi 5225c841ae chore: adjust code 2022-05-30 00:57:31 +08:00
GyDi 8464e319fd chore: update deps 2022-05-29 23:35:22 +08:00
GyDi 98b8bd90ea fix: window transparent and can not get hwnd 2022-05-25 19:06:06 +08:00
GyDi ae94993b09 fix: create main window 2022-05-25 16:45:18 +08:00
GyDi 72f10aaed1 chore: update deps 2022-05-25 16:12:48 +08:00
GyDi 4a74bae8c7 chore: update clash core 2022-05-25 16:08:58 +08:00
ctaoist 5164aec37b feat: light mode wip (#96)
* 关闭窗口释放UI资源

* windows 还有左键点击事件

* 兼容enhance profile

* bug 修复
2022-05-25 16:06:39 +08:00
GyDi 1581e9b1cd chore: update clash version 2022-05-18 14:14:37 +08:00
GyDi 536e3ffb11 chore: alpha ci 2022-05-18 14:07:41 +08:00
GyDi d6103191ba chore: error text 2022-05-18 09:58:20 +08:00
GyDi 49e37a19a5 chore: ci 2022-05-17 12:35:42 +08:00
GyDi d02431e260 chore: fix alpha ci 2022-05-17 12:34:30 +08:00
GyDi f24f6ead92 chore: alpha ci 2022-05-17 11:41:45 +08:00
GyDi b7bdb7ae50 chore: meta 2022-05-17 02:46:15 +08:00
GyDi 2dcf8ac96f chore: fix ci 2022-05-17 02:21:58 +08:00
GyDi 5421c94853 chore: test ci 2022-05-17 02:17:02 +08:00
GyDi 10f6bc092a chore: alpha 2022-05-17 02:11:29 +08:00
GyDi be9ea4ea8e feat: clash meta core supports 2022-05-17 01:59:49 +08:00
GyDi f5c6fa842a fix: adjust notice 2022-05-17 00:51:37 +08:00
GyDi e0943ce905 fix: label text 2022-05-16 20:26:13 +08:00
GyDi 61e7df77a7 feat: script mode 2022-05-16 20:18:56 +08:00
GyDi a5434360bc chore: test ci 2022-05-16 17:55:14 +08:00
GyDi ba29c66e3b chore: update deps 2022-05-16 17:54:21 +08:00
GyDi b3a72d55ae feat: clash meta core support (wip) 2022-05-16 01:52:50 +08:00
GyDi c382ad1cc8 fix: icon path 2022-05-13 12:28:36 +08:00
GyDi 363e28f323 fix: icon issue 2022-05-13 02:46:06 +08:00
GyDi d695656b8c fix: notice ui blocking 2022-05-13 02:29:43 +08:00
GyDi 31c6cbc0a2 fix: service mode error 2022-05-13 02:11:03 +08:00
GyDi b93284bc2f chore: fix linux build ci 2022-05-11 12:39:41 +08:00
GyDi 99c855b01b chore: test ci 2022-05-11 12:20:20 +08:00
GyDi c2eaedc959 chore: test ci 2022-05-11 12:12:55 +08:00
GyDi a631cd67ec chore: test ci fix deps 2022-05-11 11:30:19 +08:00
GyDi 66fccd3c68 chore: test ci 2022-05-11 11:22:09 +08:00
GyDi 9a4ebf4daa chore: test ci 2022-05-11 10:51:28 +08:00
130 changed files with 8567 additions and 4878 deletions
+84
View File
@@ -0,0 +1,84 @@
name: Alpha CI
on:
push:
branches: [alpha]
env:
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
jobs:
release:
strategy:
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
if: startsWith(github.repository, 'zzzgydi')
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
- name: Rust Cache
uses: Swatinem/rust-cache@ce325b60658c1b38465c06cc965b79baf32c1e72
with:
working-directory: src-tauri
- name: Install Node
uses: actions/setup-node@v1
with:
node-version: 14
- name: Install Dependencies (ubuntu only)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.0-dev build-essential curl wget libssl-dev libgtk-3-dev libappindicator3-dev librsvg2-dev libayatana-appindicator3-dev
- name: Get yarn cache dir path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Yarn Cache
uses: actions/cache@v2
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Yarn install and check
run: |
yarn install --network-timeout 1000000
yarn run check
- name: Tauri build
uses: tauri-apps/tauri-action@743a37fd53cbdd122910b818b9bef7b7aa019134
# enable cache even though failed
continue-on-error: true
env:
VITE_MULTI_CORE: 1
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
with:
tagName: alpha
releaseName: "Clash Verge Alpha"
releaseBody: "Alpha Version"
releaseDraft: true
prerelease: true
# - name: Portable Bundle
# if: matrix.os == 'windows-latest'
# run: |
# yarn run portable
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+11 -4
View File
@@ -28,6 +28,8 @@ jobs:
- name: Rust Cache
uses: Swatinem/rust-cache@ce325b60658c1b38465c06cc965b79baf32c1e72
with:
working-directory: src-tauri
- name: Install Node
uses: actions/setup-node@v1
@@ -35,10 +37,10 @@ jobs:
node-version: 14
- name: Install Dependencies (ubuntu only)
if: matrix.os == 'ubuntu-latest'
if: startsWith(matrix.os, 'ubuntu-')
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.0-dev build-essential curl wget libssl-dev libgtk-3-dev libappindicator3-dev librsvg2-dev libayatana-appindicator3-dev
- name: Get yarn cache dir path
id: yarn-cache-dir-path
@@ -61,7 +63,7 @@ jobs:
- name: Tauri build
uses: tauri-apps/tauri-action@0e558392ccadcb49bcc89e7df15a400e8f0c954d
# enable cache even though failed
continue-on-error: true
# continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
@@ -73,12 +75,17 @@ jobs:
releaseDraft: false
prerelease: true
- name: Green zip bundle
- name: Portable Bundle
if: matrix.os == 'windows-latest'
# rebuild with env settings
run: |
yarn build
yarn run portable
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_WIN_PORTABLE: 1
release-update:
needs: release
+28 -9
View File
@@ -1,6 +1,25 @@
name: Test CI
on: workflow_dispatch
on:
workflow_dispatch:
inputs:
os:
description: "Runs on OS"
required: true
default: windows-latest
type: choice
options:
- windows-latest
- ubuntu-latest
- macos-latest
- ubuntu-18.04
- ubuntu-20.04
- ubuntu-22.04
- macos-10.15
- macos-11
- macos-12
- windows-2019
- windows-2022
env:
CARGO_INCREMENTAL: 0
@@ -8,10 +27,7 @@ env:
jobs:
release:
strategy:
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
runs-on: ${{ github.event.inputs.os }}
if: startsWith(github.repository, 'zzzgydi')
steps:
- name: Checkout repository
@@ -24,16 +40,21 @@ jobs:
profile: minimal
override: true
- name: Rust Cache
uses: Swatinem/rust-cache@v1
with:
working-directory: src-tauri
- name: Install Node
uses: actions/setup-node@v1
with:
node-version: 14
- name: Install Dependencies (ubuntu only)
if: matrix.os == 'ubuntu-latest'
if: startsWith(github.event.inputs.os, 'ubuntu-')
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.0-dev build-essential curl wget libssl-dev libgtk-3-dev libappindicator3-dev librsvg2-dev libayatana-appindicator3-dev
- name: Get yarn cache dir path
id: yarn-cache-dir-path
@@ -55,8 +76,6 @@ jobs:
- name: Tauri build
uses: tauri-apps/tauri-action@0e558392ccadcb49bcc89e7df15a400e8f0c954d
# enable cache even though failed
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
+2
View File
@@ -4,3 +4,5 @@ dist
dist-ssr
*.local
update.json
scripts/_env.sh
.vscode
+3 -1
View File
@@ -12,8 +12,9 @@ A <a href="https://github.com/Dreamacro/clash">Clash</a> GUI based on <a href="h
## Features
- Full `clash` config supported, Partial `clash premium` config supported.
- Profiles management and enhancement (by yaml and Javascript). [Doc](https://github.com/zzzgydi/clash-verge/issues/12)
- Profiles management and enhancement (by yaml and Javascript). [Doc](https://github.com/zzzgydi/clash-verge/wiki/%E4%BD%BF%E7%94%A8%E6%8C%87%E5%8D%97)
- Simple UI and supports custom theme color.
- Built-in support [Clash.Meta](https://github.com/MetaCubeX/Clash.Meta) core.
- System proxy setting and guard.
## Install
@@ -90,6 +91,7 @@ Clash Verge was based on or inspired by these projects and so on:
- [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Build smaller, faster, and more secure desktop applications with a web frontend.
- [Dreamacro/clash](https://github.com/Dreamacro/clash): A rule-based tunnel in Go.
- [MetaCubeX/Clash.Meta](https://github.com/MetaCubeX/Clash.Meta): A rule-based tunnel in Go.
- [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): A Windows/macOS GUI based on Clash.
- [vitejs/vite](https://github.com/vitejs/vite): Next generation frontend tooling. It's fast!
+119
View File
@@ -1,3 +1,122 @@
## v1.1.2
### Features
- the system tray follows i18n
- change the proxy group ui of global mode
- support to update profile with the system proxy/clash proxy
- check the remote profile more strictly
### Bug Fixes
- use app version as default user agent
- the clash not exit in service mode
- reset the system proxy when quit the app
- fix some other glitches
---
## v1.1.1
### Features
- optimize clash config feedback
- hide macOS dock icon
- use clash meta compatible version (Linux)
### Bug Fixes
- fix some other glitches
---
## v1.1.0
### Features
- add rule page
- supports proxy providers delay check
- add proxy delay check loading status
- supports hotkey/shortcut management
- supports displaying connections data in table layout(refer to yacd)
### Bug Fixes
- supports yaml merge key in clash config
- detect the network interface and set the system proxy(macOS)
- fix some other glitches
---
## v1.0.6
### Features
- update clash and clash.meta
### Bug Fixes
- only script profile display console
- automatic configuration update on demand at launch
---
## v1.0.5
### Features
- reimplement profile enhanced mode with quick-js
- optimize the runtime config generation process
- support web ui management
- support clash field management
- support viewing the runtime config
- adjust some pages style
### Bug Fixes
- fix silent start
- fix incorrectly reset system proxy on exit
---
## v1.0.4
### Features
- update clash core and clash meta version
- support switch clash mode on system tray
- theme mode support follows system
### Bug Fixes
- config load error on first use
---
## v1.0.3
### Features
- save some states such as URL test, filter, etc
- update clash core and clash-meta core
- new icon for macOS
---
## v1.0.2
### Features
- supports for switching clash core
- supports release UI processes
- supports script mode setting
### Bug Fixes
- fix service mode bug (Windows)
---
## v1.0.1
### Features
+32 -27
View File
@@ -1,14 +1,16 @@
{
"name": "clash-verge",
"version": "1.0.1",
"version": "1.1.2",
"license": "GPL-3.0",
"scripts": {
"dev": "tauri dev",
"dev:diff": "tauri dev -f verge-dev",
"build": "tauri build",
"tauri": "tauri",
"web:dev": "vite",
"web:build": "tsc && vite build",
"web:serve": "vite preview",
"aarch": "node scripts/aarch.mjs",
"check": "node scripts/check.mjs",
"updater": "node scripts/updater.mjs",
"publish": "node scripts/publish.mjs",
@@ -16,45 +18,48 @@
"prepare": "husky install"
},
"dependencies": {
"@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1",
"@mui/icons-material": "^5.6.2",
"@mui/material": "^5.6.4",
"@tauri-apps/api": "^1.0.0-rc.4",
"ahooks": "^3.2.0",
"axios": "^0.26.0",
"dayjs": "^1.11.0",
"i18next": "^21.6.14",
"monaco-editor": "^0.33.0",
"@emotion/react": "^11.10.4",
"@emotion/styled": "^11.10.4",
"@mui/icons-material": "^5.10.3",
"@mui/material": "^5.10.3",
"@mui/x-data-grid": "^5.17.4",
"@tauri-apps/api": "^1.1.0",
"ahooks": "^3.7.0",
"axios": "^0.27.2",
"dayjs": "^1.11.5",
"i18next": "^21.9.1",
"monaco-editor": "^0.34.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-i18next": "^11.15.6",
"react-router-dom": "^6.2.2",
"react-virtuoso": "~2.7.2",
"recoil": "^0.6.1",
"react-i18next": "^11.17.4",
"react-router-dom": "^6.3.0",
"react-virtuoso": "^3.1.0",
"recoil": "^0.7.5",
"snarkdown": "^2.0.0",
"swr": "^1.2.2"
"swr": "^1.3.0"
},
"devDependencies": {
"@actions/github": "^5.0.0",
"@tauri-apps/cli": "^1.0.0-rc.9",
"@actions/github": "^5.0.3",
"@tauri-apps/cli": "^1.1.1",
"@types/fs-extra": "^9.0.13",
"@types/js-cookie": "^3.0.1",
"@types/js-cookie": "^3.0.2",
"@types/lodash": "^4.14.180",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@vitejs/plugin-react": "^1.2.0",
"@vitejs/plugin-react": "^2.0.1",
"adm-zip": "^0.5.9",
"cross-env": "^7.0.3",
"fs-extra": "^10.0.0",
"https-proxy-agent": "^5.0.1",
"husky": "^7.0.0",
"node-fetch": "^3.2.0",
"node-fetch": "^3.2.6",
"prettier": "^2.7.1",
"pretty-quick": "^3.1.3",
"sass": "^1.49.7",
"typescript": "^4.5.5",
"vite": "^2.8.6",
"vite-plugin-monaco-editor": "^1.0.10",
"vite-plugin-svgr": "^1.1.0"
"sass": "^1.54.0",
"typescript": "^4.7.4",
"vite": "^3.0.9",
"vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-svgr": "^2.2.1"
},
"prettier": {
"tabWidth": 2,
@@ -62,4 +67,4 @@
"singleQuote": false,
"endOfLine": "lf"
}
}
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Build and upload assets for macOS(aarch)
*/
import fs from "fs-extra";
import path from "path";
import { exit } from "process";
import { createRequire } from "module";
import { getOctokit, context } from "@actions/github";
const require = createRequire(import.meta.url);
async function resolve() {
if (!process.env.GITHUB_TOKEN) {
throw new Error("GITHUB_TOKEN is required");
}
if (!process.env.GITHUB_REPOSITORY) {
throw new Error("GITHUB_REPOSITORY is required");
}
if (!process.env.TAURI_PRIVATE_KEY) {
throw new Error("TAURI_PRIVATE_KEY is required");
}
if (!process.env.TAURI_KEY_PASSWORD) {
throw new Error("TAURI_KEY_PASSWORD is required");
}
const { version } = require("../package.json");
const cwd = process.cwd();
const bundlePath = path.join(cwd, "src-tauri/target/release/bundle");
const join = (p) => path.join(bundlePath, p);
const appPathList = [
join("macos/Clash Verge.aarch64.app.tar.gz"),
join("macos/Clash Verge.aarch64.app.tar.gz.sig"),
];
for (const appPath of appPathList) {
if (fs.pathExistsSync(appPath)) {
fs.removeSync(appPath);
}
}
fs.copyFileSync(join("macos/Clash Verge.app.tar.gz"), appPathList[0]);
fs.copyFileSync(join("macos/Clash Verge.app.tar.gz.sig"), appPathList[1]);
const options = { owner: context.repo.owner, repo: context.repo.repo };
const github = getOctokit(process.env.GITHUB_TOKEN);
const { data: release } = await github.rest.repos.getReleaseByTag({
...options,
tag: `v${version}`,
});
if (!release.id) throw new Error("failed to find the release");
await uploadAssets(release.id, [
join(`dmg/Clash Verge_${version}_aarch64.dmg`),
...appPathList,
]);
}
// From tauri-apps/tauri-action
// https://github.com/tauri-apps/tauri-action/blob/dev/packages/action/src/upload-release-assets.ts
async function uploadAssets(releaseId, assets) {
const github = getOctokit(process.env.GITHUB_TOKEN);
// Determine content-length for header to upload asset
const contentLength = (filePath) => fs.statSync(filePath).size;
for (const assetPath of assets) {
const headers = {
"content-type": "application/zip",
"content-length": contentLength(assetPath),
};
const ext = path.extname(assetPath);
const filename = path.basename(assetPath).replace(ext, "");
const assetName = path.dirname(assetPath).includes(`target${path.sep}debug`)
? `${filename}-debug${ext}`
: `${filename}${ext}`;
console.log(`[INFO]: Uploading ${assetName}...`);
try {
await github.rest.repos.uploadReleaseAsset({
headers,
name: assetName,
data: fs.readFileSync(assetPath),
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId,
});
} catch (error) {
console.log(error.message);
}
}
}
if (process.platform === "darwin" && process.arch === "arm64") {
resolve();
} else {
console.error("invalid");
exit(1);
}
+128 -38
View File
@@ -10,6 +10,7 @@ const cwd = process.cwd();
const TEMP_DIR = path.join(cwd, "node_modules/.verge");
const FORCE = process.argv.includes("--force");
const NO_META = process.argv.includes("--no-meta") || false;
/**
* get the correct clash release infomation
@@ -19,7 +20,7 @@ function resolveClash() {
const CLASH_URL_PREFIX =
"https://github.com/Dreamacro/clash/releases/download/premium/";
const CLASH_LATEST_DATE = "2022.04.17";
const CLASH_LATEST_DATE = "2022.08.26";
// todo
const map = {
@@ -27,6 +28,7 @@ function resolveClash() {
"darwin-x64": "clash-darwin-amd64",
"darwin-arm64": "clash-darwin-arm64",
"linux-x64": "clash-linux-amd64",
"linux-arm64": "clash-linux-armv8",
};
const name = map[`${platform}-${arch}`];
@@ -44,55 +46,143 @@ function resolveClash() {
return { url, zip, exefile, zipfile };
}
/**
* get the correct Clash.Meta release infomation
*/
async function resolveClashMeta() {
const { platform, arch } = process;
const urlPrefix = `https://github.com/MetaCubeX/Clash.Meta/releases/download/`;
const latestVersion = "v1.13.1";
const map = {
"win32-x64": "Clash.Meta-windows-amd64",
"darwin-x64": "Clash.Meta-darwin-amd64",
"darwin-arm64": "Clash.Meta-darwin-arm64",
"linux-x64": "Clash.Meta-linux-amd64-compatible",
"linux-arm64": "Clash.Meta-linux-arm64",
};
const name = map[`${platform}-${arch}`];
if (!name) {
throw new Error(`unsupport platform "${platform}-${arch}"`);
}
const isWin = platform === "win32";
const ext = isWin ? "zip" : "gz";
const url = `${urlPrefix}${latestVersion}/${name}-${latestVersion}.${ext}`;
const exefile = `${name}${isWin ? ".exe" : ""}`;
const zipfile = `${name}-${latestVersion}.${ext}`;
return { url, zip: ext, exefile, zipfile };
}
/**
* get the sidecar bin
* clash and Clash Meta
*/
async function resolveSidecar() {
const sidecarDir = path.join(cwd, "src-tauri", "sidecar");
const host = execSync("rustc -vV | grep host").toString().slice(6).trim();
const host = execSync("rustc -vV")
.toString()
.match(/(?<=host: ).+(?=\s*)/g)[0];
const ext = process.platform === "win32" ? ".exe" : "";
const sidecarFile = `clash-${host}${ext}`;
const sidecarPath = path.join(sidecarDir, sidecarFile);
await fs.mkdirp(sidecarDir);
if (!FORCE && (await fs.pathExists(sidecarPath))) return;
await clash();
if (!NO_META) await clashMeta();
// download sidecar
const binInfo = resolveClash();
const tempDir = path.join(TEMP_DIR, "clash");
const tempZip = path.join(tempDir, binInfo.zipfile);
const tempExe = path.join(tempDir, binInfo.exefile);
async function clash() {
const sidecarFile = `clash-${host}${ext}`;
const sidecarPath = path.join(sidecarDir, sidecarFile);
await fs.mkdirp(tempDir);
if (!(await fs.pathExists(tempZip))) await downloadFile(binInfo.url, tempZip);
await fs.mkdirp(sidecarDir);
if (!FORCE && (await fs.pathExists(sidecarPath))) return;
if (binInfo.zip === "zip") {
const zip = new AdmZip(tempZip);
zip.getEntries().forEach((entry) => {
console.log("[INFO]: entry name", entry.entryName);
});
zip.extractAllTo(tempDir, true);
// save as sidecar
await fs.rename(tempExe, sidecarPath);
console.log(`[INFO]: unzip finished`);
} else {
// gz
const readStream = fs.createReadStream(tempZip);
const writeStream = fs.createWriteStream(sidecarPath);
readStream
.pipe(zlib.createGunzip())
.pipe(writeStream)
.on("finish", () => {
console.log(`[INFO]: gunzip finished`);
execSync(`chmod 755 ${sidecarPath}`);
console.log(`[INFO]: chmod binary finished`);
})
.on("error", (error) => console.error(error));
// download sidecar
const binInfo = resolveClash();
const tempDir = path.join(TEMP_DIR, "clash");
const tempZip = path.join(tempDir, binInfo.zipfile);
const tempExe = path.join(tempDir, binInfo.exefile);
await fs.mkdirp(tempDir);
if (!(await fs.pathExists(tempZip)))
await downloadFile(binInfo.url, tempZip);
if (binInfo.zip === "zip") {
const zip = new AdmZip(tempZip);
zip.getEntries().forEach((entry) => {
console.log("[INFO]: entry name", entry.entryName);
});
zip.extractAllTo(tempDir, true);
// save as sidecar
await fs.rename(tempExe, sidecarPath);
console.log(`[INFO]: unzip finished`);
} else {
// gz
const readStream = fs.createReadStream(tempZip);
const writeStream = fs.createWriteStream(sidecarPath);
readStream
.pipe(zlib.createGunzip())
.pipe(writeStream)
.on("finish", () => {
console.log(`[INFO]: gunzip finished`);
execSync(`chmod 755 ${sidecarPath}`);
console.log(`[INFO]: chmod binary finished`);
})
.on("error", (error) => console.error(error));
}
// delete temp dir
await fs.remove(tempDir);
}
// delete temp dir
await fs.remove(tempDir);
async function clashMeta() {
const sidecarFile = `clash-meta-${host}${ext}`;
const sidecarPath = path.join(sidecarDir, sidecarFile);
await fs.mkdirp(sidecarDir);
if (!FORCE && (await fs.pathExists(sidecarPath))) return;
// download sidecar
const binInfo = await resolveClashMeta();
const tempDir = path.join(TEMP_DIR, "clash-meta");
const tempZip = path.join(tempDir, binInfo.zipfile);
const tempExe = path.join(tempDir, binInfo.exefile);
await fs.mkdirp(tempDir);
if (!(await fs.pathExists(tempZip)))
await downloadFile(binInfo.url, tempZip);
if (binInfo.zip === "zip") {
const zip = new AdmZip(tempZip);
zip.getEntries().forEach((entry) => {
console.log("[INFO]: entry name", entry.entryName);
});
zip.extractAllTo(tempDir, true);
// save as sidecar
await fs.rename(tempExe, sidecarPath);
console.log(`[INFO]: unzip finished`);
} else {
// gz
const readStream = fs.createReadStream(tempZip);
const writeStream = fs.createWriteStream(sidecarPath);
readStream
.pipe(zlib.createGunzip())
.pipe(writeStream)
.on("finish", () => {
console.log(`[INFO]: gunzip finished`);
execSync(`chmod 755 ${sidecarPath}`);
console.log(`[INFO]: chmod binary finished`);
})
.on("error", (error) => console.error(error));
}
// delete temp dir
await fs.remove(tempDir);
}
}
/**
@@ -170,7 +260,7 @@ async function resolveService() {
*/
async function resolveMmdb() {
const url =
"https://github.com/Dreamacro/maxmind-geoip/releases/latest/download/Country.mmdb";
"https://github.com/Dreamacro/maxmind-geoip/releases/download/20221012/Country.mmdb";
const resDir = path.join(cwd, "src-tauri", "resources");
const resPath = path.join(resDir, "Country.mmdb");
+54 -1
View File
@@ -4,6 +4,8 @@ import AdmZip from "adm-zip";
import { createRequire } from "module";
import { getOctokit, context } from "@actions/github";
const META = process.argv.includes("--meta"); // use Clash.Meta
/// Script for ci
/// 打包绿色版/便携版 (only Windows)
async function resolvePortable() {
@@ -19,6 +21,7 @@ async function resolvePortable() {
zip.addLocalFile(path.join(releaseDir, "Clash Verge.exe"));
zip.addLocalFile(path.join(releaseDir, "clash.exe"));
zip.addLocalFile(path.join(releaseDir, "clash-meta.exe"));
zip.addLocalFolder(path.join(releaseDir, "resources"), "resources");
const require = createRequire(import.meta.url);
@@ -53,4 +56,54 @@ async function resolvePortable() {
});
}
resolvePortable().catch(console.error);
/// 打包包含Clash.Meta的 (only Windows)
async function resolvePortableMeta() {
if (process.platform !== "win32") return;
const releaseDir = "./src-tauri/target/release";
if (!(await fs.pathExists(releaseDir))) {
throw new Error("could not found the release dir");
}
const zip = new AdmZip();
zip.addLocalFile(path.join(releaseDir, "Clash Verge.exe"));
zip.addLocalFile(path.join(releaseDir, "clash.exe"));
zip.addLocalFile(path.join(releaseDir, "clash-meta.exe"));
zip.addLocalFolder(path.join(releaseDir, "resources"), "resources");
const require = createRequire(import.meta.url);
const packageJson = require("../package.json");
const { version } = packageJson;
const zipFile = `Clash.Verge.Meta_${version}_x64_portable.zip`;
zip.writeZip(zipFile);
console.log("[INFO]: create portable zip successfully");
// push release assets
if (process.env.GITHUB_TOKEN === undefined) {
throw new Error("GITHUB_TOKEN is required");
}
const options = { owner: context.repo.owner, repo: context.repo.repo };
const github = getOctokit(process.env.GITHUB_TOKEN);
const { data: release } = await github.rest.repos.getReleaseByTag({
...options,
tag: `v${version}`,
});
console.log(release.name);
await github.rest.repos.uploadReleaseAsset({
...options,
release_id: release.id,
name: zipFile,
data: zip.toBuffer(),
});
}
if (META) resolvePortableMeta().catch(console.error);
else resolvePortable().catch(console.error);
+4 -4
View File
@@ -53,12 +53,12 @@ async function resolveUpdater() {
const { name, browser_download_url } = asset;
// win64 url
if (name.endsWith(".msi.zip")) {
if (name.endsWith(".msi.zip") && name.includes("en-US")) {
updateData.platforms.win64.url = browser_download_url;
updateData.platforms["windows-x86_64"].url = browser_download_url;
}
// win64 signature
if (name.endsWith(".msi.zip.sig")) {
if (name.endsWith(".msi.zip.sig") && name.includes("en-US")) {
const sig = await getSignature(browser_download_url);
updateData.platforms.win64.signature = sig;
updateData.platforms["windows-x86_64"].signature = sig;
@@ -77,11 +77,11 @@ async function resolveUpdater() {
}
// darwin url (aarch)
if (name.endsWith("aarch.app.tar.gz")) {
if (name.endsWith("aarch64.app.tar.gz")) {
updateData.platforms["darwin-aarch64"].url = browser_download_url;
}
// darwin signature (aarch)
if (name.endsWith("aarch.app.tar.gz.sig")) {
if (name.endsWith("aarch64.app.tar.gz.sig")) {
const sig = await getSignature(browser_download_url);
updateData.platforms["darwin-aarch64"].signature = sig;
}
+1078 -823
View File
File diff suppressed because it is too large Load Diff
+27 -23
View File
@@ -10,41 +10,45 @@ edition = "2021"
build = "build.rs"
[build-dependencies]
tauri-build = { version = "1.0.0-rc.7", features = [] }
tauri-build = { version = "1", features = [] }
[dependencies]
anyhow = "1.0"
dirs = "4.0.0"
open = "2.1.1"
dunce = "1.0.2"
nanoid = "0.4.0"
chrono = "0.4.19"
serde_json = "1.0"
serde_yaml = "0.8"
delay_timer = "0.11.1"
parking_lot = "0.12.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.0.0-rc.8", features = ["process-all", "shell-all", "system-tray", "updater", "window-all"] }
window-shadows = { git = "https://github.com/tauri-apps/window-shadows" }
window-vibrancy = { git = "https://github.com/tauri-apps/window-vibrancy" }
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
log = "0.4.14"
log4rs = "1.0.0"
warp = "0.3"
which = "4.2.2"
auto-launch = "0.2"
anyhow = "1.0"
dirs = "4.0.0"
open = "3.0.3"
log = "0.4.14"
ctrlc = "3.2.3"
dunce = "1.0.2"
log4rs = "1.0.0"
nanoid = "0.4.0"
chrono = "0.4.19"
sysinfo = "0.26.2"
sysproxy = "0.1"
serde_json = "1.0"
serde_yaml = "0.9"
auto-launch = "0.4"
once_cell = "1.14.0"
port_scanner = "0.1.5"
delay_timer = "0.11.1"
parking_lot = "0.12.0"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
reqwest = { version = "0.11", features = ["json"] }
tauri = { version = "1.1.1", features = ["global-shortcut-all", "process-all", "shell-all", "system-tray", "updater", "window-all"] }
rquickjs = { version = "0.1.7" }
window-shadows = { version = "0.2.0" }
window-vibrancy = { version = "0.3.0" }
[target.'cfg(windows)'.dependencies]
runas = "0.2.1"
deelevate = "0.2.0"
windows-sys = "0.36"
winreg = { version = "0.10", features = ["transactions"] }
windows-sys = { version = "0.36", features = ["Win32_System_LibraryLoader", "Win32_System_SystemInformation"] }
[features]
default = ["custom-protocol", "tauri/ayatana-tray"]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
verge-dev = []
debug-yml = []
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+160 -105
View File
@@ -1,38 +1,39 @@
use crate::{
core::{ClashInfo, Core, PrfItem, PrfOption, Profiles, Verge},
utils::{dirs, help, sysopt::SysProxyConfig},
core::Core,
data::{ClashInfo, Data, PrfItem, PrfOption, Profiles, Verge},
utils::{dirs, help},
};
use crate::{log_if_err, ret_err, wrap_err};
use anyhow::Result;
use serde_yaml::Mapping;
use tauri::{api, State};
use std::collections::{HashMap, VecDeque};
use sysproxy::Sysproxy;
type CmdResult<T = ()> = Result<T, String>;
/// get all profiles from `profiles.yaml`
#[tauri::command]
pub fn get_profiles(core: State<'_, Core>) -> CmdResult<Profiles> {
let profiles = core.profiles.lock();
pub fn get_profiles() -> CmdResult<Profiles> {
let global = Data::global();
let profiles = global.profiles.lock();
Ok(profiles.clone())
}
/// manually exec enhanced profile
#[tauri::command]
pub fn enhance_profiles(core: State<'_, Core>) -> CmdResult {
wrap_err!(core.activate_enhanced(false))
pub fn enhance_profiles() -> CmdResult {
let core = Core::global();
wrap_err!(core.activate())
}
/// import the profile from url
/// and save to `profiles.yaml`
#[tauri::command]
pub async fn import_profile(
url: String,
option: Option<PrfOption>,
core: State<'_, Core>,
) -> CmdResult {
pub async fn import_profile(url: String, option: Option<PrfOption>) -> CmdResult {
let item = wrap_err!(PrfItem::from_url(&url, None, None, option).await)?;
let mut profiles = core.profiles.lock();
let global = Data::global();
let mut profiles = global.profiles.lock();
wrap_err!(profiles.append_item(item))
}
@@ -40,100 +41,100 @@ pub async fn import_profile(
/// append a temp profile item file to the `profiles` dir
/// view the temp profile file by using vscode or other editor
#[tauri::command]
pub async fn create_profile(
item: PrfItem, // partial
file_data: Option<String>,
core: State<'_, Core>,
) -> CmdResult {
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult {
let item = wrap_err!(PrfItem::from(item, file_data).await)?;
let mut profiles = core.profiles.lock();
let global = Data::global();
let mut profiles = global.profiles.lock();
wrap_err!(profiles.append_item(item))
}
/// Update the profile
#[tauri::command]
pub async fn update_profile(
index: String,
option: Option<PrfOption>,
core: State<'_, Core>,
) -> CmdResult {
wrap_err!(Core::update_profile_item(core.inner().clone(), index, option).await)
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
let core = Core::global();
wrap_err!(core.update_profile_item(index, option).await)
}
/// change the current profile
#[tauri::command]
pub fn select_profile(index: String, core: State<'_, Core>) -> CmdResult {
let mut profiles = core.profiles.lock();
pub fn select_profile(index: String) -> CmdResult {
let global = Data::global();
let mut profiles = global.profiles.lock();
wrap_err!(profiles.put_current(index))?;
drop(profiles);
wrap_err!(core.activate_enhanced(false))
let core = Core::global();
wrap_err!(core.activate())
}
/// change the profile chain
#[tauri::command]
pub fn change_profile_chain(chain: Option<Vec<String>>, core: State<'_, Core>) -> CmdResult {
let mut profiles = core.profiles.lock();
profiles.put_chain(chain);
pub fn change_profile_chain(chain: Option<Vec<String>>) -> CmdResult {
let global = Data::global();
let mut profiles = global.profiles.lock();
wrap_err!(profiles.put_chain(chain))?;
drop(profiles);
wrap_err!(core.activate_enhanced(false))
let core = Core::global();
wrap_err!(core.activate())
}
/// change the profile valid fields
#[tauri::command]
pub fn change_profile_valid(valid: Option<Vec<String>>, core: State<Core>) -> CmdResult {
let mut profiles = core.profiles.lock();
profiles.put_valid(valid);
pub fn change_profile_valid(valid: Option<Vec<String>>) -> CmdResult {
let global = Data::global();
let mut profiles = global.profiles.lock();
wrap_err!(profiles.put_valid(valid))?;
drop(profiles);
wrap_err!(core.activate_enhanced(false))
let core = Core::global();
wrap_err!(core.activate())
}
/// delete profile item
#[tauri::command]
pub fn delete_profile(index: String, core: State<'_, Core>) -> CmdResult {
let mut profiles = core.profiles.lock();
pub fn delete_profile(index: String) -> CmdResult {
let global = Data::global();
let mut profiles = global.profiles.lock();
if wrap_err!(profiles.delete_item(index))? {
drop(profiles);
log_if_err!(core.activate_enhanced(false));
let core = Core::global();
log_if_err!(core.activate());
}
Ok(())
}
/// patch the profile config
#[tauri::command]
pub fn patch_profile(index: String, profile: PrfItem, core: State<'_, Core>) -> CmdResult {
let mut profiles = core.profiles.lock();
pub fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
let global = Data::global();
let mut profiles = global.profiles.lock();
wrap_err!(profiles.patch_item(index, profile))?;
drop(profiles);
// update cron task
let core = Core::global();
let mut timer = core.timer.lock();
wrap_err!(timer.refresh())
}
/// run vscode command to edit the profile
#[tauri::command]
pub fn view_profile(index: String, core: State<'_, Core>) -> CmdResult {
let profiles = core.profiles.lock();
pub fn view_profile(index: String) -> CmdResult {
let global = Data::global();
let profiles = global.profiles.lock();
let item = wrap_err!(profiles.get_item(&index))?;
let file = item.file.clone();
if file.is_none() {
ret_err!("the file is null");
ret_err!("file is null");
}
let path = dirs::app_profiles_dir().join(file.unwrap());
if !path.exists() {
ret_err!("the file not found");
ret_err!("file not found");
}
wrap_err!(help::open_file(path))
@@ -141,27 +142,23 @@ pub fn view_profile(index: String, core: State<'_, Core>) -> CmdResult {
/// read the profile item file data
#[tauri::command]
pub fn read_profile_file(index: String, core: State<'_, Core>) -> CmdResult<String> {
let profiles = core.profiles.lock();
pub fn read_profile_file(index: String) -> CmdResult<String> {
let global = Data::global();
let profiles = global.profiles.lock();
let item = wrap_err!(profiles.get_item(&index))?;
let data = wrap_err!(item.read_file())?;
Ok(data)
}
/// save the profile item file data
#[tauri::command]
pub fn save_profile_file(
index: String,
file_data: Option<String>,
core: State<'_, Core>,
) -> CmdResult {
pub fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult {
if file_data.is_none() {
return Ok(());
}
let profiles = core.profiles.lock();
let global = Data::global();
let profiles = global.profiles.lock();
let item = wrap_err!(profiles.get_item(&index))?;
wrap_err!(item.save_file(file_data.unwrap()))
}
@@ -169,77 +166,138 @@ pub fn save_profile_file(
/// get the clash core info from the state
/// the caller can also get the infomation by clash's api
#[tauri::command]
pub fn get_clash_info(core: State<'_, Core>) -> CmdResult<ClashInfo> {
let clash = core.clash.lock();
pub fn get_clash_info() -> CmdResult<ClashInfo> {
let global = Data::global();
let clash = global.clash.lock();
Ok(clash.info.clone())
}
/// get the runtime clash config mapping
#[tauri::command]
pub fn get_runtime_config() -> CmdResult<Option<Mapping>> {
let core = Core::global();
let rt = core.runtime.lock();
Ok(rt.config.clone())
}
/// get the runtime clash config yaml string
#[tauri::command]
pub fn get_runtime_yaml() -> CmdResult<Option<String>> {
let core = Core::global();
let rt = core.runtime.lock();
Ok(rt.config_yaml.clone())
}
/// get the runtime config exists keys
#[tauri::command]
pub fn get_runtime_exists() -> CmdResult<Vec<String>> {
let core = Core::global();
let rt = core.runtime.lock();
Ok(rt.exists_keys.clone())
}
/// get the runtime enhanced chain log
#[tauri::command]
pub fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String)>>> {
let core = Core::global();
let rt = core.runtime.lock();
Ok(rt.chain_logs.clone())
}
/// update the clash core config
/// after putting the change to the clash core
/// then we should save the latest config
#[tauri::command]
pub fn patch_clash_config(payload: Mapping, core: State<'_, Core>) -> CmdResult {
pub fn patch_clash_config(payload: Mapping) -> CmdResult {
let core = Core::global();
wrap_err!(core.patch_clash(payload))
}
/// get the verge config
#[tauri::command]
pub fn get_verge_config(core: State<'_, Core>) -> CmdResult<Verge> {
let verge = core.verge.lock();
pub fn get_verge_config() -> CmdResult<Verge> {
let global = Data::global();
let verge = global.verge.lock();
Ok(verge.clone())
}
/// patch the verge config
/// this command only save the config and not responsible for other things
#[tauri::command]
pub fn patch_verge_config(
payload: Verge,
app_handle: tauri::AppHandle,
core: State<'_, Core>,
) -> Result<(), String> {
wrap_err!(core.patch_verge(payload, &app_handle))
pub fn patch_verge_config(payload: Verge) -> CmdResult {
let core = Core::global();
wrap_err!(core.patch_verge(payload))
}
#[tauri::command]
pub fn update_hotkeys(hotkeys: Vec<String>) -> CmdResult {
let core = Core::global();
let mut hotkey = core.hotkey.lock();
wrap_err!(hotkey.update(hotkeys))
}
/// change clash core
#[tauri::command]
pub fn change_clash_core(clash_core: Option<String>) -> CmdResult {
let core = Core::global();
wrap_err!(core.change_core(clash_core))
}
/// restart the sidecar
#[tauri::command]
pub fn restart_sidecar(core: State<'_, Core>) -> CmdResult {
pub fn restart_sidecar() -> CmdResult {
let core = Core::global();
wrap_err!(core.restart_clash())
}
/// kill all sidecars when update app
#[tauri::command]
pub fn kill_sidecar() {
api::process::kill_children();
tauri::api::process::kill_children();
}
/// get the system proxy
#[tauri::command]
pub fn get_sys_proxy() -> Result<SysProxyConfig, String> {
wrap_err!(SysProxyConfig::get_sys())
pub fn get_sys_proxy() -> CmdResult<Mapping> {
let current = wrap_err!(Sysproxy::get_system_proxy())?;
let mut map = Mapping::new();
map.insert("enable".into(), current.enable.into());
map.insert(
"server".into(),
format!("{}:{}", current.host, current.port).into(),
);
map.insert("bypass".into(), current.bypass.into());
Ok(map)
}
/// get the current proxy config
/// which may not the same as system proxy
#[tauri::command]
pub fn get_cur_proxy(core: State<'_, Core>) -> CmdResult<Option<SysProxyConfig>> {
let sysopt = core.sysopt.lock();
wrap_err!(sysopt.get_sysproxy())
pub fn get_clash_logs() -> CmdResult<VecDeque<String>> {
let core = Core::global();
let service = core.service.lock();
Ok(service.get_logs())
}
/// open app config dir
#[tauri::command]
pub fn open_app_dir() -> Result<(), String> {
pub fn open_app_dir() -> CmdResult<()> {
let app_dir = dirs::app_home_dir();
wrap_err!(open::that(app_dir))
}
/// open logs dir
#[tauri::command]
pub fn open_logs_dir() -> Result<(), String> {
pub fn open_logs_dir() -> CmdResult<()> {
let log_dir = dirs::app_logs_dir();
wrap_err!(open::that(log_dir))
}
/// open url
#[tauri::command]
pub fn open_web_url(url: String) -> CmdResult<()> {
wrap_err!(open::that(url))
}
/// service mode
#[cfg(windows)]
pub mod service {
@@ -247,17 +305,17 @@ pub mod service {
use crate::core::win_service::JsonResponse;
#[tauri::command]
pub async fn start_service() -> Result<(), String> {
pub async fn start_service() -> CmdResult<()> {
wrap_err!(crate::core::Service::start_service().await)
}
#[tauri::command]
pub async fn stop_service() -> Result<(), String> {
pub async fn stop_service() -> CmdResult<()> {
wrap_err!(crate::core::Service::stop_service().await)
}
#[tauri::command]
pub async fn check_service() -> Result<JsonResponse, String> {
pub async fn check_service() -> CmdResult<JsonResponse> {
// no log
match crate::core::Service::check_service().await {
Ok(res) => Ok(res),
@@ -266,12 +324,12 @@ pub mod service {
}
#[tauri::command]
pub async fn install_service() -> Result<(), String> {
pub async fn install_service() -> CmdResult<()> {
wrap_err!(crate::core::Service::install_service().await)
}
#[tauri::command]
pub async fn uninstall_service() -> Result<(), String> {
pub async fn uninstall_service() -> CmdResult<()> {
wrap_err!(crate::core::Service::uninstall_service().await)
}
}
@@ -281,26 +339,23 @@ pub mod service {
use super::*;
#[tauri::command]
pub async fn start_service() -> Result<(), String> {
Ok(())
}
#[tauri::command]
pub async fn stop_service() -> Result<(), String> {
Ok(())
}
#[tauri::command]
pub async fn check_service() -> Result<(), String> {
Ok(())
}
#[tauri::command]
pub async fn install_service() -> Result<(), String> {
pub async fn start_service() -> CmdResult<()> {
Ok(())
}
#[tauri::command]
pub async fn uninstall_service() -> Result<(), String> {
pub async fn stop_service() -> CmdResult<()> {
Ok(())
}
#[tauri::command]
pub async fn check_service() -> CmdResult<()> {
Ok(())
}
#[tauri::command]
pub async fn install_service() -> CmdResult<()> {
Ok(())
}
#[tauri::command]
pub async fn uninstall_service() -> CmdResult<()> {
Ok(())
}
}
+118
View File
@@ -0,0 +1,118 @@
use serde_yaml::{Mapping, Value};
pub const HANDLE_FIELDS: [&str; 9] = [
"port",
"socks-port",
"mixed-port",
"mode",
"ipv6",
"log-level",
"allow-lan",
"external-controller",
"secret",
];
pub const DEFAULT_FIELDS: [&str; 5] = [
"proxies",
"proxy-groups",
"rules",
"proxy-providers",
"rule-providers",
];
pub const OTHERS_FIELDS: [&str; 20] = [
"tun",
"dns",
"ebpf",
"hosts",
"script",
"profile",
"payload",
"auto-redir",
"experimental",
"interface-name",
"routing-mark",
"redir-port",
"tproxy-port",
"iptables",
"external-ui",
"bind-address",
"authentication",
"sniffer", // meta
"geodata-mode", // meta
"tcp-concurrent", // meta
];
pub fn use_clash_fields() -> Vec<String> {
DEFAULT_FIELDS
.into_iter()
.chain(HANDLE_FIELDS)
.chain(OTHERS_FIELDS)
.map(|s| s.to_string())
.collect()
}
pub fn use_valid_fields(mut valid: Vec<String>) -> Vec<String> {
let others = Vec::from(OTHERS_FIELDS);
valid.iter_mut().for_each(|s| s.make_ascii_lowercase());
valid
.into_iter()
.filter(|s| others.contains(&s.as_str()))
.chain(DEFAULT_FIELDS.iter().map(|s| s.to_string()))
.collect()
}
pub fn use_filter(config: Mapping, filter: &Vec<String>) -> Mapping {
let mut ret = Mapping::new();
for (key, value) in config.into_iter() {
if let Some(key) = key.as_str() {
if filter.contains(&key.to_string()) {
ret.insert(Value::from(key), value);
}
}
}
ret
}
pub fn use_lowercase(config: Mapping) -> Mapping {
let mut ret = Mapping::new();
for (key, value) in config.into_iter() {
if let Some(key_str) = key.as_str() {
let mut key_str = String::from(key_str);
key_str.make_ascii_lowercase();
ret.insert(Value::from(key_str), value);
}
}
ret
}
pub fn use_sort(config: Mapping) -> Mapping {
let mut ret = Mapping::new();
HANDLE_FIELDS
.into_iter()
.chain(OTHERS_FIELDS)
.chain(DEFAULT_FIELDS)
.for_each(|key| {
let key = Value::from(key);
config.get(&key).map(|value| {
ret.insert(key, value.clone());
});
});
ret
}
pub fn use_keys(config: &Mapping) -> Vec<String> {
config
.iter()
.filter_map(|(key, _)| key.as_str())
.map(|s| {
let mut s = s.to_string();
s.make_ascii_lowercase();
return s;
})
.collect()
}
+93
View File
@@ -0,0 +1,93 @@
use super::{use_filter, use_lowercase};
use serde_yaml::{self, Mapping, Sequence, Value};
#[allow(unused)]
const MERGE_FIELDS: [&str; 6] = [
"prepend-rules",
"append-rules",
"prepend-proxies",
"append-proxies",
"prepend-proxy-groups",
"append-proxy-groups",
];
pub fn use_merge(merge: Mapping, mut config: Mapping) -> Mapping {
// 直接覆盖原字段
use_lowercase(merge.clone())
.into_iter()
.for_each(|(key, value)| {
config.insert(key, value);
});
let merge_list = MERGE_FIELDS.iter().map(|s| s.to_string());
let merge = use_filter(merge, &merge_list.collect());
["rules", "proxies", "proxy-groups"]
.iter()
.for_each(|key_str| {
let key_val = Value::from(key_str.to_string());
let mut list = Sequence::default();
list = config.get(&key_val).map_or(list.clone(), |val| {
val.as_sequence().map_or(list, |v| v.clone())
});
let pre_key = Value::from(format!("prepend-{key_str}"));
let post_key = Value::from(format!("append-{key_str}"));
if let Some(pre_val) = merge.get(&pre_key) {
if pre_val.is_sequence() {
let mut pre_val = pre_val.as_sequence().unwrap().clone();
pre_val.extend(list);
list = pre_val;
}
}
if let Some(post_val) = merge.get(&post_key) {
if post_val.is_sequence() {
list.extend(post_val.as_sequence().unwrap().clone());
}
}
config.insert(key_val, Value::from(list));
});
config
}
#[test]
fn test_merge() -> anyhow::Result<()> {
let merge = r"
prepend-rules:
- prepend
- 1123123
append-rules:
- append
prepend-proxies:
- 9999
append-proxies:
- 1111
rules:
- replace
proxy-groups:
- 123781923810
tun:
enable: true
dns:
enable: true
";
let config = r"
rules:
- aaaaa
script1: test
";
let merge = serde_yaml::from_str::<Mapping>(merge)?;
let config = serde_yaml::from_str::<Mapping>(config)?;
let result = serde_yaml::to_string(&use_merge(merge, config))?;
println!("{result}");
Ok(())
}
+69
View File
@@ -0,0 +1,69 @@
mod field;
mod merge;
mod script;
mod tun;
pub(self) use self::field::*;
use self::merge::*;
use self::script::*;
use self::tun::*;
use crate::data::ChainItem;
use crate::data::ChainType;
use serde_yaml::Mapping;
use std::collections::HashMap;
use std::collections::HashSet;
type ResultLog = Vec<(String, String)>;
pub fn enhance_config(
clash_config: Mapping,
profile_config: Mapping,
chain: Vec<ChainItem>,
valid: Vec<String>,
tun_mode: bool,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
let mut config = profile_config;
let mut result_map = HashMap::new();
let mut exists_keys = use_keys(&config);
let valid = use_valid_fields(valid);
chain.into_iter().for_each(|item| match item.data {
ChainType::Merge(merge) => {
exists_keys.extend(use_keys(&merge));
config = use_merge(merge, config.to_owned());
config = use_filter(config.to_owned(), &valid);
}
ChainType::Script(script) => {
let mut logs = vec![];
match use_script(script, config.to_owned()) {
Ok((res_config, res_logs)) => {
exists_keys.extend(use_keys(&res_config));
config = use_filter(res_config, &valid);
logs.extend(res_logs);
}
Err(err) => logs.push(("exception".into(), err.to_string())),
}
result_map.insert(item.uid, logs);
}
});
config = use_filter(config, &valid);
for (key, value) in clash_config.into_iter() {
config.insert(key, value);
}
let clash_fields = use_clash_fields();
config = use_filter(config, &clash_fields);
config = use_tun(config, tun_mode);
config = use_sort(config);
let mut exists_set = HashSet::new();
exists_set.extend(exists_keys.into_iter().filter(|s| clash_fields.contains(s)));
exists_keys = exists_set.into_iter().collect();
(config, exists_keys, result_map)
}
+94
View File
@@ -0,0 +1,94 @@
use super::use_lowercase;
use anyhow::Result;
use serde_yaml::Mapping;
pub fn use_script(script: String, config: Mapping) -> Result<(Mapping, Vec<(String, String)>)> {
use rquickjs::{Context, Func, Runtime};
use std::sync::{Arc, Mutex};
let runtime = Runtime::new().unwrap();
let context = Context::full(&runtime).unwrap();
let outputs = Arc::new(Mutex::new(vec![]));
let copy_outputs = outputs.clone();
let result = context.with(|ctx| -> Result<Mapping> {
ctx.globals().set(
"__verge_log__",
Func::from(move |level: String, data: String| {
let mut out = copy_outputs.lock().unwrap();
out.push((level, data));
}),
)?;
ctx.eval(
r#"var console = Object.freeze({
log(data){__verge_log__("log",JSON.stringify(data))},
info(data){__verge_log__("info",JSON.stringify(data))},
error(data){__verge_log__("error",JSON.stringify(data))},
debug(data){__verge_log__("debug",JSON.stringify(data))},
});"#,
)?;
let config = use_lowercase(config.clone());
let config_str = serde_json::to_string(&config)?;
let code = format!(
r#"try{{
{script};
JSON.stringify(main({config_str})||'')
}} catch(err) {{
`__error_flag__ ${{err.toString()}}`
}}"#
);
let result: String = ctx.eval(code.as_str())?;
if result.starts_with("__error_flag__") {
anyhow::bail!(result[15..].to_owned());
}
if result == "\"\"" {
anyhow::bail!("main function should return object");
}
return Ok(serde_json::from_str::<Mapping>(result.as_str())?);
});
let mut out = outputs.lock().unwrap();
match result {
Ok(config) => Ok((use_lowercase(config), out.to_vec())),
Err(err) => {
out.push(("exception".into(), err.to_string()));
Ok((config, out.to_vec()))
}
}
}
#[test]
fn test_script() {
let script = r#"
function main(config) {
if (Array.isArray(config.rules)) {
config.rules = [...config.rules, "add"];
}
console.log(config);
config.proxies = ["111"];
return config;
}
"#;
let config = r#"
rules:
- 111
- 222
tun:
enable: false
dns:
enable: false
"#;
let config = serde_yaml::from_str(config).unwrap();
let (config, results) = use_script(script.into(), config).unwrap();
let config_str = serde_yaml::to_string(&config).unwrap();
println!("{config_str}");
dbg!(results);
}
+81
View File
@@ -0,0 +1,81 @@
use serde_yaml::{Mapping, Value};
macro_rules! revise {
($map: expr, $key: expr, $val: expr) => {
let ret_key = Value::String($key.into());
$map.insert(ret_key, Value::from($val));
};
}
// if key not exists then append value
macro_rules! append {
($map: expr, $key: expr, $val: expr) => {
let ret_key = Value::String($key.into());
if !$map.contains_key(&ret_key) {
$map.insert(ret_key, Value::from($val));
}
};
}
pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
let tun_key = Value::from("tun");
let tun_val = config.get(&tun_key);
if !enable && tun_val.is_none() {
return config;
}
let mut tun_val = tun_val.map_or(Mapping::new(), |val| {
val.as_mapping().cloned().unwrap_or(Mapping::new())
});
revise!(tun_val, "enable", enable);
if enable {
append!(tun_val, "stack", "gvisor");
append!(tun_val, "dns-hijack", vec!["198.18.0.2:53"]);
append!(tun_val, "auto-route", true);
append!(tun_val, "auto-detect-interface", true);
}
revise!(config, "tun", tun_val);
if enable {
use_dns_for_tun(config)
} else {
config
}
}
fn use_dns_for_tun(mut config: Mapping) -> Mapping {
let dns_key = Value::from("dns");
let dns_val = config.get(&dns_key);
let mut dns_val = dns_val.map_or(Mapping::new(), |val| {
val.as_mapping().cloned().unwrap_or(Mapping::new())
});
// 开启tun将同时开启dns
revise!(dns_val, "enable", true);
append!(dns_val, "enhanced-mode", "fake-ip");
append!(dns_val, "fake-ip-range", "198.18.0.1/16");
append!(
dns_val,
"nameserver",
vec!["114.114.114.114", "223.5.5.5", "8.8.8.8"]
);
append!(dns_val, "fallback", vec![] as Vec<&str>);
#[cfg(target_os = "windows")]
append!(
dns_val,
"fake-ip-filter",
vec![
"dns.msftncsi.com",
"www.msftncsi.com",
"www.msftconnecttest.com"
]
);
revise!(config, "dns", dns_val);
config
}
-288
View File
@@ -1,288 +0,0 @@
use crate::utils::{config, dirs};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping, Value};
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct ClashInfo {
/// clash sidecar status
pub status: String,
/// clash core port
pub port: Option<String>,
/// same as `external-controller`
pub server: Option<String>,
/// clash secret
pub secret: Option<String>,
}
impl ClashInfo {
/// parse the clash's config.yaml
/// get some information
pub fn from(config: &Mapping) -> ClashInfo {
let key_port_1 = Value::from("port");
let key_port_2 = Value::from("mixed-port");
let key_server = Value::from("external-controller");
let key_secret = Value::from("secret");
let port = match config.get(&key_port_1) {
Some(value) => match value {
Value::String(val_str) => Some(val_str.clone()),
Value::Number(val_num) => Some(val_num.to_string()),
_ => None,
},
_ => None,
};
let port = match port {
Some(_) => port,
None => match config.get(&key_port_2) {
Some(value) => match value {
Value::String(val_str) => Some(val_str.clone()),
Value::Number(val_num) => Some(val_num.to_string()),
_ => None,
},
_ => None,
},
};
// `external-controller` could be
// "127.0.0.1:9090" or ":9090"
// "9090" or 9090 (clash do not support this)
let server = match config.get(&key_server) {
Some(value) => match value {
Value::String(val_str) => match val_str.starts_with(":") {
true => Some(format!("127.0.0.1{val_str}")),
false => Some(val_str.clone()),
},
_ => None,
},
_ => None,
};
let secret = match config.get(&key_secret) {
Some(value) => match value {
Value::String(val_str) => Some(val_str.clone()),
Value::Bool(val_bool) => Some(val_bool.to_string()),
Value::Number(val_num) => Some(val_num.to_string()),
_ => None,
},
_ => None,
};
ClashInfo {
status: "init".into(),
port,
server,
secret,
}
}
}
pub struct Clash {
/// maintain the clash config
pub config: Mapping,
/// some info
pub info: ClashInfo,
}
impl Clash {
pub fn new() -> Clash {
let config = Clash::read_config();
let info = ClashInfo::from(&config);
Clash { config, info }
}
/// get clash config
pub fn read_config() -> Mapping {
config::read_yaml::<Mapping>(dirs::clash_path())
}
/// save the clash config
pub fn save_config(&self) -> Result<()> {
config::save_yaml(
dirs::clash_path(),
&self.config,
Some("# Default Config For Clash Core\n\n"),
)
}
/// patch update the clash config
/// if the port is changed then return true
pub fn patch_config(&mut self, patch: Mapping) -> Result<bool> {
let port_key = Value::from("mixed-port");
let server_key = Value::from("external-controller");
let secret_key = Value::from("secret");
let mut change_port = false;
let mut change_info = false;
for (key, value) in patch.into_iter() {
if key == port_key {
change_port = true;
}
if key == port_key || key == server_key || key == secret_key {
change_info = true;
}
self.config.insert(key, value);
}
if change_info {
self.info = ClashInfo::from(&self.config);
}
self.save_config()?;
Ok(change_port)
}
/// revise the `tun` and `dns` config
pub fn _tun_mode(mut config: Mapping, enable: bool) -> Mapping {
macro_rules! revise {
($map: expr, $key: expr, $val: expr) => {
let ret_key = Value::String($key.into());
$map.insert(ret_key, Value::from($val));
};
}
// if key not exists then append value
macro_rules! append {
($map: expr, $key: expr, $val: expr) => {
let ret_key = Value::String($key.into());
if !$map.contains_key(&ret_key) {
$map.insert(ret_key, Value::from($val));
}
};
}
// tun config
let tun_val = config.get(&Value::from("tun"));
let mut new_tun = Mapping::new();
if tun_val.is_some() && tun_val.as_ref().unwrap().is_mapping() {
new_tun = tun_val.as_ref().unwrap().as_mapping().unwrap().clone();
}
revise!(new_tun, "enable", enable);
if enable {
append!(new_tun, "stack", "gvisor");
append!(new_tun, "dns-hijack", vec!["198.18.0.2:53"]);
append!(new_tun, "auto-route", true);
append!(new_tun, "auto-detect-interface", true);
}
revise!(config, "tun", new_tun);
if enable {
// dns config
let dns_val = config.get(&Value::from("dns"));
let mut new_dns = Mapping::new();
if dns_val.is_some() && dns_val.as_ref().unwrap().is_mapping() {
new_dns = dns_val.as_ref().unwrap().as_mapping().unwrap().clone();
}
revise!(new_dns, "enable", enable);
// 借鉴cfw的默认配置
append!(new_dns, "enhanced-mode", "fake-ip");
append!(
new_dns,
"nameserver",
vec!["114.114.114.114", "223.5.5.5", "8.8.8.8"]
);
append!(new_dns, "fallback", vec![] as Vec<&str>);
#[cfg(target_os = "windows")]
append!(
new_dns,
"fake-ip-filter",
vec![
"dns.msftncsi.com",
"www.msftncsi.com",
"www.msftconnecttest.com"
]
);
revise!(config, "dns", new_dns);
}
config
}
/// only 5 default fields available (clash config fields)
/// convert to lowercase
pub fn strict_filter(config: Mapping) -> Mapping {
// Only the following fields are allowed:
// proxies/proxy-providers/proxy-groups/rule-providers/rules
let valid_keys = vec![
"proxies",
"proxy-providers",
"proxy-groups",
"rules",
"rule-providers",
];
let mut new_config = Mapping::new();
for (key, value) in config.into_iter() {
key.as_str().map(|key_str| {
// change to lowercase
let mut key_str = String::from(key_str);
key_str.make_ascii_lowercase();
// filter
if valid_keys.contains(&&*key_str) {
new_config.insert(Value::String(key_str), value);
}
});
}
new_config
}
/// more clash config fields available
/// convert to lowercase
pub fn loose_filter(config: Mapping) -> Mapping {
// all of these can not be revised by script or merge
// http/https/socks port should be under control
let not_allow = vec![
"port",
"socks-port",
"mixed-port",
"allow-lan",
"mode",
"external-controller",
"secret",
"log-level",
];
let mut new_config = Mapping::new();
for (key, value) in config.into_iter() {
key.as_str().map(|key_str| {
// change to lowercase
let mut key_str = String::from(key_str);
key_str.make_ascii_lowercase();
// filter
if !not_allow.contains(&&*key_str) {
new_config.insert(Value::String(key_str), value);
}
});
}
new_config
}
}
impl Default for Clash {
fn default() -> Self {
Clash::new()
}
}
-66
View File
@@ -1,66 +0,0 @@
use super::prfitem::PrfItem;
use crate::utils::{config, dirs};
use serde::{Deserialize, Serialize};
use serde_yaml::Mapping;
use std::fs;
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct PrfEnhanced {
pub current: Mapping,
pub chain: Vec<PrfData>,
pub valid: Vec<String>,
pub callback: String,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct PrfEnhancedResult {
pub data: Option<Mapping>,
pub status: String,
pub error: Option<String>,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct PrfData {
item: PrfItem,
#[serde(skip_serializing_if = "Option::is_none")]
merge: Option<Mapping>,
#[serde(skip_serializing_if = "Option::is_none")]
script: Option<String>,
}
impl PrfData {
pub fn from_item(item: &PrfItem) -> Option<PrfData> {
match item.itype.as_ref() {
Some(itype) => {
let file = item.file.clone()?;
let path = dirs::app_profiles_dir().join(file);
if !path.exists() {
return None;
}
match itype.as_str() {
"script" => Some(PrfData {
item: item.clone(),
script: Some(fs::read_to_string(path).unwrap_or("".into())),
merge: None,
}),
"merge" => Some(PrfData {
item: item.clone(),
merge: Some(config::read_yaml::<Mapping>(path)),
script: None,
}),
_ => None,
}
}
None => None,
}
}
}
+66
View File
@@ -0,0 +1,66 @@
use super::tray::Tray;
use crate::log_if_err;
use anyhow::{bail, Result};
use tauri::{AppHandle, Manager, Window};
#[derive(Debug, Default, Clone)]
pub struct Handle {
pub app_handle: Option<AppHandle>,
}
impl Handle {
pub fn set_inner(&mut self, app_handle: AppHandle) {
self.app_handle = Some(app_handle);
}
pub fn get_window(&self) -> Option<Window> {
self
.app_handle
.as_ref()
.map_or(None, |a| a.get_window("main"))
}
pub fn refresh_clash(&self) {
if let Some(window) = self.get_window() {
log_if_err!(window.emit("verge://refresh-clash-config", "yes"));
}
}
pub fn refresh_verge(&self) {
if let Some(window) = self.get_window() {
log_if_err!(window.emit("verge://refresh-verge-config", "yes"));
}
}
#[allow(unused)]
pub fn refresh_profiles(&self) {
if let Some(window) = self.get_window() {
log_if_err!(window.emit("verge://refresh-profiles-config", "yes"));
}
}
pub fn notice_message(&self, status: String, msg: String) {
if let Some(window) = self.get_window() {
log_if_err!(window.emit("verge://notice-message", (status, msg)));
}
}
pub fn update_systray(&self) -> Result<()> {
if self.app_handle.is_none() {
bail!("update_systray unhandle error");
}
let app_handle = self.app_handle.as_ref().unwrap();
Tray::update_systray(app_handle)?;
Ok(())
}
/// update the system tray state
pub fn update_systray_part(&self) -> Result<()> {
if self.app_handle.is_none() {
bail!("update_systray unhandle error");
}
let app_handle = self.app_handle.as_ref().unwrap();
Tray::update_part(app_handle)?;
Ok(())
}
}
+153
View File
@@ -0,0 +1,153 @@
use crate::{data::*, feat, log_if_err};
use anyhow::{bail, Result};
use std::collections::HashMap;
use tauri::{AppHandle, GlobalShortcutManager};
pub struct Hotkey {
current: Vec<String>, // 保存当前的热键设置
manager: Option<AppHandle>,
}
impl Hotkey {
pub fn new() -> Hotkey {
Hotkey {
current: Vec::new(),
manager: None,
}
}
pub fn init(&mut self, app_handle: AppHandle) -> Result<()> {
self.manager = Some(app_handle);
let data = Data::global();
let verge = data.verge.lock();
if let Some(hotkeys) = verge.hotkeys.as_ref() {
for hotkey in hotkeys.iter() {
let mut iter = hotkey.split(',');
let func = iter.next();
let key = iter.next();
if func.is_some() && key.is_some() {
log_if_err!(self.register(key.unwrap(), func.unwrap()));
} else {
log::error!(target: "app", "invalid hotkey \"{}\":\"{}\"", key.unwrap_or("None"), func.unwrap_or("None"));
}
}
self.current = hotkeys.clone();
}
Ok(())
}
fn get_manager(&self) -> Result<impl GlobalShortcutManager> {
if self.manager.is_none() {
bail!("failed to get hotkey manager");
}
Ok(self.manager.as_ref().unwrap().global_shortcut_manager())
}
fn register(&mut self, hotkey: &str, func: &str) -> Result<()> {
let mut manager = self.get_manager()?;
if manager.is_registered(hotkey)? {
manager.unregister(hotkey)?;
}
let f = match func.trim() {
"clash_mode_rule" => || feat::change_clash_mode("rule"),
"clash_mode_global" => || feat::change_clash_mode("global"),
"clash_mode_direct" => || feat::change_clash_mode("direct"),
"clash_mode_script" => || feat::change_clash_mode("script"),
"toggle_system_proxy" => || feat::toggle_system_proxy(),
"enable_system_proxy" => || feat::enable_system_proxy(),
"disable_system_proxy" => || feat::disable_system_proxy(),
"toggle_tun_mode" => || feat::toggle_tun_mode(),
"enable_tun_mode" => || feat::enable_tun_mode(),
"disable_tun_mode" => || feat::disable_tun_mode(),
_ => bail!("invalid function \"{func}\""),
};
manager.register(hotkey, f)?;
log::info!(target: "app", "register hotkey {hotkey} {func}");
Ok(())
}
fn unregister(&mut self, hotkey: &str) -> Result<()> {
self.get_manager()?.unregister(&hotkey)?;
log::info!(target: "app", "unregister hotkey {hotkey}");
Ok(())
}
pub fn update(&mut self, new_hotkeys: Vec<String>) -> Result<()> {
let current = self.current.to_owned();
let old_map = Self::get_map_from_vec(&current);
let new_map = Self::get_map_from_vec(&new_hotkeys);
let (del, add) = Self::get_diff(old_map, new_map);
del.iter().for_each(|key| {
let _ = self.unregister(key);
});
add.iter().for_each(|(key, func)| {
log_if_err!(self.register(key, func));
});
self.current = new_hotkeys;
Ok(())
}
fn get_map_from_vec<'a>(hotkeys: &'a Vec<String>) -> HashMap<&'a str, &'a str> {
let mut map = HashMap::new();
hotkeys.iter().for_each(|hotkey| {
let mut iter = hotkey.split(',');
let func = iter.next();
let key = iter.next();
if func.is_some() && key.is_some() {
let func = func.unwrap().trim();
let key = key.unwrap().trim();
map.insert(key, func);
}
});
map
}
fn get_diff<'a>(
old_map: HashMap<&'a str, &'a str>,
new_map: HashMap<&'a str, &'a str>,
) -> (Vec<&'a str>, Vec<(&'a str, &'a str)>) {
let mut del_list = vec![];
let mut add_list = vec![];
old_map.iter().for_each(|(&key, func)| {
match new_map.get(key) {
Some(new_func) => {
if new_func != func {
del_list.push(key);
add_list.push((key, *new_func));
}
}
None => del_list.push(key),
};
});
new_map.iter().for_each(|(&key, &func)| {
if old_map.get(key).is_none() {
add_list.push((key, func));
}
});
(del_list, add_list)
}
}
impl Drop for Hotkey {
fn drop(&mut self) {
if let Ok(mut manager) = self.get_manager() {
let _ = manager.unregister_all();
}
}
}
+213 -280
View File
@@ -1,131 +1,80 @@
use self::notice::Notice;
use self::handle::Handle;
use self::hotkey::Hotkey;
use self::sysopt::Sysopt;
use self::timer::Timer;
use crate::core::enhance::PrfEnhancedResult;
use crate::config::enhance_config;
use crate::data::*;
use crate::log_if_err;
use crate::utils::{dirs, help};
use anyhow::{bail, Result};
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use serde_yaml::Mapping;
use serde_yaml::{Mapping, Value};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Manager, Window};
use tokio::time::sleep;
mod clash;
mod enhance;
mod notice;
mod prfitem;
mod profiles;
mod handle;
mod hotkey;
mod service;
mod sysopt;
mod timer;
mod verge;
pub mod tray;
pub use self::clash::*;
pub use self::prfitem::*;
pub use self::profiles::*;
pub use self::service::*;
pub use self::verge::*;
#[derive(Clone)]
pub struct Core {
pub clash: Arc<Mutex<Clash>>,
pub verge: Arc<Mutex<Verge>>,
pub profiles: Arc<Mutex<Profiles>>,
pub service: Arc<Mutex<Service>>,
pub sysopt: Arc<Mutex<Sysopt>>,
pub timer: Arc<Mutex<Timer>>,
pub window: Arc<Mutex<Option<Window>>>,
pub hotkey: Arc<Mutex<Hotkey>>,
pub runtime: Arc<Mutex<RuntimeResult>>,
pub handle: Arc<Mutex<Handle>>,
}
impl Core {
pub fn new() -> Core {
let clash = Clash::new();
let verge = Verge::new();
let profiles = Profiles::new();
let service = Service::new();
pub fn global() -> &'static Core {
static CORE: OnceCell<Core> = OnceCell::new();
Core {
clash: Arc::new(Mutex::new(clash)),
verge: Arc::new(Mutex::new(verge)),
profiles: Arc::new(Mutex::new(profiles)),
service: Arc::new(Mutex::new(service)),
CORE.get_or_init(|| Core {
service: Arc::new(Mutex::new(Service::new())),
sysopt: Arc::new(Mutex::new(Sysopt::new())),
timer: Arc::new(Mutex::new(Timer::new())),
window: Arc::new(Mutex::new(None)),
}
hotkey: Arc::new(Mutex::new(Hotkey::new())),
runtime: Arc::new(Mutex::new(RuntimeResult::default())),
handle: Arc::new(Mutex::new(Handle::default())),
})
}
/// initialize the core state
pub fn init(&self, app_handle: tauri::AppHandle) {
#[cfg(windows)]
{
let verge = self.verge.lock();
let enable = verge.enable_service_mode.clone();
// kill old clash process
Service::kill_old_clash();
let mut service = self.service.lock();
service.set_mode(enable.unwrap_or(false));
let mut handle = self.handle.lock();
handle.set_inner(app_handle.clone());
drop(handle);
log_if_err!(service.start());
}
#[cfg(not(windows))]
{
let mut service = self.service.lock();
log_if_err!(service.start());
}
let mut service = self.service.lock();
log_if_err!(service.start());
drop(service);
log_if_err!(self.activate());
let clash = self.clash.lock();
let verge = self.verge.lock();
let silent_start = verge.enable_silent_start.clone();
let auto_launch = verge.enable_auto_launch.clone();
// silent start
if silent_start.unwrap_or(false) {
let window = self.window.lock();
window.as_ref().map(|win| {
win.hide().unwrap();
});
}
let mut sysopt = self.sysopt.lock();
log_if_err!(sysopt.init_launch());
log_if_err!(sysopt.init_sysproxy());
drop(sysopt);
sysopt.init_sysproxy(clash.info.port.clone(), &verge);
let handle = self.handle.lock();
log_if_err!(handle.update_systray_part());
drop(handle);
drop(clash);
drop(verge);
log_if_err!(sysopt.init_launch(auto_launch));
log_if_err!(self.update_systray(&app_handle));
// wait the window setup during resolve app
let core = self.clone();
tauri::async_runtime::spawn(async move {
sleep(Duration::from_secs(2)).await;
log_if_err!(core.activate_enhanced(true));
});
let mut hotkey = self.hotkey.lock();
log_if_err!(hotkey.init(app_handle));
drop(hotkey);
// timer initialize
let mut timer = self.timer.lock();
timer.set_core(self.clone());
log_if_err!(timer.refresh());
}
/// save the window instance
pub fn set_win(&self, win: Option<Window>) {
let mut window = self.window.lock();
*window = win;
log_if_err!(timer.restore());
}
/// restart the clash sidecar
@@ -133,247 +82,234 @@ impl Core {
let mut service = self.service.lock();
service.restart()?;
drop(service);
self.activate()
}
self.activate()?;
self.activate_enhanced(true)
/// change the clash core
pub fn change_core(&self, clash_core: Option<String>) -> Result<()> {
let clash_core = clash_core.unwrap_or("clash".into());
if &clash_core != "clash" && &clash_core != "clash-meta" {
bail!("invalid clash core name \"{clash_core}\"");
}
let global = Data::global();
let mut verge = global.verge.lock();
verge.patch_config(Verge {
clash_core: Some(clash_core.clone()),
..Verge::default()
})?;
drop(verge);
let mut service = self.service.lock();
service.clear_logs();
service.restart()?;
drop(service);
self.activate()
}
/// Patch Clash
/// handle the clash config changed
pub fn patch_clash(&self, patch: Mapping) -> Result<()> {
let (changed, port) = {
let mut clash = self.clash.lock();
(clash.patch_config(patch)?, clash.info.port.clone())
let has_port = patch.contains_key(&Value::from("mixed-port"));
let has_mode = patch.contains_key(&Value::from("mode"));
let port = {
let global = Data::global();
let mut clash = global.clash.lock();
clash.patch_config(patch)?;
clash.info.port.clone()
};
// todo: port check
if changed {
if has_port && port.is_some() {
let mut service = self.service.lock();
service.restart()?;
drop(service);
self.activate()?;
self.activate_enhanced(true)?;
let mut sysopt = self.sysopt.lock();
let verge = self.verge.lock();
sysopt.init_sysproxy(port, &verge);
sysopt.init_sysproxy()?;
}
if has_mode {
let handle = self.handle.lock();
handle.update_systray_part()?;
}
Ok(())
}
/// Patch Verge
pub fn patch_verge(&self, patch: Verge, app_handle: &AppHandle) -> Result<()> {
let tun_mode = patch.enable_tun_mode.clone();
let auto_launch = patch.enable_auto_launch.clone();
let system_proxy = patch.enable_system_proxy.clone();
let proxy_bypass = patch.system_proxy_bypass.clone();
let proxy_guard = patch.enable_proxy_guard.clone();
#[cfg(windows)]
{
let service_mode = patch.enable_service_mode.clone();
if service_mode.is_some() {
let service_mode = service_mode.unwrap();
let mut service = self.service.lock();
service.stop()?;
service.set_mode(service_mode);
service.start()?;
drop(service);
self.activate_enhanced(false)?;
}
}
if auto_launch.is_some() {
let mut sysopt = self.sysopt.lock();
sysopt.update_launch(auto_launch)?;
}
if system_proxy.is_some() || proxy_bypass.is_some() {
let mut sysopt = self.sysopt.lock();
sysopt.update_sysproxy(system_proxy.clone(), proxy_bypass)?;
sysopt.guard_proxy();
}
if proxy_guard.unwrap_or(false) {
let sysopt = self.sysopt.lock();
sysopt.guard_proxy();
}
#[cfg(target_os = "windows")]
if tun_mode.is_some() && *tun_mode.as_ref().unwrap_or(&false) {
let wintun_dll = dirs::app_home_dir().join("wintun.dll");
if !wintun_dll.exists() {
bail!("failed to enable TUN for missing `wintun.dll`");
}
}
pub fn patch_verge(&self, patch: Verge) -> Result<()> {
// save the patch
let mut verge = self.verge.lock();
verge.patch_config(patch)?;
let global = Data::global();
let mut verge = global.verge.lock();
verge.patch_config(patch.clone())?;
drop(verge);
if system_proxy.is_some() || tun_mode.is_some() {
self.update_systray(app_handle)?;
let tun_mode = patch.enable_tun_mode;
let auto_launch = patch.enable_auto_launch;
let system_proxy = patch.enable_system_proxy;
let proxy_bypass = patch.system_proxy_bypass;
let proxy_guard = patch.enable_proxy_guard;
let language = patch.language;
#[cfg(target_os = "windows")]
{
let service_mode = patch.enable_service_mode;
// 重启服务
if service_mode.is_some() {
let mut service = self.service.lock();
service.restart()?;
drop(service);
}
if tun_mode.is_some() && *tun_mode.as_ref().unwrap_or(&false) {
let wintun_dll = crate::utils::dirs::app_home_dir().join("wintun.dll");
if !wintun_dll.exists() {
bail!("failed to enable TUN for missing `wintun.dll`");
}
}
if service_mode.is_some() || tun_mode.is_some() {
self.activate()?;
}
}
#[cfg(not(target_os = "windows"))]
if tun_mode.is_some() {
self.activate_enhanced(false)?;
self.activate()?;
}
let mut sysopt = self.sysopt.lock();
if auto_launch.is_some() {
sysopt.update_launch()?;
}
if system_proxy.is_some() || proxy_bypass.is_some() {
sysopt.update_sysproxy()?;
sysopt.guard_proxy();
}
if proxy_guard.unwrap_or(false) {
sysopt.guard_proxy();
}
// 更新tray
if language.is_some() {
let handle = self.handle.lock();
handle.update_systray()?;
} else if system_proxy.is_some() || tun_mode.is_some() {
let handle = self.handle.lock();
handle.update_systray_part()?;
}
if patch.hotkeys.is_some() {
let mut hotkey = self.hotkey.lock();
hotkey.update(patch.hotkeys.unwrap())?;
}
Ok(())
}
/// update the system tray state
pub fn update_systray(&self, app_handle: &AppHandle) -> Result<()> {
let verge = self.verge.lock();
let tray = app_handle.tray_handle();
// update rule/global/direct/script mode
pub fn update_mode(&self, mode: &str) -> Result<()> {
// save config to file
let info = {
let global = Data::global();
let mut clash = global.clash.lock();
clash.config.insert(Value::from("mode"), Value::from(mode));
clash.save_config()?;
clash.info.clone()
};
let system_proxy = verge.enable_system_proxy.as_ref();
let tun_mode = verge.enable_tun_mode.as_ref();
let mut mapping = Mapping::new();
mapping.insert(Value::from("mode"), Value::from(mode));
tray
.get_item("system_proxy")
.set_selected(*system_proxy.unwrap_or(&false))?;
tray
.get_item("tun_mode")
.set_selected(*tun_mode.unwrap_or(&false))?;
let handle = self.handle.clone();
// update verge config
let window = app_handle.get_window("main");
let notice = Notice::from(window);
notice.refresh_verge();
tauri::async_runtime::spawn(async move {
log_if_err!(Service::patch_config(info, mapping.to_owned()).await);
// update tray
let handle = handle.lock();
handle.refresh_clash();
log_if_err!(handle.update_systray_part());
});
Ok(())
}
/// activate the profile
/// auto activate enhanced profile
/// 触发clash配置更新
pub fn activate(&self) -> Result<()> {
let data = {
let profiles = self.profiles.lock();
let data = profiles.gen_activate()?;
Clash::strict_filter(data)
let global = Data::global();
let verge = global.verge.lock();
let clash = global.clash.lock();
let profiles = global.profiles.lock();
let tun_mode = verge.enable_tun_mode.clone().unwrap_or(false);
let profile_activate = profiles.gen_activate()?;
let clash_config = clash.config.clone();
let clash_info = clash.info.clone();
drop(clash);
drop(verge);
drop(profiles);
let (config, exists_keys, logs) = enhance_config(
clash_config,
profile_activate.current,
profile_activate.chain,
profile_activate.valid,
tun_mode,
);
let mut runtime = self.runtime.lock();
*runtime = RuntimeResult {
config: Some(config.clone()),
config_yaml: Some(serde_yaml::to_string(&config).unwrap_or("".into())),
exists_keys,
chain_logs: logs,
};
drop(runtime);
let (mut config, info) = {
let clash = self.clash.lock();
let config = clash.config.clone();
let info = clash.info.clone();
(config, info)
};
let mut service = self.service.lock();
service.check_start()?;
drop(service);
for (key, value) in data.into_iter() {
config.insert(key, value);
}
let config = {
let verge = self.verge.lock();
let tun_mode = verge.enable_tun_mode.unwrap_or(false);
Clash::_tun_mode(config, tun_mode)
};
let notice = {
let window = self.window.lock();
Notice::from(window.clone())
};
let service = self.service.lock();
service.set_config(info, config, notice)
}
/// Enhanced
/// enhanced profiles mode
pub fn activate_enhanced(&self, skip: bool) -> Result<()> {
let window = self.window.lock();
if window.is_none() {
bail!("failed to get the main window");
}
let event_name = help::get_uid("e");
let event_name = format!("enhanced-cb-{event_name}");
// generate the payload
let payload = {
let profiles = self.profiles.lock();
profiles.gen_enhanced(event_name.clone())?
};
// do not run enhanced
if payload.chain.len() == 0 {
if skip {
return Ok(());
}
drop(window);
return self.activate();
}
let tun_mode = {
let verge = self.verge.lock();
verge.enable_tun_mode.unwrap_or(false)
};
let info = {
let clash = self.clash.lock();
clash.info.clone()
};
let notice = Notice::from(window.clone());
let service = self.service.clone();
let window = window.clone().unwrap();
window.once(&event_name, move |event| {
let result = event.payload();
if result.is_none() {
log::warn!("event payload result is none");
return;
}
let result = result.unwrap();
let result: PrfEnhancedResult = serde_json::from_str(result).unwrap();
if let Some(data) = result.data {
let mut config = Clash::read_config();
let filter_data = Clash::loose_filter(data); // loose filter
for (key, value) in filter_data.into_iter() {
config.insert(key, value);
let handle = self.handle.clone();
tauri::async_runtime::spawn(async move {
match Service::set_config(clash_info, config).await {
Ok(_) => {
let handle = handle.lock();
handle.refresh_clash();
handle.notice_message("set_config::ok".into(), "ok".into());
}
Err(err) => {
let handle = handle.lock();
handle.notice_message("set_config::error".into(), format!("{err}"));
log::error!(target: "app", "last {err}")
}
let config = Clash::_tun_mode(config, tun_mode);
let service = service.lock();
log_if_err!(service.set_config(info, config, notice));
log::info!("profile enhanced status {}", result.status);
}
result.error.map(|err| log::error!("{err}"));
});
window.emit("script-handler", payload).unwrap();
Ok(())
}
}
impl Core {
/// Static function
/// update profile item
pub async fn update_profile_item(
core: Core,
uid: String,
option: Option<PrfOption>,
) -> Result<()> {
pub async fn update_profile_item(&self, uid: String, option: Option<PrfOption>) -> Result<()> {
let global = Data::global();
let (url, opt) = {
let profiles = core.profiles.lock();
let profiles = global.profiles.lock();
let item = profiles.get_item(&uid)?;
if let Some(typ) = item.itype.as_ref() {
@@ -382,30 +318,27 @@ impl Core {
// reactivate the config
if Some(uid) == profiles.get_current() {
drop(profiles);
return core.activate_enhanced(false);
self.activate()?;
}
return Ok(());
}
}
if item.url.is_none() {
bail!("failed to get the profile item url");
}
(item.url.clone().unwrap(), item.option.clone())
};
let merged_opt = PrfOption::merge(opt, option);
let item = PrfItem::from_url(&url, None, None, merged_opt).await?;
let mut profiles = core.profiles.lock();
let mut profiles = global.profiles.lock();
profiles.update_item(uid.clone(), item)?;
// reactivate the profile
if Some(uid) == profiles.get_current() {
drop(profiles);
core.activate_enhanced(false)?;
self.activate()?;
}
Ok(())
-35
View File
@@ -1,35 +0,0 @@
use crate::log_if_err;
use tauri::Window;
#[derive(Debug, Default, Clone)]
pub struct Notice {
win: Option<Window>,
}
impl Notice {
pub fn from(win: Option<Window>) -> Notice {
Notice { win }
}
pub fn set_win(&mut self, win: Option<Window>) {
self.win = win;
}
pub fn refresh_clash(&self) {
if let Some(window) = self.win.as_ref() {
log_if_err!(window.emit("verge://refresh-clash-config", "yes"));
}
}
pub fn refresh_verge(&self) {
if let Some(window) = self.win.as_ref() {
log_if_err!(window.emit("verge://refresh-verge-config", "yes"));
}
}
pub fn refresh_profiles(&self) {
if let Some(window) = self.win.as_ref() {
log_if_err!(window.emit("verge://refresh-profiles-config", "yes"));
}
}
}
+230 -83
View File
@@ -1,79 +1,91 @@
use super::{notice::Notice, ClashInfo};
use crate::data::{ClashInfo, Data};
use crate::log_if_err;
use crate::utils::{config, dirs};
use anyhow::{bail, Result};
use parking_lot::RwLock;
use reqwest::header::HeaderMap;
use serde::{Deserialize, Serialize};
use serde_yaml::Mapping;
use std::{collections::HashMap, time::Duration};
use std::fs;
use std::io::Write;
use std::sync::Arc;
use std::{
collections::{HashMap, VecDeque},
time::Duration,
};
use tauri::api::process::{Command, CommandChild, CommandEvent};
use tokio::time::sleep;
const LOGS_QUEUE_LEN: usize = 100;
#[derive(Debug)]
pub struct Service {
sidecar: Option<CommandChild>,
logs: Arc<RwLock<VecDeque<String>>>,
#[allow(unused)]
service_mode: bool,
use_service_mode: bool,
}
impl Service {
pub fn new() -> Service {
let queue = VecDeque::with_capacity(LOGS_QUEUE_LEN + 10);
Service {
sidecar: None,
service_mode: false,
logs: Arc::new(RwLock::new(queue)),
use_service_mode: false,
}
}
#[allow(unused)]
pub fn set_mode(&mut self, enable: bool) {
self.service_mode = enable;
}
#[cfg(not(windows))]
pub fn start(&mut self) -> Result<()> {
self.start_clash_by_sidecar()
}
#[cfg(not(target_os = "windows"))]
self.start_clash_by_sidecar()?;
#[cfg(windows)]
pub fn start(&mut self) -> Result<()> {
if !self.service_mode {
return self.start_clash_by_sidecar();
}
#[cfg(target_os = "windows")]
{
let enable = {
let data = Data::global();
let verge = data.verge.lock();
verge.enable_service_mode.clone().unwrap_or(false)
};
tauri::async_runtime::spawn(async move {
match Self::check_service().await {
Ok(status) => {
// 未启动clash
if status.code != 0 {
if let Err(err) = Self::start_clash_by_service().await {
log::error!("{err}");
self.use_service_mode = enable;
if !enable {
return self.start_clash_by_sidecar();
}
tauri::async_runtime::spawn(async move {
match Self::check_service().await {
Ok(status) => {
// 未启动clash
if status.code != 0 {
log_if_err!(Self::start_clash_by_service().await);
}
}
Err(err) => log::error!(target: "app", "{err}"),
}
Err(err) => log::error!("{err}"),
}
});
});
}
Ok(())
}
#[cfg(not(windows))]
pub fn stop(&mut self) -> Result<()> {
self.stop_clash_by_sidecar()
}
#[cfg(not(target_os = "windows"))]
self.stop_clash_by_sidecar()?;
#[cfg(windows)]
pub fn stop(&mut self) -> Result<()> {
if !self.service_mode {
return self.stop_clash_by_sidecar();
}
#[cfg(target_os = "windows")]
{
let _ = self.stop_clash_by_sidecar();
tauri::async_runtime::spawn(async move {
if let Err(err) = Self::stop_clash_by_service().await {
log::error!("{err}");
if self.use_service_mode {
tauri::async_runtime::block_on(async move {
log_if_err!(Self::stop_clash_by_service().await);
});
}
});
}
Ok(())
}
@@ -83,26 +95,85 @@ impl Service {
self.start()
}
pub fn get_logs(&self) -> VecDeque<String> {
self.logs.read().clone()
}
#[allow(unused)]
pub fn set_logs(&self, text: String) {
let mut logs = self.logs.write();
if logs.len() > LOGS_QUEUE_LEN {
(*logs).pop_front();
}
(*logs).push_back(text);
}
pub fn clear_logs(&self) {
let mut logs = self.logs.write();
(*logs).clear();
}
/// start the clash sidecar
fn start_clash_by_sidecar(&mut self) -> Result<()> {
if self.sidecar.is_some() {
bail!("could not run clash sidecar twice");
let sidecar = self.sidecar.take().unwrap();
let _ = sidecar.kill();
}
let clash_core: String = {
let global = Data::global();
let verge = global.verge.lock();
verge.clash_core.clone().unwrap_or("clash".into())
};
let app_dir = dirs::app_home_dir();
let app_dir = app_dir.as_os_str().to_str().unwrap();
let cmd = Command::new_sidecar("clash")?;
let (mut rx, cmd_child) = cmd.args(["-d", app_dir]).spawn()?;
// fix #212
let args = match clash_core.as_str() {
"clash-meta" => vec!["-m", "-d", app_dir],
_ => vec!["-d", app_dir],
};
let cmd = Command::new_sidecar(clash_core)?;
let (mut rx, cmd_child) = cmd.args(args).spawn()?;
// 将pid写入文件中
let pid = cmd_child.pid();
log_if_err!(|| -> Result<()> {
let path = dirs::clash_pid_path();
fs::File::create(path)?.write(format!("{pid}").as_bytes())?;
Ok(())
}());
self.sidecar = Some(cmd_child);
// clash log
let logs = self.logs.clone();
tauri::async_runtime::spawn(async move {
let write_log = |text: String| {
let mut logs = logs.write();
if logs.len() >= LOGS_QUEUE_LEN {
(*logs).pop_front();
}
(*logs).push_back(text);
};
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => log::info!("[clash]: {}", line),
CommandEvent::Stderr(err) => log::error!("[clash]: {}", err),
CommandEvent::Stdout(line) => {
let can_short = line.starts_with("time=") && line.len() > 33;
let stdout = if can_short { &line[33..] } else { &line };
log::info!(target: "app" ,"[clash]: {}", stdout);
write_log(line);
}
CommandEvent::Stderr(err) => {
log::error!(target: "app" ,"[clash error]: {}", err);
write_log(err);
}
CommandEvent::Error(err) => log::error!(target: "app" ,"{err}"),
CommandEvent::Terminated(_) => break,
_ => {}
}
}
@@ -119,18 +190,89 @@ impl Service {
Ok(())
}
/// update clash config
/// using PUT methods
pub fn set_config(&self, info: ClashInfo, config: Mapping, notice: Notice) -> Result<()> {
if !self.service_mode && self.sidecar.is_none() {
bail!("did not start sidecar");
pub fn check_start(&mut self) -> Result<()> {
#[cfg(target_os = "windows")]
{
let global = Data::global();
let verge = global.verge.lock();
let service_mode = verge.enable_service_mode.unwrap_or(false);
if !service_mode && self.sidecar.is_none() {
self.start()?;
}
}
#[cfg(not(target_os = "windows"))]
if self.sidecar.is_none() {
self.start()?;
}
Ok(())
}
/// update clash config
/// using PUT methods
pub async fn set_config(info: ClashInfo, config: Mapping) -> Result<()> {
let temp_path = dirs::profiles_temp_path();
config::save_yaml(temp_path.clone(), &config, Some("# Clash Verge Temp File"))?;
let (server, headers) = Self::clash_client_info(info)?;
let mut data = HashMap::new();
data.insert("path", temp_path.as_os_str().to_str().unwrap());
macro_rules! report_err {
($i: expr, $e: expr) => {
match $i {
4 => bail!($e),
_ => log::error!(target: "app", $e),
}
};
}
// retry 5 times
for i in 0..5 {
let headers = headers.clone();
match reqwest::ClientBuilder::new().no_proxy().build() {
Ok(client) => {
let builder = client.put(&server).headers(headers).json(&data);
match builder.send().await {
Ok(resp) => match resp.status().as_u16() {
204 => break,
// 配置有问题不重试
400 => bail!("failed to update clash config with status 400"),
status @ _ => report_err!(i, "failed to activate clash with status \"{status}\""),
},
Err(err) => report_err!(i, "{err}"),
}
}
Err(err) => report_err!(i, "{err}"),
}
sleep(Duration::from_millis(500)).await;
}
Ok(())
}
/// patch clash config
pub async fn patch_config(info: ClashInfo, config: Mapping) -> Result<()> {
let (server, headers) = Self::clash_client_info(info)?;
let client = reqwest::ClientBuilder::new().no_proxy().build()?;
let builder = client.patch(&server).headers(headers.clone()).json(&config);
builder.send().await?;
Ok(())
}
/// get clash client url and headers from clash info
fn clash_client_info(info: ClashInfo) -> Result<(String, HeaderMap)> {
if info.server.is_none() {
bail!("failed to parse the server");
let status = &info.status;
if info.port.is_none() {
bail!("failed to parse config.yaml file with status {status}");
} else {
bail!("failed to parse the server with status {status}");
}
}
let server = info.server.unwrap();
@@ -144,37 +286,24 @@ impl Service {
headers.insert("Authorization", secret);
}
tauri::async_runtime::spawn(async move {
let mut data = HashMap::new();
data.insert("path", temp_path.as_os_str().to_str().unwrap());
Ok((server, headers))
}
// retry 5 times
for _ in 0..5 {
match reqwest::ClientBuilder::new().no_proxy().build() {
Ok(client) => {
let builder = client.put(&server).headers(headers.clone()).json(&data);
/// kill old clash process
pub fn kill_old_clash() {
use sysinfo::{Pid, PidExt, ProcessExt, System, SystemExt};
match builder.send().await {
Ok(resp) => {
if resp.status() != 204 {
log::error!("failed to activate clash with status \"{}\"", resp.status());
}
if let Ok(pid) = fs::read(dirs::clash_pid_path()) {
if let Ok(pid) = String::from_utf8_lossy(&pid).parse() {
let mut system = System::new();
system.refresh_all();
notice.refresh_clash();
// do not retry
break;
}
Err(err) => log::error!("failed to activate for `{err}`"),
}
}
Err(err) => log::error!("failed to activate for `{err}`"),
let proc = system.process(Pid::from_u32(pid));
if let Some(proc) = proc {
proc.kill();
}
sleep(Duration::from_millis(500)).await;
}
});
Ok(())
}
}
}
@@ -186,12 +315,13 @@ impl Drop for Service {
/// ### Service Mode
///
#[cfg(windows)]
#[cfg(target_os = "windows")]
pub mod win_service {
use super::*;
use anyhow::Context;
use deelevate::{PrivilegeLevel, Token};
use runas::Command as RunasCommand;
use serde::{Deserialize, Serialize};
use std::os::windows::process::CommandExt;
use std::{env::current_exe, process::Command as StdCommand};
@@ -300,7 +430,9 @@ pub mod win_service {
/// stop service
pub async fn stop_service() -> Result<()> {
let url = format!("{SERVICE_URL}/stop_service");
let res = reqwest::Client::new()
let res = reqwest::ClientBuilder::new()
.no_proxy()
.build()?
.post(url)
.send()
.await?
@@ -318,7 +450,11 @@ pub mod win_service {
/// check the windows service status
pub async fn check_service() -> Result<JsonResponse> {
let url = format!("{SERVICE_URL}/get_clash");
let response = reqwest::get(url)
let response = reqwest::ClientBuilder::new()
.no_proxy()
.build()?
.get(url)
.send()
.await?
.json::<JsonResponse>()
.await
@@ -336,7 +472,14 @@ pub mod win_service {
sleep(Duration::from_secs(1)).await;
}
let bin_path = current_exe().unwrap().with_file_name("clash.exe");
let clash_core = {
let global = Data::global();
let verge = global.verge.lock();
verge.clash_core.clone().unwrap_or("clash".into())
};
let clash_bin = format!("{clash_core}.exe");
let bin_path = current_exe().unwrap().with_file_name(clash_bin);
let bin_path = bin_path.as_os_str().to_str().unwrap();
let config_dir = dirs::app_home_dir();
@@ -351,7 +494,9 @@ pub mod win_service {
map.insert("log_file", log_path);
let url = format!("{SERVICE_URL}/start_clash");
let res = reqwest::Client::new()
let res = reqwest::ClientBuilder::new()
.no_proxy()
.build()?
.post(url)
.json(&map)
.send()
@@ -370,7 +515,9 @@ pub mod win_service {
/// stop the clash by service
pub(super) async fn stop_clash_by_service() -> Result<()> {
let url = format!("{SERVICE_URL}/stop_clash");
let res = reqwest::Client::new()
let res = reqwest::ClientBuilder::new()
.no_proxy()
.build()?
.post(url)
.send()
.await?
+167 -87
View File
@@ -1,18 +1,17 @@
use super::{Clash, Verge};
use crate::{log_if_err, utils::sysopt::SysProxyConfig};
use anyhow::{bail, Result};
use crate::{data::*, log_if_err};
use anyhow::{anyhow, bail, Result};
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
// use parking_lot::Mutex;
use std::sync::Arc;
use sysproxy::Sysproxy;
use tauri::{async_runtime::Mutex, utils::platform::current_exe};
pub struct Sysopt {
/// current system proxy setting
cur_sysproxy: Option<SysProxyConfig>,
cur_sysproxy: Option<Sysproxy>,
/// record the original system proxy
/// recover it when exit
old_sysproxy: Option<SysProxyConfig>,
old_sysproxy: Option<Sysproxy>,
/// helps to auto launch the app
auto_launch: Option<AutoLaunch>,
@@ -21,6 +20,13 @@ pub struct Sysopt {
guard_state: Arc<Mutex<bool>>,
}
#[cfg(target_os = "windows")]
static DEFAULT_BYPASS: &str = "localhost;127.*;192.168.*;<local>";
#[cfg(target_os = "linux")]
static DEFAULT_BYPASS: &str = "localhost,127.0.0.1/8,::1";
#[cfg(target_os = "macos")]
static DEFAULT_BYPASS: &str = "127.0.0.1,localhost,<local>";
impl Sysopt {
pub fn new() -> Sysopt {
Sysopt {
@@ -32,116 +38,180 @@ impl Sysopt {
}
/// init the sysproxy
pub fn init_sysproxy(&mut self, port: Option<String>, verge: &Verge) {
if let Some(port) = port {
let enable = verge.enable_system_proxy.clone().unwrap_or(false);
pub fn init_sysproxy(&mut self) -> Result<()> {
let data = Data::global();
let clash = data.clash.lock();
let port = clash.info.port.clone();
self.old_sysproxy = match SysProxyConfig::get_sys() {
Ok(proxy) => Some(proxy),
Err(_) => None,
};
let bypass = verge.system_proxy_bypass.clone();
let sysproxy = SysProxyConfig::new(enable, port, bypass);
if enable {
if let Err(err) = sysproxy.set_sys() {
log::error!("failed to set system proxy for `{err}`");
}
}
self.cur_sysproxy = Some(sysproxy);
if port.is_none() {
bail!("clash port is none");
}
// launchs the system proxy guard
let verge = data.verge.lock();
let enable = verge.enable_system_proxy.clone().unwrap_or(false);
let bypass = verge.system_proxy_bypass.clone();
let bypass = bypass.unwrap_or(DEFAULT_BYPASS.into());
let port = port.unwrap().parse::<u16>()?;
let host = String::from("127.0.0.1");
self.cur_sysproxy = Some(Sysproxy {
enable,
host,
port,
bypass,
});
if enable {
self.old_sysproxy = Sysproxy::get_system_proxy().map_or(None, |p| Some(p));
self.cur_sysproxy.as_ref().unwrap().set_system_proxy()?;
}
// run the system proxy guard
self.guard_proxy();
Ok(())
}
/// update the system proxy
/// when the verge config is changed
pub fn update_sysproxy(&mut self, enable: Option<bool>, bypass: Option<String>) -> Result<()> {
let sysproxy = self.cur_sysproxy.take();
if sysproxy.is_none() {
bail!("unhandle error for sysproxy is none");
pub fn update_sysproxy(&mut self) -> Result<()> {
if self.cur_sysproxy.is_none() || self.old_sysproxy.is_none() {
return self.init_sysproxy();
}
let mut sysproxy = sysproxy.unwrap();
let data = Data::global();
let verge = data.verge.lock();
if let Some(enable) = enable {
sysproxy.enable = enable;
}
let enable = verge.enable_system_proxy.clone().unwrap_or(false);
let bypass = verge.system_proxy_bypass.clone();
let bypass = bypass.unwrap_or(DEFAULT_BYPASS.into());
if let Some(bypass) = bypass {
sysproxy.bypass = bypass;
}
let mut sysproxy = self.cur_sysproxy.take().unwrap();
sysproxy.enable = enable;
sysproxy.bypass = bypass;
self.cur_sysproxy = Some(sysproxy);
if self.cur_sysproxy.as_ref().unwrap().set_sys().is_err() {
bail!("failed to set system proxy");
}
self.cur_sysproxy.as_ref().unwrap().set_system_proxy()?;
Ok(())
}
/// reset the sysproxy
pub fn reset_sysproxy(&mut self) {
if let Some(sysproxy) = self.old_sysproxy.take() {
match sysproxy.set_sys() {
Ok(_) => self.cur_sysproxy = None,
Err(_) => log::error!("failed to reset proxy"),
}
}
}
pub fn reset_sysproxy(&mut self) -> Result<()> {
let cur = self.cur_sysproxy.take();
/// get current proxy
pub fn get_sysproxy(&self) -> Result<Option<SysProxyConfig>> {
Ok(self.cur_sysproxy.clone())
if let Some(mut old) = self.old_sysproxy.take() {
// 如果原代理和当前代理 端口一致,就disable关闭,否则就恢复原代理设置
// 当前没有设置代理的时候,不确定旧设置是否和当前一致,全关了
let port_same = cur.map_or(true, |cur| old.port == cur.port);
if old.enable && port_same {
old.enable = false;
log::info!(target: "app", "reset proxy by disabling the original proxy");
} else {
log::info!(target: "app", "reset proxy to the original proxy");
}
old.set_system_proxy()?;
} else if let Some(mut cur @ Sysproxy { enable: true, .. }) = cur {
// 没有原代理,就按现在的代理设置disable即可
log::info!(target: "app", "reset proxy by disabling the current proxy");
cur.enable = false;
cur.set_system_proxy()?;
} else {
log::info!(target: "app", "reset proxy with no action");
}
Ok(())
}
/// init the auto launch
pub fn init_launch(&mut self, enable: Option<bool>) -> Result<()> {
let app_exe = current_exe().unwrap();
let app_exe = dunce::canonicalize(app_exe).unwrap();
let app_name = app_exe.file_stem().unwrap().to_str().unwrap();
let app_path = app_exe.as_os_str().to_str().unwrap();
pub fn init_launch(&mut self) -> Result<()> {
let data = Data::global();
let verge = data.verge.lock();
let enable = verge.enable_auto_launch.clone().unwrap_or(false);
let app_exe = current_exe()?;
let app_exe = dunce::canonicalize(app_exe)?;
let app_name = app_exe
.file_stem()
.and_then(|f| f.to_str())
.ok_or(anyhow!("failed to get file stem"))?;
let app_path = app_exe
.as_os_str()
.to_str()
.ok_or(anyhow!("failed to get app_path"))?
.to_string();
// fix issue #26
#[cfg(target_os = "windows")]
let app_path = format!("\"{app_path}\"");
#[cfg(target_os = "windows")]
let app_path = app_path.as_str();
// use the /Applications/Clash Verge.app path
#[cfg(target_os = "macos")]
let app_path = (|| -> Option<String> {
let path = std::path::PathBuf::from(&app_path);
let path = path.parent()?.parent()?.parent()?;
let extension = path.extension()?.to_str()?;
match extension == "app" {
true => Some(path.as_os_str().to_str()?.to_string()),
false => None,
}
})()
.unwrap_or(app_path);
let auto = AutoLaunchBuilder::new()
.set_app_name(app_name)
.set_app_path(app_path)
.build();
.set_app_path(&app_path)
.build()?;
if let Some(enable) = enable {
// fix issue #26
self.auto_launch = Some(auto);
// 避免在开发时将自启动关了
#[cfg(feature = "verge-dev")]
if !enable {
return Ok(());
}
let auto = self.auto_launch.as_ref().unwrap();
// macos每次启动都更新登录项,避免重复设置登录项
#[cfg(target_os = "macos")]
{
let _ = auto.disable();
if enable {
auto.enable()?;
}
}
self.auto_launch = Some(auto);
#[cfg(not(target_os = "macos"))]
{
match enable {
true => auto.enable()?,
false => auto.disable()?,
};
}
Ok(())
}
/// update the startup
pub fn update_launch(&mut self, enable: Option<bool>) -> Result<()> {
if enable.is_none() {
return Ok(());
pub fn update_launch(&mut self) -> Result<()> {
if self.auto_launch.is_none() {
return self.init_launch();
}
let enable = enable.unwrap();
let data = Data::global();
let verge = data.verge.lock();
let enable = verge.enable_auto_launch.clone().unwrap_or(false);
let auto_launch = self.auto_launch.as_ref().unwrap();
match enable {
true => auto_launch.enable()?,
false => auto_launch.disable()?,
false => crate::log_if_err!(auto_launch.disable()), // 忽略关闭的错误
};
Ok(())
@@ -169,32 +239,42 @@ impl Sysopt {
loop {
sleep(Duration::from_secs(wait_secs)).await;
let verge = Verge::new();
let global = Data::global();
let verge = global.verge.lock();
let enable_proxy = verge.enable_system_proxy.unwrap_or(false);
let enable_guard = verge.enable_proxy_guard.unwrap_or(false);
let guard_duration = verge.proxy_guard_duration.unwrap_or(10);
let enable = verge.enable_system_proxy.clone().unwrap_or(false);
let guard = verge.enable_proxy_guard.clone().unwrap_or(false);
let guard_duration = verge.proxy_guard_duration.clone().unwrap_or(10);
let bypass = verge.system_proxy_bypass.clone();
drop(verge);
// stop loop
if !enable || !guard {
break;
}
// update duration
wait_secs = guard_duration;
// stop loop
if !enable_guard || !enable_proxy {
break;
}
let clash = global.clash.lock();
let port = clash.info.port.clone();
let port = port.unwrap_or("".into()).parse::<u16>();
drop(clash);
log::debug!("try to guard the system proxy");
log::debug!(target: "app", "try to guard the system proxy");
let clash = Clash::new();
match port {
Ok(port) => {
let sysproxy = Sysproxy {
enable: true,
host: "127.0.0.1".into(),
port,
bypass: bypass.unwrap_or(DEFAULT_BYPASS.into()),
};
match &clash.info.port {
Some(port) => {
let bypass = verge.system_proxy_bypass.clone();
let sysproxy = SysProxyConfig::new(true, port.clone(), bypass);
log_if_err!(sysproxy.set_sys());
log_if_err!(sysproxy.set_system_proxy());
}
None => log::error!("failed to parse clash port"),
Err(_) => log::error!(target: "app", "failed to parse clash port"),
}
}
+39 -19
View File
@@ -1,6 +1,7 @@
use super::Core;
use crate::log_if_err;
use anyhow::{bail, Context, Result};
use crate::utils::help::get_now;
use crate::{data::Data, log_if_err};
use anyhow::{Context, Result};
use delay_timer::prelude::{DelayTimer, DelayTimerBuilder, TaskBuilder};
use std::collections::HashMap;
@@ -15,9 +16,6 @@ pub struct Timer {
/// increment id
timer_count: TaskID,
/// save the instance of the app
core: Option<Core>,
}
impl Timer {
@@ -26,20 +24,11 @@ impl Timer {
delay_timer: DelayTimerBuilder::default().build(),
timer_map: HashMap::new(),
timer_count: 1,
core: None,
}
}
pub fn set_core(&mut self, core: Core) {
self.core = Some(core);
}
/// Correctly update all cron tasks
pub fn refresh(&mut self) -> Result<()> {
if self.core.is_none() {
bail!("unhandle error for core is none");
}
let diff_map = self.gen_diff();
for (uid, diff) in diff_map.into_iter() {
@@ -63,9 +52,40 @@ impl Timer {
Ok(())
}
/// restore timer
pub fn restore(&mut self) -> Result<()> {
self.refresh()?;
let cur_timestamp = get_now(); // seconds
let global = Data::global();
let profiles = global.profiles.lock();
profiles
.get_items()
.unwrap_or(&vec![])
.iter()
.filter(|item| item.uid.is_some() && item.updated.is_some() && item.option.is_some())
.filter(|item| {
// mins to seconds
let interval = item.option.as_ref().unwrap().update_interval.unwrap_or(0) as usize * 60;
let updated = item.updated.unwrap();
return interval > 0 && cur_timestamp - updated >= interval;
})
.for_each(|item| {
let uid = item.uid.as_ref().unwrap();
if let Some((task_id, _)) = self.timer_map.get(uid) {
log_if_err!(self.delay_timer.advance_task(*task_id));
}
});
Ok(())
}
/// generate a uid -> update_interval map
fn gen_map(&self) -> HashMap<String, u64> {
let profiles = self.core.as_ref().unwrap().profiles.lock();
let global = Data::global();
let profiles = global.profiles.lock();
let mut new_map = HashMap::new();
@@ -119,14 +139,14 @@ impl Timer {
/// add a cron task
fn add_task(&self, uid: String, tid: TaskID, minutes: u64) -> Result<()> {
let core = self.core.clone().unwrap();
let core = Core::global();
let task = TaskBuilder::default()
.set_task_id(tid)
.set_maximum_parallel_runnable_num(1)
.set_frequency_repeated_by_minutes(minutes)
// .set_frequency_repeated_by_seconds(minutes) // for test
.spawn_async_routine(move || Self::async_task(core.clone(), uid.clone()))
.spawn_async_routine(move || Self::async_task(core.to_owned(), uid.to_owned()))
.context("failed to create timer task")?;
self
@@ -139,8 +159,8 @@ impl Timer {
/// the task runner
async fn async_task(core: Core, uid: String) {
log::info!("running timer task `{uid}`");
log_if_err!(Core::update_profile_item(core, uid, None).await);
log::info!(target: "app", "running timer task `{uid}`");
log_if_err!(core.update_profile_item(uid, None).await);
}
}
+124
View File
@@ -0,0 +1,124 @@
use crate::{data::Data, feat, utils::resolve};
use anyhow::{Ok, Result};
use tauri::{
api, AppHandle, CustomMenuItem, Manager, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem,
SystemTraySubmenu,
};
pub struct Tray {}
impl Tray {
pub fn tray_menu(app_handle: &AppHandle) -> SystemTrayMenu {
let data = Data::global();
let zh = {
let verge = data.verge.lock();
verge.language == Some("zh".into())
};
let version = app_handle.package_info().version.to_string();
if zh {
SystemTrayMenu::new()
.add_item(CustomMenuItem::new("open_window", "打开面板"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("rule_mode", "规则模式"))
.add_item(CustomMenuItem::new("global_mode", "全局模式"))
.add_item(CustomMenuItem::new("direct_mode", "直连模式"))
.add_item(CustomMenuItem::new("script_mode", "脚本模式"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("system_proxy", "系统代理"))
.add_item(CustomMenuItem::new("tun_mode", "TUN 模式"))
.add_submenu(SystemTraySubmenu::new(
"更多",
SystemTrayMenu::new()
.add_item(CustomMenuItem::new("restart_clash", "重启 Clash"))
.add_item(CustomMenuItem::new("restart_app", "重启应用"))
.add_item(CustomMenuItem::new("app_version", format!("Version {version}")).disabled()),
))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("quit", "退出").accelerator("CmdOrControl+Q"))
} else {
SystemTrayMenu::new()
.add_item(CustomMenuItem::new("open_window", "Dashboard"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("rule_mode", "Rule Mode"))
.add_item(CustomMenuItem::new("global_mode", "Global Mode"))
.add_item(CustomMenuItem::new("direct_mode", "Direct Mode"))
.add_item(CustomMenuItem::new("script_mode", "Script Mode"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("system_proxy", "System Proxy"))
.add_item(CustomMenuItem::new("tun_mode", "Tun Mode"))
.add_submenu(SystemTraySubmenu::new(
"More",
SystemTrayMenu::new()
.add_item(CustomMenuItem::new("restart_clash", "Restart Clash"))
.add_item(CustomMenuItem::new("restart_app", "Restart App"))
.add_item(CustomMenuItem::new("app_version", format!("Version {version}")).disabled()),
))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("quit", "Quit").accelerator("CmdOrControl+Q"))
}
}
pub fn update_systray(app_handle: &AppHandle) -> Result<()> {
app_handle
.tray_handle()
.set_menu(Tray::tray_menu(app_handle))?;
Tray::update_part(app_handle)?;
Ok(())
}
pub fn update_part(app_handle: &AppHandle) -> Result<()> {
let global = Data::global();
let clash = global.clash.lock();
let mode = clash
.config
.get(&serde_yaml::Value::from("mode"))
.map(|val| val.as_str().unwrap_or("rule"))
.unwrap_or("rule");
let tray = app_handle.tray_handle();
let _ = tray.get_item("rule_mode").set_selected(mode == "rule");
let _ = tray.get_item("global_mode").set_selected(mode == "global");
let _ = tray.get_item("direct_mode").set_selected(mode == "direct");
let _ = tray.get_item("script_mode").set_selected(mode == "script");
let verge = global.verge.lock();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
let _ = tray.get_item("system_proxy").set_selected(*system_proxy);
let _ = tray.get_item("tun_mode").set_selected(*tun_mode);
Ok(())
}
pub fn on_system_tray_event(app_handle: &AppHandle, event: SystemTrayEvent) {
match event {
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
mode @ ("rule_mode" | "global_mode" | "direct_mode" | "script_mode") => {
let mode = &mode[0..mode.len() - 5];
feat::change_clash_mode(mode);
}
"open_window" => resolve::create_window(app_handle),
"system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(),
"restart_clash" => feat::restart_clash_core(),
"restart_app" => api::process::restart(&app_handle.env()),
"quit" => {
resolve::resolve_reset();
api::process::kill_children();
app_handle.exit(0);
}
_ => {}
},
#[cfg(target_os = "windows")]
SystemTrayEvent::LeftClick { .. } => {
resolve::create_window(app_handle);
}
_ => {}
}
}
}
+167
View File
@@ -0,0 +1,167 @@
use crate::utils::{config, dirs};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping, Value};
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct ClashInfo {
/// clash sidecar status
pub status: String,
/// clash core port
pub port: Option<String>,
/// same as `external-controller`
pub server: Option<String>,
/// clash secret
pub secret: Option<String>,
}
impl ClashInfo {
/// parse the clash's config.yaml
/// get some information
pub fn from(config: &Mapping) -> ClashInfo {
let key_port_1 = Value::from("mixed-port");
let key_port_2 = Value::from("port");
let key_server = Value::from("external-controller");
let key_secret = Value::from("secret");
let mut status: u32 = 0;
let port = match config.get(&key_port_1) {
Some(value) => match value {
Value::String(val_str) => Some(val_str.clone()),
Value::Number(val_num) => Some(val_num.to_string()),
_ => {
status |= 0b1;
None
}
},
_ => {
status |= 0b10;
None
}
};
let port = match port {
Some(_) => port,
None => match config.get(&key_port_2) {
Some(value) => match value {
Value::String(val_str) => Some(val_str.clone()),
Value::Number(val_num) => Some(val_num.to_string()),
_ => {
status |= 0b100;
None
}
},
_ => {
status |= 0b1000;
None
}
},
};
// `external-controller` could be
// "127.0.0.1:9090" or ":9090"
let server = match config.get(&key_server) {
Some(value) => match value.as_str() {
Some(val_str) => {
if val_str.starts_with(":") {
Some(format!("127.0.0.1{val_str}"))
} else if val_str.starts_with("0.0.0.0:") {
Some(format!("127.0.0.1:{}", &val_str[8..]))
} else if val_str.starts_with("[::]:") {
Some(format!("127.0.0.1:{}", &val_str[5..]))
} else {
Some(val_str.into())
}
}
None => {
status |= 0b10000;
None
}
},
None => {
status |= 0b100000;
None
}
};
let secret = match config.get(&key_secret) {
Some(value) => match value {
Value::String(val_str) => Some(val_str.clone()),
Value::Bool(val_bool) => Some(val_bool.to_string()),
Value::Number(val_num) => Some(val_num.to_string()),
_ => None,
},
_ => None,
};
ClashInfo {
status: format!("{status}"),
port,
server,
secret,
}
}
}
#[derive(Debug)]
pub struct Clash {
/// maintain the clash config
pub config: Mapping,
/// some info
pub info: ClashInfo,
}
impl Clash {
pub fn new() -> Clash {
let config = Clash::read_config();
let info = ClashInfo::from(&config);
Clash { config, info }
}
/// get clash config
pub fn read_config() -> Mapping {
config::read_merge_mapping(dirs::clash_path())
}
/// save the clash config
pub fn save_config(&self) -> Result<()> {
config::save_yaml(
dirs::clash_path(),
&self.config,
Some("# Default Config For Clash Core\n\n"),
)
}
/// patch update the clash config
/// if the port is changed then return true
pub fn patch_config(&mut self, patch: Mapping) -> Result<()> {
let port_key = Value::from("mixed-port");
let server_key = Value::from("external-controller");
let secret_key = Value::from("secret");
let change_info = patch.contains_key(&port_key)
|| patch.contains_key(&server_key)
|| patch.contains_key(&secret_key);
for (key, value) in patch.into_iter() {
self.config.insert(key, value);
}
if change_info {
self.info = ClashInfo::from(&self.config);
}
self.save_config()
}
}
impl Default for Clash {
fn default() -> Self {
Clash::new()
}
}
+32
View File
@@ -0,0 +1,32 @@
mod clash;
mod prfitem;
mod profiles;
mod verge;
pub use self::clash::*;
pub use self::prfitem::*;
pub use self::profiles::*;
pub use self::verge::*;
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct Data {
pub clash: Arc<Mutex<Clash>>,
pub verge: Arc<Mutex<Verge>>,
pub profiles: Arc<Mutex<Profiles>>,
}
impl Data {
pub fn global() -> &'static Data {
static DATA: OnceCell<Data> = OnceCell::new();
DATA.get_or_init(|| Data {
clash: Arc::new(Mutex::new(Clash::new())),
verge: Arc::new(Mutex::new(Verge::new())),
profiles: Arc::new(Mutex::new(Profiles::new())),
})
}
}
@@ -1,8 +1,10 @@
use crate::utils::{dirs, help, tmpl};
use crate::utils::{config, dirs, help, tmpl};
use anyhow::{bail, Context, Result};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use serde_yaml::Mapping;
use std::fs;
use sysproxy::Sysproxy;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PrfItem {
@@ -69,39 +71,31 @@ pub struct PrfOption {
pub user_agent: Option<String>,
/// for `remote` profile
/// use system proxy
#[serde(skip_serializing_if = "Option::is_none")]
pub with_proxy: Option<bool>,
/// for `remote` profile
/// use self proxy
#[serde(skip_serializing_if = "Option::is_none")]
pub self_proxy: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub update_interval: Option<u64>,
}
impl PrfOption {
pub fn merge(one: Option<Self>, other: Option<Self>) -> Option<Self> {
if one.is_none() {
return other;
match (one, other) {
(Some(mut a), Some(b)) => {
a.user_agent = b.user_agent.or(a.user_agent);
a.with_proxy = b.with_proxy.or(a.with_proxy);
a.self_proxy = b.self_proxy.or(a.self_proxy);
a.update_interval = b.update_interval.or(a.update_interval);
Some(a)
}
t @ _ => t.0.or(t.1),
}
if one.is_some() && other.is_some() {
let mut one = one.unwrap();
let other = other.unwrap();
if let Some(val) = other.user_agent {
one.user_agent = Some(val);
}
if let Some(val) = other.with_proxy {
one.with_proxy = Some(val);
}
if let Some(val) = other.update_interval {
one.update_interval = Some(val);
}
return Some(one);
}
return one;
}
}
@@ -189,27 +183,64 @@ impl PrfItem {
desc: Option<String>,
option: Option<PrfOption>,
) -> Result<PrfItem> {
let with_proxy = match option.as_ref() {
Some(opt) => opt.with_proxy.unwrap_or(false),
None => false,
};
let user_agent = match option.as_ref() {
Some(opt) => opt.user_agent.clone(),
None => None,
};
let opt_ref = option.as_ref();
let with_proxy = opt_ref.map_or(false, |o| o.with_proxy.unwrap_or(false));
let self_proxy = opt_ref.map_or(false, |o| o.self_proxy.unwrap_or(false));
let user_agent = opt_ref.map_or(None, |o| o.user_agent.clone());
let mut builder = reqwest::ClientBuilder::new();
let mut builder = reqwest::ClientBuilder::new().no_proxy();
if !with_proxy {
builder = builder.no_proxy();
// 使用软件自己的代理
if self_proxy {
let data = super::Data::global();
let port = data.clash.lock().info.port.clone();
let port = port.ok_or(anyhow::anyhow!("failed to get clash info 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 {
match Sysproxy::get_system_proxy() {
Ok(p @ Sysproxy { enable: true, .. }) => {
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);
}
}
_ => {}
};
}
builder = builder.user_agent(user_agent.unwrap_or("clash-verge/v1.0.0".into()));
let version = unsafe { dirs::APP_VERSION };
let version = format!("clash-verge/{version}");
builder = builder.user_agent(user_agent.unwrap_or(version));
let resp = builder.build()?.get(url).send().await?;
let status_code = resp.status();
if !StatusCode::is_success(&status_code) {
bail!("failed to fetch remote profile with status {status_code}")
}
let header = resp.headers();
// parse the Subscription Userinfo
// parse the Subscription UserInfo
let extra = match header.get("Subscription-Userinfo") {
Some(value) => {
let sub_info = value.to_str().unwrap_or("");
@@ -251,8 +282,11 @@ impl PrfItem {
let data = resp.text_with_charset("utf-8").await?;
// check the data whether the valid yaml format
if !serde_yaml::from_str::<Mapping>(&data).is_ok() {
bail!("the remote profile data is not valid yaml");
let yaml = serde_yaml::from_str::<Mapping>(&data) //
.context("the remote profile data is invalid yaml")?;
if !yaml.contains_key("proxies") && !yaml.contains_key("proxy-providers") {
bail!("profile does not contain `proxies` or `proxy-providers`");
}
Ok(PrfItem {
@@ -333,4 +367,40 @@ impl PrfItem {
let path = dirs::app_profiles_dir().join(file);
fs::write(path, data.as_bytes()).context("failed to save the file")
}
/// get the data for enhanced mode
pub fn to_enhance(&self) -> Option<ChainItem> {
let itype = self.itype.as_ref()?.as_str();
let file = self.file.clone()?;
let uid = self.uid.clone().unwrap_or("".into());
let path = dirs::app_profiles_dir().join(file);
if !path.exists() {
return None;
}
match itype {
"script" => Some(ChainItem {
uid,
data: ChainType::Script(fs::read_to_string(path).unwrap_or("".into())),
}),
"merge" => Some(ChainItem {
uid,
data: ChainType::Merge(config::read_merge_mapping(path)),
}),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct ChainItem {
pub uid: String,
pub data: ChainType,
}
#[derive(Debug, Clone)]
pub enum ChainType {
Merge(Mapping),
Script(String),
}
@@ -1,9 +1,10 @@
use super::enhance::{PrfData, PrfEnhanced};
use super::prfitem::PrfItem;
use super::ChainItem;
use crate::utils::{config, dirs, help};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use serde_yaml::Mapping;
use std::collections::HashMap;
use std::{fs, io::Write};
///
@@ -91,13 +92,15 @@ impl Profiles {
}
/// just change the `chain`
pub fn put_chain(&mut self, chain: Option<Vec<String>>) {
pub fn put_chain(&mut self, chain: Option<Vec<String>>) -> Result<()> {
self.chain = chain;
self.save_file()
}
/// just change the `field`
pub fn put_valid(&mut self, valid: Option<Vec<String>>) {
pub fn put_valid(&mut self, valid: Option<Vec<String>>) -> Result<()> {
self.valid = valid;
self.save_file()
}
/// get items ref
@@ -118,7 +121,7 @@ impl Profiles {
}
}
bail!("failed to get the item by \"{}\"", uid);
bail!("failed to get the profile item \"uid:{uid}\"");
}
/// append new item
@@ -175,7 +178,7 @@ impl Profiles {
}
self.items = Some(items);
bail!("failed to found the uid \"{uid}\"")
bail!("failed to find the profile item \"uid:{uid}\"")
}
/// be used to update the remote item
@@ -260,8 +263,8 @@ impl Profiles {
Ok(current == uid)
}
/// only generate config mapping
pub fn gen_activate(&self) -> Result<Mapping> {
/// generate the current Mapping data
fn gen_current(&self) -> Result<Mapping> {
let config = Mapping::new();
if self.current.is_none() || self.items.is_none() {
@@ -269,7 +272,6 @@ impl Profiles {
}
let current = self.current.clone().unwrap();
for item in self.items.as_ref().unwrap().iter() {
if item.uid == Some(current.clone()) {
let file_path = match item.file.clone() {
@@ -281,37 +283,46 @@ impl Profiles {
bail!("failed to read the file \"{}\"", file_path.display());
}
return Ok(config::read_yaml::<Mapping>(file_path.clone()));
return Ok(config::read_merge_mapping(file_path.clone()));
}
}
bail!("failed to found the uid \"{current}\"");
bail!("failed to find current profile \"uid:{current}\"");
}
/// gen the enhanced profiles
pub fn gen_enhanced(&self, callback: String) -> Result<PrfEnhanced> {
let current = self.gen_activate()?;
/// generate the data for activate clash config
pub fn gen_activate(&self) -> Result<PrfActivate> {
let current = self.gen_current()?;
let chain = match self.chain.as_ref() {
Some(chain) => chain
.iter()
.map(|uid| self.get_item(uid))
.filter(|item| item.is_ok())
.map(|item| item.unwrap())
.map(|item| PrfData::from_item(item))
.filter(|o| o.is_some())
.map(|o| o.unwrap())
.collect::<Vec<PrfData>>(),
.filter_map(|uid| self.get_item(uid).ok())
.filter_map(|item| item.to_enhance())
.collect::<Vec<ChainItem>>(),
None => vec![],
};
let valid = self.valid.clone().unwrap_or(vec![]);
Ok(PrfEnhanced {
Ok(PrfActivate {
current,
chain,
valid,
callback,
})
}
}
#[derive(Default, Clone)]
pub struct PrfActivate {
pub current: Mapping,
pub chain: Vec<ChainItem>,
pub valid: Vec<String>,
}
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct RuntimeResult {
pub config: Option<Mapping>,
pub config_yaml: Option<String>,
// 记录在配置中(包括merge和script生成的)出现过的keys
// 这些keys不一定都生效
pub exists_keys: Vec<String>,
pub chain_logs: HashMap<String, Vec<(String, String)>>,
}
@@ -5,10 +5,14 @@ use serde::{Deserialize, Serialize};
/// ### `verge.yaml` schema
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct Verge {
/// app listening port
/// for app singleton
pub app_singleton_port: Option<u16>,
// i18n
pub language: Option<String>,
/// `light` or `dark`
/// `light` or `dark` or `system`
pub theme_mode: Option<String>,
/// enable blur mode
@@ -45,6 +49,17 @@ pub struct Verge {
/// theme setting
pub theme_setting: Option<VergeTheme>,
/// web ui list
pub web_ui_list: Option<Vec<String>>,
/// clash core path
#[serde(skip_serializing_if = "Option::is_none")]
pub clash_core: Option<String>,
/// hotkey map
/// format: {func},{key}
pub hotkeys: Option<Vec<String>>,
}
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
@@ -80,52 +95,32 @@ impl Verge {
/// patch verge config
/// only save to file
pub fn patch_config(&mut self, patch: Verge) -> Result<()> {
// only change it
if patch.language.is_some() {
self.language = patch.language;
}
if patch.theme_mode.is_some() {
self.theme_mode = patch.theme_mode;
}
if patch.theme_blur.is_some() {
self.theme_blur = patch.theme_blur;
}
if patch.theme_setting.is_some() {
self.theme_setting = patch.theme_setting;
}
if patch.traffic_graph.is_some() {
self.traffic_graph = patch.traffic_graph;
macro_rules! patch {
($key: tt) => {
if patch.$key.is_some() {
self.$key = patch.$key;
}
};
}
// system setting
if patch.enable_silent_start.is_some() {
self.enable_silent_start = patch.enable_silent_start;
}
if patch.enable_auto_launch.is_some() {
self.enable_auto_launch = patch.enable_auto_launch;
}
patch!(language);
patch!(theme_mode);
patch!(theme_blur);
patch!(traffic_graph);
// proxy
if patch.enable_system_proxy.is_some() {
self.enable_system_proxy = patch.enable_system_proxy;
}
if patch.system_proxy_bypass.is_some() {
self.system_proxy_bypass = patch.system_proxy_bypass;
}
if patch.enable_proxy_guard.is_some() {
self.enable_proxy_guard = patch.enable_proxy_guard;
}
if patch.proxy_guard_duration.is_some() {
self.proxy_guard_duration = patch.proxy_guard_duration;
}
patch!(enable_tun_mode);
patch!(enable_service_mode);
patch!(enable_auto_launch);
patch!(enable_silent_start);
patch!(enable_system_proxy);
patch!(enable_proxy_guard);
patch!(system_proxy_bypass);
patch!(proxy_guard_duration);
// tun mode
if patch.enable_tun_mode.is_some() {
self.enable_tun_mode = patch.enable_tun_mode;
}
if patch.enable_service_mode.is_some() {
self.enable_service_mode = patch.enable_service_mode;
}
patch!(theme_setting);
patch!(web_ui_list);
patch!(clash_core);
patch!(hotkeys);
self.save_file()
}
+102
View File
@@ -0,0 +1,102 @@
use crate::core::*;
use crate::data::*;
use crate::log_if_err;
// 重启clash
pub fn restart_clash_core() {
let core = Core::global();
let mut service = core.service.lock();
log_if_err!(service.restart());
drop(service);
log_if_err!(core.activate());
}
// 切换模式
pub fn change_clash_mode(mode: &str) {
let core = Core::global();
log_if_err!(core.update_mode(mode));
}
// 切换系统代理
pub fn toggle_system_proxy() {
let core = Core::global();
let data = Data::global();
let verge = data.verge.lock();
let enable = !verge.enable_system_proxy.clone().unwrap_or(false);
drop(verge);
log_if_err!(core.patch_verge(Verge {
enable_system_proxy: Some(enable),
..Verge::default()
}));
let handle = core.handle.lock();
let _ = handle.refresh_verge();
}
// 打开系统代理
pub fn enable_system_proxy() {
let core = Core::global();
log_if_err!(core.patch_verge(Verge {
enable_system_proxy: Some(true),
..Verge::default()
}));
let handle = core.handle.lock();
let _ = handle.refresh_verge();
}
// 关闭系统代理
pub fn disable_system_proxy() {
let core = Core::global();
log_if_err!(core.patch_verge(Verge {
enable_system_proxy: Some(false),
..Verge::default()
}));
let handle = core.handle.lock();
let _ = handle.refresh_verge();
}
// 切换tun模式
pub fn toggle_tun_mode() {
let core = Core::global();
let data = Data::global();
let verge = data.verge.lock();
let enable = !verge.enable_tun_mode.clone().unwrap_or(false);
drop(verge);
log_if_err!(core.patch_verge(Verge {
enable_tun_mode: Some(enable),
..Verge::default()
}));
let handle = core.handle.lock();
let _ = handle.refresh_verge();
}
// 打开tun模式
pub fn enable_tun_mode() {
let core = Core::global();
log_if_err!(core.patch_verge(Verge {
enable_tun_mode: Some(true),
..Verge::default()
}));
let handle = core.handle.lock();
let _ = handle.refresh_verge();
}
// 关闭tun模式
pub fn disable_tun_mode() {
let core = Core::global();
log_if_err!(core.patch_verge(Verge {
enable_tun_mode: Some(false),
..Verge::default()
}));
let handle = core.handle.lock();
let _ = handle.refresh_verge();
}
+68 -106
View File
@@ -4,18 +4,17 @@
)]
mod cmds;
mod config;
mod core;
mod data;
mod feat;
mod utils;
use crate::{
core::Verge,
utils::{resolve, server},
};
use tauri::{
api, CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem,
};
use crate::utils::{init, resolve, server};
use tauri::{api, Manager, SystemTray};
fn main() -> std::io::Result<()> {
// 单例检测
if server::check_singleton().is_err() {
println!("app exists");
return Ok(());
@@ -24,94 +23,37 @@ fn main() -> std::io::Result<()> {
#[cfg(target_os = "windows")]
unsafe {
use crate::utils::dirs;
dirs::init_portable_flag();
}
let tray_menu = SystemTrayMenu::new()
.add_item(CustomMenuItem::new("open_window", "Show"))
.add_item(CustomMenuItem::new("system_proxy", "System Proxy"))
.add_item(CustomMenuItem::new("tun_mode", "Tun Mode"))
.add_item(CustomMenuItem::new("restart_clash", "Restart Clash"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("quit", "Quit").accelerator("CmdOrControl+Q"));
crate::log_if_err!(init::init_config());
#[allow(unused_mut)]
let mut builder = tauri::Builder::default()
.manage(core::Core::new())
.setup(|app| Ok(resolve::resolve_setup(app)))
.system_tray(SystemTray::new().with_menu(tray_menu))
.on_system_tray_event(move |app_handle, event| match event {
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
"open_window" => {
let window = app_handle.get_window("main").unwrap();
window.unminimize().unwrap();
window.show().unwrap();
window.set_focus().unwrap();
}
"system_proxy" => {
let core = app_handle.state::<core::Core>();
let new_value = {
let verge = core.verge.lock();
!verge.enable_system_proxy.clone().unwrap_or(false)
};
let patch = Verge {
enable_system_proxy: Some(new_value),
..Verge::default()
};
crate::log_if_err!(core.patch_verge(patch, app_handle));
}
"tun_mode" => {
let core = app_handle.state::<core::Core>();
let new_value = {
let verge = core.verge.lock();
!verge.enable_tun_mode.clone().unwrap_or(false)
};
let patch = Verge {
enable_tun_mode: Some(new_value),
..Verge::default()
};
crate::log_if_err!(core.patch_verge(patch, app_handle));
}
"restart_clash" => {
let core = app_handle.state::<core::Core>();
crate::log_if_err!(core.restart_clash());
}
"quit" => {
resolve::resolve_reset(app_handle);
app_handle.exit(0);
}
_ => {}
},
#[cfg(target_os = "windows")]
SystemTrayEvent::LeftClick { .. } => {
let window = app_handle.get_window("main").unwrap();
window.unminimize().unwrap();
window.show().unwrap();
window.set_focus().unwrap();
}
_ => {}
})
.system_tray(SystemTray::new())
.on_system_tray_event(core::tray::Tray::on_system_tray_event)
.invoke_handler(tauri::generate_handler![
// common
cmds::restart_sidecar,
cmds::get_sys_proxy,
cmds::get_cur_proxy,
cmds::kill_sidecar,
cmds::open_app_dir,
cmds::open_logs_dir,
cmds::open_web_url,
cmds::kill_sidecar,
cmds::restart_sidecar,
// clash
cmds::get_clash_info,
cmds::get_clash_logs,
cmds::patch_clash_config,
cmds::change_clash_core,
cmds::get_runtime_config,
cmds::get_runtime_yaml,
cmds::get_runtime_exists,
cmds::get_runtime_logs,
// verge
cmds::get_verge_config,
cmds::patch_verge_config,
cmds::update_hotkeys,
// profile
cmds::view_profile,
cmds::patch_profile,
@@ -138,41 +80,61 @@ fn main() -> std::io::Result<()> {
{
use tauri::{Menu, MenuItem, Submenu};
let submenu_file = Submenu::new(
"File",
Menu::new()
.add_native_item(MenuItem::Undo)
.add_native_item(MenuItem::Redo)
.add_native_item(MenuItem::Copy)
.add_native_item(MenuItem::Paste)
.add_native_item(MenuItem::Cut)
.add_native_item(MenuItem::SelectAll),
builder = builder.menu(
Menu::new().add_submenu(Submenu::new(
"File",
Menu::new()
.add_native_item(MenuItem::Undo)
.add_native_item(MenuItem::Redo)
.add_native_item(MenuItem::Copy)
.add_native_item(MenuItem::Paste)
.add_native_item(MenuItem::Cut)
.add_native_item(MenuItem::SelectAll),
)),
);
builder = builder.menu(Menu::new().add_submenu(submenu_file));
}
builder
#[allow(unused_mut)]
let mut app = builder
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|app_handle, e| match e {
tauri::RunEvent::WindowEvent { label, event, .. } => match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
let app_handle = app_handle.clone();
api.prevent_close();
app_handle.get_window(&label).unwrap().hide().unwrap();
.expect("error while running tauri application");
#[cfg(target_os = "macos")]
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
let app_handle = app.app_handle();
ctrlc::set_handler(move || {
resolve::resolve_reset();
app_handle.exit(0);
})
.expect("error while exiting.");
#[allow(unused)]
app.run(|app_handle, e| match e {
tauri::RunEvent::ExitRequested { api, .. } => {
api.prevent_exit();
}
tauri::RunEvent::Exit => {
resolve::resolve_reset();
api::process::kill_children();
app_handle.exit(0);
}
#[cfg(target_os = "macos")]
tauri::RunEvent::WindowEvent { label, event, .. } => {
if label == "main" {
match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
api.prevent_close();
app_handle.get_window("main").map(|win| {
let _ = win.hide();
});
}
_ => {}
}
_ => {}
},
tauri::RunEvent::ExitRequested { .. } => {
resolve::resolve_reset(app_handle);
api::process::kill_children();
}
tauri::RunEvent::Exit => {
resolve::resolve_reset(app_handle);
api::process::kill_children();
}
_ => {}
});
}
_ => {}
});
Ok(())
}
-11
View File
@@ -1,11 +0,0 @@
// use crate::core::{Clash, Profiles, Verge};
// use std::sync::{Arc, Mutex};
// #[derive(Default)]
// pub struct ProfilesState(pub Arc<Mutex<Profiles>>);
// #[derive(Default)]
// pub struct ClashState(pub Arc<Mutex<Clash>>);
// #[derive(Default)]
// pub struct VergeState(pub Arc<Mutex<Verge>>);
+38 -2
View File
@@ -1,11 +1,47 @@
use anyhow::{Context, Result};
use serde::{de::DeserializeOwned, Serialize};
use serde_yaml::{Mapping, Value};
use std::{fs, path::PathBuf};
/// read data from yaml as struct T
pub fn read_yaml<T: DeserializeOwned + Default>(path: PathBuf) -> T {
let yaml_str = fs::read_to_string(path).unwrap_or("".into());
serde_yaml::from_str::<T>(&yaml_str).unwrap_or(T::default())
if !path.exists() {
log::error!(target: "app", "file not found \"{}\"", path.display());
return T::default();
}
let yaml_str = fs::read_to_string(&path).unwrap_or("".into());
match serde_yaml::from_str::<T>(&yaml_str) {
Ok(val) => val,
Err(_) => {
log::error!(target: "app", "failed to read yaml file \"{}\"", path.display());
T::default()
}
}
}
/// read mapping from yaml fix #165
pub fn read_merge_mapping(path: PathBuf) -> Mapping {
let map = Mapping::new();
if !path.exists() {
log::error!(target: "app", "file not found \"{}\"", path.display());
return map;
}
let yaml_str = fs::read_to_string(&path).unwrap_or("".into());
match serde_yaml::from_str::<Value>(&yaml_str) {
Ok(mut val) => {
crate::log_if_err!(val.apply_merge());
val.as_mapping().unwrap_or(&map).to_owned()
}
Err(_) => {
log::error!(target: "app", "failed to read yaml file \"{}\"", path.display());
map
}
}
}
/// save the data to the file
+10 -2
View File
@@ -15,13 +15,14 @@ static VERGE_CONFIG: &str = "verge.yaml";
static PROFILE_YAML: &str = "profiles.yaml";
static PROFILE_TEMP: &str = "clash-verge-runtime.yaml";
#[cfg(windows)]
static mut RESOURCE_DIR: Option<PathBuf> = None;
/// portable flag
#[allow(unused)]
static mut PORTABLE_FLAG: bool = false;
pub static mut APP_VERSION: &str = "v1.1.1";
/// initialize portable flag
#[allow(unused)]
pub unsafe fn init_portable_flag() {
@@ -65,9 +66,12 @@ pub fn app_resources_dir(package_info: &PackageInfo) -> PathBuf {
.unwrap()
.join("resources");
#[cfg(windows)]
unsafe {
RESOURCE_DIR = Some(res_dir.clone());
let ver = package_info.version.to_string();
let ver_str = format!("v{ver}");
APP_VERSION = Box::leak(Box::new(ver_str));
}
res_dir
@@ -103,6 +107,10 @@ pub fn profiles_temp_path() -> PathBuf {
return app_home_dir().join(PROFILE_TEMP);
}
pub fn clash_pid_path() -> PathBuf {
unsafe { RESOURCE_DIR.clone().unwrap().join("clash.pid") }
}
#[cfg(windows)]
static SERVICE_PATH: &str = "clash-verge-service.exe";
+22 -29
View File
@@ -1,4 +1,4 @@
use anyhow::{bail, Result};
use anyhow::Result;
use nanoid::nanoid;
use std::path::PathBuf;
use std::process::Command;
@@ -28,20 +28,16 @@ pub fn get_uid(prefix: &str) -> String {
/// parse the string
/// xxx=123123; => 123123
pub fn parse_str<T: FromStr>(target: &str, key: &str) -> Option<T> {
match target.find(key) {
Some(idx) => {
let idx = idx + key.len();
let value = &target[idx..];
match match value.split(';').nth(0) {
Some(value) => value.trim().parse(),
None => value.trim().parse(),
} {
Ok(r) => Some(r),
Err(_) => None,
}
target.find(key).and_then(|idx| {
let idx = idx + key.len();
let value = &target[idx..];
match value.split(';').nth(0) {
Some(value) => value.trim().parse(),
None => value.trim().parse(),
}
None => None,
}
.ok()
})
}
/// open file
@@ -49,38 +45,35 @@ pub fn parse_str<T: FromStr>(target: &str, key: &str) -> Option<T> {
pub fn open_file(path: PathBuf) -> Result<()> {
// use vscode first
if let Ok(code) = which::which("code") {
let mut command = Command::new(&code);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
if let Err(err) = Command::new(code)
.creation_flags(0x08000000)
.arg(path)
.spawn()
{
bail!(format!("failed to open file by VScode for `{err}`"));
if let Err(err) = command.creation_flags(0x08000000).arg(&path).spawn() {
log::error!(target: "app", "failed to open with VScode `{err}`");
open::that(path)?;
}
}
#[cfg(not(target_os = "windows"))]
if let Err(err) = Command::new(code).arg(path).spawn() {
bail!(format!("failed to open file by VScode for `{err}`"));
if let Err(err) = command.arg(&path).spawn() {
log::error!(target: "app", "failed to open with VScode `{err}`");
open::that(path)?;
}
return Ok(());
}
match open::that(path) {
Ok(_) => Ok(()),
Err(err) => bail!(format!("failed to open file for `{err}`")),
}
open::that(path)?;
Ok(())
}
#[macro_export]
macro_rules! log_if_err {
($result: expr) => {
if let Err(err) = $result {
log::error!("{err}");
log::error!(target: "app", "{err}");
}
};
}
@@ -93,7 +86,7 @@ macro_rules! wrap_err {
match $stat {
Ok(a) => Ok(a),
Err(err) => {
log::error!("{}", err.to_string());
log::error!(target: "app", "{}", err.to_string());
Err(format!("{}", err.to_string()))
}
}
+35 -30
View File
@@ -1,17 +1,22 @@
use crate::utils::{dirs, tmpl};
use anyhow::Result;
use chrono::Local;
use log::LevelFilter;
use log4rs::append::console::ConsoleAppender;
use log4rs::append::file::FileAppender;
use log4rs::config::{Appender, Config, Root};
use log4rs::config::{Appender, Config, Logger, Root};
use log4rs::encode::pattern::PatternEncoder;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use tauri::PackageInfo;
/// initialize this instance's log file
fn init_log(log_dir: &PathBuf) {
fn init_log() -> Result<()> {
let log_dir = dirs::app_logs_dir();
if !log_dir.exists() {
let _ = fs::create_dir_all(&log_dir);
}
let local_time = Local::now().format("%Y-%m-%d-%H%M%S").to_string();
let log_file = format!("{}.log", local_time);
let log_file = log_dir.join(log_file);
@@ -22,24 +27,38 @@ fn init_log(log_dir: &PathBuf) {
.build();
let tofile = FileAppender::builder()
.encoder(Box::new(PatternEncoder::new(time_format)))
.build(log_file)
.unwrap();
.build(log_file)?;
let config = Config::builder()
.appender(Appender::builder().build("stdout", Box::new(stdout)))
.appender(Appender::builder().build("file", Box::new(tofile)))
.build(
Root::builder()
.appenders(["stdout", "file"])
.build(LevelFilter::Info),
.logger(
Logger::builder()
.appenders(["file", "stdout"])
.additive(false)
.build("app", LevelFilter::Info),
)
.unwrap();
.build(Root::builder().appender("stdout").build(LevelFilter::Info))?;
log4rs::init_config(config).unwrap();
log4rs::init_config(config)?;
Ok(())
}
/// Initialize all the files from resources
fn init_config(app_dir: &PathBuf) -> std::io::Result<()> {
pub fn init_config() -> Result<()> {
let _ = init_log();
let app_dir = dirs::app_home_dir();
let profiles_dir = dirs::app_profiles_dir();
if !app_dir.exists() {
let _ = fs::create_dir_all(&app_dir);
}
if !profiles_dir.exists() {
let _ = fs::create_dir_all(&profiles_dir);
}
// target path
let clash_path = app_dir.join("config.yaml");
let verge_path = app_dir.join("verge.yaml");
@@ -58,34 +77,20 @@ fn init_config(app_dir: &PathBuf) -> std::io::Result<()> {
}
/// initialize app
pub fn init_app(package_info: &PackageInfo) {
pub fn init_resources(package_info: &PackageInfo) {
// create app dir
let app_dir = dirs::app_home_dir();
let log_dir = dirs::app_logs_dir();
let profiles_dir = dirs::app_profiles_dir();
let res_dir = dirs::app_resources_dir(package_info);
if !app_dir.exists() {
fs::create_dir_all(&app_dir).unwrap();
}
if !log_dir.exists() {
fs::create_dir_all(&log_dir).unwrap();
}
if !profiles_dir.exists() {
fs::create_dir_all(&profiles_dir).unwrap();
}
init_log(&log_dir);
if let Err(err) = init_config(&app_dir) {
log::error!("{err}");
let _ = fs::create_dir_all(&app_dir);
}
// copy the resource file
let mmdb_path = app_dir.join("Country.mmdb");
let mmdb_tmpl = res_dir.join("Country.mmdb");
if !mmdb_path.exists() && mmdb_tmpl.exists() {
fs::copy(mmdb_tmpl, mmdb_path).unwrap();
let _ = fs::copy(mmdb_tmpl, mmdb_path);
}
// copy the wintun.dll
@@ -94,7 +99,7 @@ pub fn init_app(package_info: &PackageInfo) {
let wintun_path = app_dir.join("wintun.dll");
let wintun_tmpl = res_dir.join("wintun.dll");
if !wintun_path.exists() && wintun_tmpl.exists() {
fs::copy(wintun_tmpl, wintun_path).unwrap();
let _ = fs::copy(wintun_tmpl, wintun_path);
}
}
}
-1
View File
@@ -4,6 +4,5 @@ pub mod help;
pub mod init;
pub mod resolve;
pub mod server;
pub mod sysopt;
pub mod tmpl;
mod winhelp;
+77 -30
View File
@@ -1,63 +1,110 @@
use crate::{core::Core, utils::init, utils::server};
use crate::{
core::{tray, Core},
data::Data,
utils::init,
utils::server,
};
use tauri::{App, AppHandle, Manager};
/// handle something when start app
pub fn resolve_setup(app: &App) {
// setup a simple http server for singleton
server::embed_server(&app.handle());
let _ = app
.tray_handle()
.set_menu(tray::Tray::tray_menu(&app.app_handle()));
// init app config
init::init_app(app.package_info());
init::init_resources(app.package_info());
// init states
let core = app.state::<Core>();
let silent_start = {
let global = Data::global();
let verge = global.verge.lock();
let singleton = verge.app_singleton_port.clone();
core.set_win(app.get_window("main"));
// setup a simple http server for singleton
server::embed_server(&app.app_handle(), singleton);
verge.enable_silent_start.clone().unwrap_or(false)
};
// core should be initialized after init_app fix #122
let core = Core::global();
core.init(app.app_handle());
resolve_window(app);
if !silent_start {
create_window(&app.app_handle());
}
}
/// reset system proxy
pub fn resolve_reset(app_handle: &AppHandle) {
let core = app_handle.state::<Core>();
pub fn resolve_reset() {
let core = Core::global();
let mut sysopt = core.sysopt.lock();
sysopt.reset_sysproxy();
crate::log_if_err!(sysopt.reset_sysproxy());
drop(sysopt);
let mut service = core.service.lock();
crate::log_if_err!(service.stop());
}
/// customize the window theme
fn resolve_window(app: &App) {
let window = app.get_window("main").unwrap();
/// create main window
pub fn create_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
return;
}
let builder = tauri::window::WindowBuilder::new(
app_handle,
"main".to_string(),
tauri::WindowUrl::App("index.html".into()),
)
.title("Clash Verge")
.center()
.fullscreen(false)
.min_inner_size(600.0, 520.0);
#[cfg(target_os = "windows")]
{
use crate::utils::winhelp;
use std::time::Duration;
use tokio::time::sleep;
use window_shadows::set_shadow;
use window_vibrancy::apply_blur;
let _ = window.set_decorations(false);
let _ = set_shadow(&window, true);
match builder
.decorations(false)
.transparent(true)
.inner_size(800.0, 636.0)
.build()
{
Ok(_) => {
let app_handle = app_handle.clone();
// todo
// win11 disable this feature temporarily due to lag
if !winhelp::is_win11() {
let _ = apply_blur(&window, None);
tauri::async_runtime::spawn(async move {
sleep(Duration::from_secs(1)).await;
if let Some(window) = app_handle.get_window("main") {
let _ = window.show();
let _ = set_shadow(&window, true);
if !winhelp::is_win11() {
let _ = apply_blur(&window, None);
}
}
});
}
Err(err) => log::error!(target: "app", "{err}"),
}
}
#[cfg(target_os = "macos")]
{
use tauri::LogicalSize;
use tauri::Size::Logical;
crate::log_if_err!(builder.decorations(true).inner_size(800.0, 642.0).build());
let _ = window.set_decorations(true);
let _ = window.set_size(Logical(LogicalSize {
width: 800.0,
height: 620.0,
}));
}
#[cfg(target_os = "linux")]
crate::log_if_err!(builder
.decorations(false)
.transparent(true)
.inner_size(800.0, 636.0)
.build());
}
+14 -11
View File
@@ -1,19 +1,24 @@
extern crate warp;
use super::resolve;
use crate::data::Verge;
use port_scanner::local_port_available;
use tauri::{AppHandle, Manager};
use tauri::AppHandle;
use warp::Filter;
#[cfg(not(feature = "verge-dev"))]
const SERVER_PORT: u16 = 33333;
const SERVER_PORT: u16 = 33331;
#[cfg(feature = "verge-dev")]
const SERVER_PORT: u16 = 11233;
/// check whether there is already exists
pub fn check_singleton() -> Result<(), ()> {
if !local_port_available(SERVER_PORT) {
let verge = Verge::new();
let port = verge.app_singleton_port.unwrap_or(SERVER_PORT);
if !local_port_available(port) {
tauri::async_runtime::block_on(async {
let url = format!("http://127.0.0.1:{}/commands/visible", SERVER_PORT);
let url = format!("http://127.0.0.1:{}/commands/visible", port);
reqwest::get(url).await.unwrap();
Err(())
})
@@ -24,18 +29,16 @@ pub fn check_singleton() -> Result<(), ()> {
/// The embed server only be used to implement singleton process
/// maybe it can be used as pac server later
pub fn embed_server(app: &AppHandle) {
let window = app.get_window("main").unwrap();
pub fn embed_server(app_handle: &AppHandle, port: Option<u16>) {
let app_handle = app_handle.clone();
let port = port.unwrap_or(SERVER_PORT);
tauri::async_runtime::spawn(async move {
let commands = warp::path!("commands" / "visible").map(move || {
window.show().unwrap();
window.set_focus().unwrap();
resolve::create_window(&app_handle);
return format!("ok");
});
warp::serve(commands)
.bind(([127, 0, 0, 1], SERVER_PORT))
.await;
warp::serve(commands).bind(([127, 0, 0, 1], port)).await;
});
}
-366
View File
@@ -1,366 +0,0 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
#[cfg(target_os = "windows")]
static DEFAULT_BYPASS: &str = "localhost;127.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;192.168.*;<local>";
#[cfg(target_os = "linux")]
static DEFAULT_BYPASS: &str = "localhost,127.0.0.1/8,::1";
#[cfg(target_os = "macos")]
static DEFAULT_BYPASS: &str =
"192.168.0.0/16\n10.0.0.0/8\n172.16.0.0/12\n127.0.0.1\nlocalhost\n*.local\ntimestamp.apple.com\n";
#[cfg(target_os = "macos")]
static MACOS_SERVICE: &str = "Wi-Fi";
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct SysProxyConfig {
pub enable: bool,
pub server: String,
pub bypass: String,
}
impl Default for SysProxyConfig {
fn default() -> Self {
SysProxyConfig {
enable: false,
server: String::from(""),
bypass: String::from(""),
}
}
}
impl SysProxyConfig {
pub fn new(enable: bool, port: String, bypass: Option<String>) -> Self {
SysProxyConfig {
enable,
server: format!("127.0.0.1:{}", port),
bypass: bypass.unwrap_or(DEFAULT_BYPASS.into()),
}
}
}
#[cfg(target_os = "windows")]
impl SysProxyConfig {
/// Get the windows system proxy config
pub fn get_sys() -> Result<Self> {
use winreg::enums::*;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let cur_var = hkcu.open_subkey_with_flags(
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
KEY_READ,
)?;
Ok(SysProxyConfig {
enable: cur_var.get_value::<u32, _>("ProxyEnable")? == 1u32,
server: cur_var.get_value("ProxyServer")?,
bypass: cur_var.get_value("ProxyOverride")?,
})
}
/// Set the windows system proxy config
pub fn set_sys(&self) -> Result<()> {
use winreg::enums::*;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let cur_var = hkcu.open_subkey_with_flags(
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
KEY_SET_VALUE,
)?;
let enable: u32 = if self.enable { 1u32 } else { 0u32 };
cur_var.set_value("ProxyEnable", &enable)?;
cur_var.set_value("ProxyServer", &self.server)?;
cur_var.set_value("ProxyOverride", &self.bypass)?;
Ok(())
}
}
#[cfg(target_os = "macos")]
impl SysProxyConfig {
/// Get the macos system proxy config
pub fn get_sys() -> Result<Self> {
use std::process::Command;
let http = macproxy::get_proxy(&["-getwebproxy", MACOS_SERVICE])?;
let https = macproxy::get_proxy(&["-getsecurewebproxy", MACOS_SERVICE])?;
let sock = macproxy::get_proxy(&["-getsocksfirewallproxy", MACOS_SERVICE])?;
let mut enable = false;
let mut server = "".into();
if sock.0 == "Yes" {
enable = true;
server = sock.1;
}
if https.0 == "Yes" {
enable = true;
server = https.1;
}
if http.0 == "Yes" || !enable {
enable = http.0 == "Yes";
server = http.1;
}
let bypass_output = Command::new("networksetup")
.args(["-getproxybypassdomains", MACOS_SERVICE])
.output()?;
// change the format to xxx,xxx
let bypass = std::str::from_utf8(&bypass_output.stdout)
.unwrap_or(DEFAULT_BYPASS)
.to_string()
.split('\n')
.collect::<Vec<_>>()
.join(",");
Ok(SysProxyConfig {
enable,
server,
bypass,
})
}
/// Set the macos system proxy config
pub fn set_sys(&self) -> Result<()> {
use std::process::Command;
let enable = self.enable;
let server = self.server.as_str();
let bypass = self.bypass.clone();
macproxy::set_proxy("-setwebproxy", MACOS_SERVICE, enable, server)?;
macproxy::set_proxy("-setsecurewebproxy", MACOS_SERVICE, enable, server)?;
macproxy::set_proxy("-setsocksfirewallproxy", MACOS_SERVICE, enable, server)?;
let domains = bypass.split(",").collect::<Vec<_>>();
Command::new("networksetup")
.args([["-setproxybypassdomains", MACOS_SERVICE].to_vec(), domains].concat())
.status()?;
Ok(())
}
}
#[cfg(target_os = "macos")]
mod macproxy {
use super::*;
use anyhow::bail;
use std::process::Command;
/// use networksetup
/// get the target proxy config
pub(super) fn get_proxy(args: &[&str; 2]) -> Result<(String, String)> {
let output = Command::new("networksetup").args(args).output()?;
let stdout = std::str::from_utf8(&output.stdout)?;
let enable = parse(stdout, "Enabled:");
let server = parse(stdout, "Server:");
let port = parse(stdout, "Port:");
let server = format!("{server}:{port}");
Ok((enable.into(), server))
}
/// use networksetup
/// set the target proxy config
pub(super) fn set_proxy(
target: &str, // like: -setwebproxy
device: &str,
enable: bool,
server: &str,
) -> Result<()> {
let mut split = server.split(":");
let host = split.next();
let port = split.next();
// can not parse the field
if host.is_none() || port.is_none() {
bail!("failed to parse the server into host:port");
}
let args = vec![target, device, host.unwrap(), port.unwrap()];
Command::new("networksetup").args(&args).status()?;
let target_state = String::from(target) + "state";
let enable = if enable { "on" } else { "off" };
let args = vec![target_state.as_str(), device, enable];
Command::new("networksetup").args(&args).status()?;
Ok(())
}
/// parse the networksetup output
fn parse<'a>(target: &'a str, key: &'a str) -> &'a str {
match target.find(key) {
Some(idx) => {
let idx = idx + key.len();
let value = &target[idx..];
let value = match value.find("\n") {
Some(end) => &value[..end],
None => value,
};
value.trim()
}
None => "",
}
}
#[test]
fn test_get() {
use std::process::Command;
let output = Command::new("networksetup")
.args(["-getwebproxy", "Wi-Fi"])
.output();
let output = output.unwrap();
let stdout = std::str::from_utf8(&output.stdout).unwrap();
let enable = parse(stdout, "Enabled:");
let server = parse(stdout, "Server:");
let port = parse(stdout, "Port:");
println!("enable: {}, server: {}, port: {}", enable, server, port);
dbg!(SysProxyConfig::get_sys().unwrap());
}
#[test]
fn test_set() {
let sysproxy = SysProxyConfig::new(true, "7890".into(), None);
dbg!(sysproxy.set_sys().unwrap());
}
}
///
/// Linux Desktop System Proxy Supports
/// by using `gsettings`
#[cfg(target_os = "linux")]
impl SysProxyConfig {
/// Get the system proxy config [http/https/socks]
pub fn get_sys() -> Result<Self> {
use std::process::Command;
let schema = "org.gnome.system.proxy";
// get enable
let mode = Command::new("gsettings")
.args(["get", schema, "mode"])
.output()?;
let mode = std::str::from_utf8(&mode.stdout)?;
let enable = mode == "manual";
// get bypass
// Todo: parse the ignore-hosts
// ['aaa', 'bbb'] -> aaa,bbb
let ignore = Command::new("gsettings")
.args(["get", schema, "ignore-hosts"])
.output()?;
let ignore = std::str::from_utf8(&ignore.stdout)?;
let bypass = ignore.to_string();
let http = Self::get_proxy("http")?;
let https = Self::get_proxy("https")?;
let socks = Self::get_proxy("socks")?;
let mut server = "".into();
if socks.len() > 0 {
server = socks;
}
if https.len() > 0 {
server = https;
}
if http.len() > 0 {
server = http;
}
Ok(SysProxyConfig {
enable,
server,
bypass,
})
}
/// Get the system proxy config [http/https/socks]
pub fn set_sys(&self) -> Result<()> {
use anyhow::bail;
use std::process::Command;
let enable = self.enable;
let server = self.server.as_str();
let bypass = self.bypass.clone();
let schema = "org.gnome.system.proxy";
if enable {
let mut split = server.split(":");
let host = split.next();
let port = split.next();
if port.is_none() {
bail!("failed to parse the port");
}
let host = format!("'{}'", host.unwrap_or("127.0.0.1"));
let host = host.as_str();
let port = port.unwrap();
let http = format!("{schema}.http");
Command::new("gsettings")
.args(["set", http.as_str(), "host", host])
.status()?;
Command::new("gsettings")
.args(["set", http.as_str(), "port", port])
.status()?;
let https = format!("{schema}.https");
Command::new("gsettings")
.args(["set", https.as_str(), "host", host])
.status()?;
Command::new("gsettings")
.args(["set", https.as_str(), "port", port])
.status()?;
let socks = format!("{schema}.socks");
Command::new("gsettings")
.args(["set", socks.as_str(), "host", host])
.status()?;
Command::new("gsettings")
.args(["set", socks.as_str(), "port", port])
.status()?;
// set bypass
// Todo: parse the ignore-hosts
// aaa,bbb,cccc -> ['aaa', 'bbb', 'ccc']
Command::new("gsettings")
.args(["set", schema, "ignore-hosts", bypass.as_str()]) // todo
.status()?;
}
let mode = if enable { "'manual'" } else { "'none'" };
Command::new("gsettings")
.args(["set", schema, "mode", mode])
.status()?;
Ok(())
}
/// help function
fn get_proxy(typ: &str) -> Result<String> {
use std::process::Command;
let schema = format!("org.gnome.system.proxy.{typ}");
let schema = schema.as_str();
let host = Command::new("gsettings")
.args(["get", schema, "host"])
.output()?;
let host = std::str::from_utf8(&host.stdout)?;
let port = Command::new("gsettings")
.args(["get", schema, "port"])
.output()?;
let port = std::str::from_utf8(&port.stdout)?;
Ok(format!("{host}:{port}"))
}
}
+1 -2
View File
@@ -29,7 +29,6 @@ enable_self_startup: false
enable_system_proxy: false
enable_proxy_guard: false
proxy_guard_duration: 10
system_proxy_bypass: localhost;127.*;10.*;192.168.*;<local>
";
/// template for new a profile item
@@ -60,7 +59,7 @@ append-proxy-groups:
";
/// enhanced profile
pub const ITEM_SCRIPT: &str = "// Should define the `main` function
pub const ITEM_SCRIPT: &str = "// Define the `main` function
// The argument to this function is the clash config
// or the result of the previous handler
// so you should return the config after processing
+22 -20
View File
@@ -1,7 +1,7 @@
{
"package": {
"productName": "Clash Verge",
"version": "1.0.1"
"version": "1.1.2"
},
"build": {
"distDir": "../dist",
@@ -11,7 +11,7 @@
},
"tauri": {
"systemTray": {
"iconPath": "icons/icon.png",
"iconPath": "icons/tray-icon.ico",
"iconAsTemplate": true
},
"bundle": {
@@ -22,11 +22,16 @@
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon-new.icns",
"icons/icon.ico"
],
"resources": ["resources"],
"externalBin": ["sidecar/clash"],
"resources": [
"resources"
],
"externalBin": [
"sidecar/clash",
"sidecar/clash-meta"
],
"copyright": "© 2022 zzzgydi All Rights Reserved",
"category": "DeveloperTool",
"shortDescription": "A Clash GUI based on tauri.",
@@ -44,7 +49,13 @@
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
"timestampUrl": "",
"wix": {
"language": [
"zh-CN",
"en-US"
]
}
}
},
"updater": {
@@ -65,23 +76,14 @@
},
"process": {
"all": true
},
"globalShortcut": {
"all": true
}
},
"windows": [
{
"title": "Clash Verge",
"width": 800,
"height": 636,
"resizable": true,
"fullscreen": false,
"decorations": false,
"transparent": true,
"minWidth": 600,
"minHeight": 520
}
],
"windows": [],
"security": {
"csp": "script-src 'unsafe-eval' 'self'; default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self'; img-src data: 'self';"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+6
View File
@@ -31,3 +31,9 @@ body {
@import "./layout.scss";
@import "./page.scss";
@media (prefers-color-scheme: dark) {
:root {
background-color: rgba(18, 18, 18, 1);
}
}
+16 -7
View File
@@ -16,12 +16,14 @@
user-select: none;
overflow: hidden;
$maxLogo: 100px;
.the-logo {
position: relative;
flex: 0 1 168px;
flex: 0 1 $maxLogo;
width: 100%;
max-width: 168px;
max-height: 168px;
max-width: $maxLogo + 32px;
max-height: $maxLogo;
margin: 0 auto;
padding: 0 16px;
text-align: center;
@@ -29,15 +31,15 @@
img,
svg {
width: 96%;
height: 96%;
width: 100%;
height: 100%;
pointer-events: none;
}
.the-newbtn {
position: absolute;
right: 20px;
bottom: 12px;
right: 10px;
bottom: 0px;
transform: scale(0.8);
}
}
@@ -87,6 +89,13 @@
.windows,
.unknown {
&.layout {
$maxLogo: 115px;
.layout__left .the-logo {
flex: 0 1 $maxLogo;
max-width: $maxLogo + 32px;
max-height: $maxLogo;
}
.layout__right .the-content {
top: 30px;
}
+2 -2
View File
@@ -7,7 +7,7 @@
> header {
flex: 0 0 58px;
width: 90%;
max-width: 850px;
// max-width: 850px;
margin: 0 auto;
padding-right: 4px;
box-sizing: border-box;
@@ -28,7 +28,7 @@
.base-content {
width: 90%;
max-width: 850px;
// max-width: 850px;
margin: 0 auto;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { alpha, Box, Typography } from "@mui/material";
import { BlurOnRounded } from "@mui/icons-material";
interface Props {
text?: React.ReactNode;
extra?: React.ReactNode;
}
const BaseEmpty = (props: Props) => {
const { text = "Empty", extra } = props;
return (
<Box
sx={({ palette }) => ({
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
color: alpha(palette.text.secondary, 0.75),
})}
>
<BlurOnRounded sx={{ fontSize: "4em" }} />
<Typography sx={{ fontSize: "1.25em" }}>{text}</Typography>
{extra}
</Box>
);
};
export default BaseEmpty;
+50
View File
@@ -0,0 +1,50 @@
import { styled } from "@mui/material";
const Loading = styled("div")`
position: relative;
display: flex;
height: 100%;
min-height: 18px;
box-sizing: border-box;
align-items: center;
& > div {
box-sizing: border-box;
width: 6px;
height: 6px;
margin: 2px;
border-radius: 100%;
animation: loading 0.7s -0.15s infinite linear;
}
& > div:nth-child(2n-1) {
animation-delay: -0.5s;
}
@keyframes loading {
50% {
opacity: 0.2;
transform: scale(0.75);
}
100% {
opacity: 1;
transform: scale(1);
}
}
`;
const LoadingItem = styled("div")(({ theme }) => ({
background: theme.palette.text.secondary,
}));
const BaseLoading = () => {
return (
<Loading>
<LoadingItem />
<LoadingItem />
<LoadingItem />
</Loading>
);
};
export default BaseLoading;
+8 -5
View File
@@ -11,7 +11,7 @@ interface InnerProps {
}
const NoticeInner = (props: InnerProps) => {
const { type, message, duration = 2000, onClose } = props;
const { type, message, duration = 1500, onClose } = props;
const [visible, setVisible] = useState(true);
const onBtnClose = () => {
@@ -26,12 +26,13 @@ const NoticeInner = (props: InnerProps) => {
type === "info" ? (
message
) : (
<Box sx={{ display: "flex", alignItems: "center" }}>
<Box sx={{ width: 328, display: "flex", alignItems: "center" }}>
{type === "error" && <ErrorRounded color="error" />}
{type === "success" && <CheckCircleRounded color="success" />}
<Typography
sx={{ ml: 1, wordWrap: "break-word", wordBreak: "break-all" }}
component="span"
sx={{ ml: 1, wordWrap: "break-word", width: "calc(100% - 35px)" }}
>
{message}
</Typography>
@@ -79,14 +80,16 @@ const Notice: NoticeInstance = (props) => {
const onUnmount = () => {
const result = ReactDOM.unmountComponentAtNode(container);
if (result && parent) parent.removeChild(container);
if (result && parent) setTimeout(() => parent.removeChild(container), 500);
};
ReactDOM.render(<NoticeInner {...props} onClose={onUnmount} />, container);
};
(["info", "error", "success"] as const).forEach((type) => {
Notice[type] = (message, duration) => Notice({ type, message, duration });
Notice[type] = (message, duration) => {
setTimeout(() => Notice({ type, message, duration }), 0);
};
});
export default Notice;
+20 -12
View File
@@ -1,20 +1,25 @@
import dayjs from "dayjs";
import { useLockFn } from "ahooks";
import { styled, ListItem, IconButton, ListItemText } from "@mui/material";
import {
styled,
ListItem,
IconButton,
ListItemText,
Box,
alpha,
} from "@mui/material";
import { CloseRounded } from "@mui/icons-material";
import { ApiType } from "../../services/types";
import { deleteConnection } from "../../services/api";
import parseTraffic from "../../utils/parse-traffic";
import { deleteConnection } from "@/services/api";
import parseTraffic from "@/utils/parse-traffic";
const Tag = styled("span")(({ theme }) => ({
display: "inline-block",
fontSize: "12px",
fontSize: "10px",
padding: "0 4px",
lineHeight: 1.375,
border: "1px solid #ccc",
border: "1px solid",
borderRadius: 4,
marginRight: "0.1em",
transform: "scale(0.92)",
borderColor: alpha(theme.palette.text.secondary, 0.35),
marginRight: "4px",
}));
interface Props {
@@ -27,7 +32,7 @@ const ConnectionItem = (props: Props) => {
const { id, metadata, chains, start, curUpload, curDownload } = value;
const onDelete = useLockFn(async () => deleteConnection(id));
const showTraffic = curUpload! > 1024 || curDownload! > 1024;
const showTraffic = curUpload! >= 100 || curDownload! >= 100;
return (
<ListItem
@@ -39,15 +44,18 @@ const ConnectionItem = (props: Props) => {
}
>
<ListItemText
sx={{ userSelect: "text" }}
primary={metadata.host || metadata.destinationIP}
secondary={
<>
<Box sx={{ display: "flex", flexWrap: "wrap" }}>
<Tag sx={{ textTransform: "uppercase", color: "success" }}>
{metadata.network}
</Tag>
<Tag>{metadata.type}</Tag>
{!!metadata.process && <Tag>{metadata.process}</Tag>}
{chains.length > 0 && <Tag>{chains[value.chains.length - 1]}</Tag>}
<Tag>{dayjs(start).fromNow()}</Tag>
@@ -57,7 +65,7 @@ const ConnectionItem = (props: Props) => {
{parseTraffic(curUpload!)} / {parseTraffic(curDownload!)}
</Tag>
)}
</>
</Box>
}
/>
</ListItem>
@@ -0,0 +1,144 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import parseTraffic from "@/utils/parse-traffic";
interface Props {
connections: ApiType.ConnectionsItem[];
}
const ConnectionTable = (props: Props) => {
const { connections } = props;
const columns: GridColDef[] = [
{
field: "host",
headerName: "Host",
flex: 200,
minWidth: 200,
resizable: false,
disableColumnMenu: true,
},
{
field: "download",
headerName: "Download",
width: 88,
align: "right",
headerAlign: "right",
disableColumnMenu: true,
valueFormatter: (params: any) => parseTraffic(params.value).join(" "),
},
{
field: "upload",
headerName: "Upload",
width: 88,
align: "right",
headerAlign: "right",
disableColumnMenu: true,
valueFormatter: (params: any) => parseTraffic(params.value).join(" "),
},
{
field: "dlSpeed",
headerName: "DL Speed",
align: "right",
width: 88,
headerAlign: "right",
disableColumnMenu: true,
valueFormatter: (params: any) =>
parseTraffic(params.value).join(" ") + "/s",
},
{
field: "ulSpeed",
headerName: "UL Speed",
width: 88,
align: "right",
headerAlign: "right",
disableColumnMenu: true,
valueFormatter: (params: any) =>
parseTraffic(params.value).join(" ") + "/s",
},
{
field: "chains",
headerName: "Chains",
width: 360,
disableColumnMenu: true,
},
{
field: "rule",
headerName: "Rule",
width: 225,
disableColumnMenu: true,
},
{
field: "process",
headerName: "Process",
width: 120,
disableColumnMenu: true,
},
{
field: "time",
headerName: "Time",
width: 120,
align: "right",
headerAlign: "right",
disableColumnMenu: true,
valueFormatter: (params) => dayjs(params.value).fromNow(),
},
{
field: "source",
headerName: "Source",
width: 150,
disableColumnMenu: true,
},
{
field: "destinationIP",
headerName: "Destination IP",
width: 125,
disableColumnMenu: true,
},
{
field: "type",
headerName: "Type",
width: 160,
disableColumnMenu: true,
},
];
const connRows = useMemo(() => {
return connections.map((each) => {
const { metadata, rulePayload } = each;
const chains = [...each.chains].reverse().join(" / ");
const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
return {
id: each.id,
host: metadata.host
? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.destinationIP}:${metadata.destinationPort}`,
download: each.download,
upload: each.upload,
dlSpeed: each.curDownload,
ulSpeed: each.curUpload,
chains,
rule,
process: metadata.process || metadata.processPath,
time: each.start,
source: `${metadata.sourceIP}:${metadata.sourcePort}`,
destinationIP: metadata.destinationIP,
type: `${metadata.type}(${metadata.network})`,
};
});
}, [connections]);
return (
<DataGrid
rows={connRows}
columns={columns}
density="compact"
sx={{ border: "none", "div:focus": { outline: "none !important" } }}
hideFooter
/>
);
};
export default ConnectionTable;
+1 -1
View File
@@ -30,7 +30,7 @@ const LayoutControl = () => {
<Button
size="small"
sx={{ minWidth, svg: { transform: "scale(1.05)" } }}
onClick={() => appWindow.hide()}
onClick={() => appWindow.close()}
>
<CloseRounded fontSize="small" />
</Button>
+4 -5
View File
@@ -4,13 +4,12 @@ import { useRecoilValue } from "recoil";
import { Box, Typography } from "@mui/material";
import { ArrowDownward, ArrowUpward } from "@mui/icons-material";
import { listen } from "@tauri-apps/api/event";
import { ApiType } from "../../services/types";
import { getInformation } from "../../services/api";
import { getVergeConfig } from "../../services/cmds";
import { atomClashPort } from "../../services/states";
import { getInformation } from "@/services/api";
import { getVergeConfig } from "@/services/cmds";
import { atomClashPort } from "@/services/states";
import TrafficGraph from "./traffic-graph";
import useLogSetup from "./use-log-setup";
import parseTraffic from "../../utils/parse-traffic";
import parseTraffic from "@/utils/parse-traffic";
// setup the traffic
const LayoutTraffic = () => {
+2 -25
View File
@@ -1,7 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { useTheme } from "@mui/material";
import { listen } from "@tauri-apps/api/event";
import { appWindow } from "@tauri-apps/api/window";
const maxPoint = 30;
@@ -72,28 +70,7 @@ const TrafficGraph = (props: Props) => {
};
}, []);
// reduce the GPU usage when hidden
const [enablePaint, setEnablePaint] = useState(true);
useEffect(() => {
appWindow.isVisible().then(setEnablePaint);
const unlistenBlur = listen("tauri://blur", async () => {
setEnablePaint(await appWindow.isVisible());
});
const unlistenFocus = listen("tauri://focus", async () => {
setEnablePaint(await appWindow.isVisible());
});
return () => {
unlistenBlur.then((fn) => fn());
unlistenFocus.then((fn) => fn());
};
}, []);
useEffect(() => {
if (!enablePaint) return;
let raf = 0;
const canvas = canvasRef.current!;
@@ -216,7 +193,7 @@ const TrafficGraph = (props: Props) => {
return () => {
cancelAnimationFrame(raf);
};
}, [enablePaint, palette]);
}, [palette]);
return <canvas ref={canvasRef} style={{ width: "100%", height: "100%" }} />;
};
+7 -6
View File
@@ -2,6 +2,7 @@ import useSWR from "swr";
import snarkdown from "snarkdown";
import { useMemo } from "react";
import { useRecoilState } from "recoil";
import { useTranslation } from "react-i18next";
import {
Box,
Button,
@@ -13,8 +14,7 @@ import {
} from "@mui/material";
import { relaunch } from "@tauri-apps/api/process";
import { checkUpdate, installUpdate } from "@tauri-apps/api/updater";
import { killSidecar, restartSidecar } from "../../services/cmds";
import { atomUpdateState } from "../../services/states";
import { atomUpdateState } from "@/services/states";
import Notice from "../base/base-notice";
interface Props {
@@ -28,6 +28,7 @@ const UpdateLog = styled(Box)(() => ({
const UpdateDialog = (props: Props) => {
const { open, onClose } = props;
const { t } = useTranslation();
const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, {
errorRetryCount: 2,
revalidateIfStale: false,
@@ -41,11 +42,9 @@ const UpdateDialog = (props: Props) => {
setUpdateState(true);
try {
await killSidecar();
await installUpdate();
await relaunch();
} catch (err: any) {
await restartSidecar();
Notice.error(err?.message || err.toString());
} finally {
setUpdateState(false);
@@ -69,14 +68,16 @@ const UpdateDialog = (props: Props) => {
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="outlined" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
autoFocus
variant="contained"
disabled={updateState}
onClick={onUpdate}
>
Update
{t("Update")}
</Button>
</DialogActions>
</Dialog>
+26 -7
View File
@@ -1,8 +1,11 @@
import useSWR from "swr";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { useRecoilState } from "recoil";
import { createTheme } from "@mui/material";
import { getVergeConfig } from "../../services/cmds";
import { defaultTheme, defaultDarkTheme } from "../../pages/_theme";
import { appWindow } from "@tauri-apps/api/window";
import { getVergeConfig } from "@/services/cmds";
import { atomThemeMode } from "@/services/states";
import { defaultTheme, defaultDarkTheme } from "@/pages/_theme";
/**
* custome theme
@@ -10,10 +13,27 @@ import { defaultTheme, defaultDarkTheme } from "../../pages/_theme";
export default function useCustomTheme() {
const { data } = useSWR("getVergeConfig", getVergeConfig);
const { theme_mode, theme_setting } = data ?? {};
const [mode, setMode] = useRecoilState(atomThemeMode);
useEffect(() => {
const themeMode = ["light", "dark", "system"].includes(theme_mode!)
? theme_mode!
: "light";
if (themeMode !== "system") {
setMode(themeMode);
return;
}
appWindow.theme().then((m) => m && setMode(m));
const unlisten = appWindow.onThemeChanged((e) => setMode(e.payload));
return () => {
unlisten.then((fn) => fn());
};
}, [theme_mode]);
const theme = useMemo(() => {
const mode = theme_mode ?? "light";
const setting = theme_setting || {};
const dt = mode === "light" ? defaultTheme : defaultDarkTheme;
@@ -47,7 +67,6 @@ export default function useCustomTheme() {
const scrollColor = mode === "light" ? "#90939980" : "#54545480";
const rootEle = document.documentElement;
rootEle.style.background = "transparent";
rootEle.style.setProperty("--selection-color", selectColor);
rootEle.style.setProperty("--scroller-color", scrollColor);
rootEle.style.setProperty("--primary-main", theme.palette.primary.main);
@@ -78,7 +97,7 @@ export default function useCustomTheme() {
}, 0);
return theme;
}, [theme_mode, theme_setting]);
}, [mode, theme_setting]);
return { theme };
}
+15 -10
View File
@@ -1,20 +1,24 @@
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { useSetRecoilState } from "recoil";
import { useRecoilValue, useSetRecoilState } from "recoil";
import { listen } from "@tauri-apps/api/event";
import { ApiType } from "../../services/types";
import { getInformation } from "../../services/api";
import { atomLogData } from "../../services/states";
import { getInformation } from "@/services/api";
import { getClashLogs } from "@/services/cmds";
import { atomEnableLog, atomLogData } from "@/services/states";
const MAX_LOG_NUM = 1000;
// setup the log websocket
export default function useLogSetup() {
const [refresh, setRefresh] = useState({});
const enableLog = useRecoilValue(atomEnableLog);
const setLogData = useSetRecoilState(atomLogData);
useEffect(() => {
let ws: WebSocket = null!;
if (!enableLog) return;
getClashLogs().then(setLogData);
const handler = (event: MessageEvent<any>) => {
const data = JSON.parse(event.data) as ApiType.LogItem;
@@ -25,10 +29,11 @@ export default function useLogSetup() {
});
};
getInformation().then((info) => {
const ws = getInformation().then((info) => {
const { server = "", secret = "" } = info;
ws = new WebSocket(`ws://${server}/logs?token=${secret}`);
const ws = new WebSocket(`ws://${server}/logs?token=${secret}`);
ws.addEventListener("message", handler);
return ws;
});
const unlisten = listen("verge://refresh-clash-config", () =>
@@ -36,8 +41,8 @@ export default function useLogSetup() {
);
return () => {
ws?.close();
unlisten?.then((fn) => fn());
ws.then((ws) => ws?.close());
unlisten.then((fn) => fn());
};
}, [refresh]);
}, [refresh, enableLog]);
}
-1
View File
@@ -1,5 +1,4 @@
import { styled, Box } from "@mui/material";
import { ApiType } from "../../services/types";
const Item = styled(Box)(({ theme }) => ({
padding: "8px 0",
+26 -118
View File
@@ -1,32 +1,15 @@
import useSWR from "swr";
import { useState } from "react";
import { useLockFn } from "ahooks";
import {
Box,
Divider,
Grid,
IconButton,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Stack,
} from "@mui/material";
import {
AddchartRounded,
CheckRounded,
MenuRounded,
RestartAltRounded,
} from "@mui/icons-material";
import { useTranslation } from "react-i18next";
import { Box, Grid, IconButton, Stack } from "@mui/material";
import { RestartAltRounded } from "@mui/icons-material";
import {
getProfiles,
deleteProfile,
enhanceProfiles,
changeProfileChain,
changeProfileValid,
} from "../../services/cmds";
import { CmdType } from "../../services/types";
import getSystem from "../../utils/get-system";
getRuntimeLogs,
} from "@/services/cmds";
import ProfileMore from "./profile-more";
import Notice from "../base/base-notice";
@@ -35,21 +18,22 @@ interface Props {
chain: string[];
}
const OS = getSystem();
const EnhancedMode = (props: Props) => {
const { items, chain } = props;
const { data, mutate } = useSWR("getProfiles", getProfiles);
const valid = data?.valid || [];
const [anchorEl, setAnchorEl] = useState<any>(null);
const { t } = useTranslation();
const { mutate: mutateProfiles } = useSWR("getProfiles", getProfiles);
const { data: chainLogs = {}, mutate: mutateLogs } = useSWR(
"getRuntimeLogs",
getRuntimeLogs
);
// handler
const onEnhance = useLockFn(async () => {
try {
await enhanceProfiles();
Notice.success("Refresh clash config", 2000);
mutateLogs();
// Notice.success("Refresh clash config", 1000);
} catch (err: any) {
Notice.error(err.message || err.toString());
}
@@ -60,7 +44,8 @@ const EnhancedMode = (props: Props) => {
const newChain = [...chain, uid];
await changeProfileChain(newChain);
mutate((conf = {}) => ({ ...conf, chain: newChain }), true);
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
mutateLogs();
});
const onEnhanceDisable = useLockFn(async (uid: string) => {
@@ -68,14 +53,16 @@ const EnhancedMode = (props: Props) => {
const newChain = chain.filter((i) => i !== uid);
await changeProfileChain(newChain);
mutate((conf = {}) => ({ ...conf, chain: newChain }), true);
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
mutateLogs();
});
const onEnhanceDelete = useLockFn(async (uid: string) => {
try {
await onEnhanceDisable(uid);
await deleteProfile(uid);
mutate();
mutateProfiles();
mutateLogs();
} catch (err: any) {
Notice.error(err?.message || err.toString());
}
@@ -86,7 +73,8 @@ const EnhancedMode = (props: Props) => {
const newChain = [uid].concat(chain.filter((i) => i !== uid));
await changeProfileChain(newChain);
mutate((conf = {}) => ({ ...conf, chain: newChain }), true);
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
mutateLogs();
});
const onMoveEnd = useLockFn(async (uid: string) => {
@@ -94,24 +82,12 @@ const EnhancedMode = (props: Props) => {
const newChain = chain.filter((i) => i !== uid).concat([uid]);
await changeProfileChain(newChain);
mutate((conf = {}) => ({ ...conf, chain: newChain }), true);
});
// update valid list
const onToggleValid = useLockFn(async (key: string) => {
try {
const newValid = valid.includes(key)
? valid.filter((i) => i !== key)
: valid.concat(key);
await changeProfileValid(newValid);
mutate();
} catch (err: any) {
Notice.error(err.message || err.toString());
}
mutateProfiles((conf = {}) => ({ ...conf, chain: newChain }), true);
mutateLogs();
});
return (
<Box sx={{ mt: 4 }}>
<Box sx={{ mt: 2 }}>
<Stack
spacing={1}
direction="row"
@@ -122,80 +98,11 @@ const EnhancedMode = (props: Props) => {
<IconButton
size="small"
color="inherit"
title="refresh enhanced profiles"
title={t("Refresh profiles")}
onClick={onEnhance}
>
<RestartAltRounded />
</IconButton>
<IconButton
size="small"
color="inherit"
id="profile-use-button"
title="enable clash fields"
aria-controls={!!anchorEl ? "profile-use-menu" : undefined}
aria-haspopup="true"
aria-expanded={!!anchorEl ? "true" : undefined}
onClick={(e) => setAnchorEl(e.currentTarget)}
>
<MenuRounded />
</IconButton>
<Menu
id="profile-use-menu"
open={!!anchorEl}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
transitionDuration={225}
TransitionProps={
OS === "macos" ? { style: { transitionDuration: "225ms" } } : {}
}
MenuListProps={{
dense: true,
"aria-labelledby": "profile-use-button",
}}
onContextMenu={(e) => {
setAnchorEl(null);
e.preventDefault();
}}
>
<MenuItem>
<ListItemIcon color="inherit">
<AddchartRounded />
</ListItemIcon>
Use Clash Fields
</MenuItem>
<Divider />
{[
"tun",
"dns",
"hosts",
"script",
"profile",
"payload",
"interface-name",
"routing-mark",
].map((key) => {
const has = valid.includes(key);
return (
<MenuItem
key={key}
sx={{ width: 180 }}
onClick={() => onToggleValid(key)}
>
{has && (
<ListItemIcon color="inherit">
<CheckRounded />
</ListItemIcon>
)}
<ListItemText inset={!has}>{key}</ListItemText>
</MenuItem>
);
})}
</Menu>
</Stack>
<Grid container spacing={2}>
@@ -205,6 +112,7 @@ const EnhancedMode = (props: Props) => {
selected={!!chain.includes(item.uid)}
itemData={item}
enableNum={chain.length}
logInfo={chainLogs[item.uid]}
onEnable={() => onEnhanceEnable(item.uid)}
onDisable={() => onEnhanceDisable(item.uid)}
onDelete={() => onEnhanceDelete(item.uid)}
+8 -10
View File
@@ -1,6 +1,6 @@
import useSWR from "swr";
import { useEffect, useRef } from "react";
import { useLockFn } from "ahooks";
import { useRecoilValue } from "recoil";
import { useTranslation } from "react-i18next";
import {
Button,
@@ -9,11 +9,8 @@ import {
DialogContent,
DialogTitle,
} from "@mui/material";
import {
getVergeConfig,
readProfileFile,
saveProfileFile,
} from "../../services/cmds";
import { atomThemeMode } from "@/services/states";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import Notice from "../base/base-notice";
import "monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js";
@@ -35,8 +32,7 @@ const FileEditor = (props: Props) => {
const { t } = useTranslation();
const editorRef = useRef<any>();
const instanceRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const { data: vergeConfig } = useSWR("getVergeConfig", getVergeConfig);
const { theme_mode } = vergeConfig ?? {};
const themeMode = useRecoilValue(atomThemeMode);
useEffect(() => {
if (!open) return;
@@ -50,7 +46,7 @@ const FileEditor = (props: Props) => {
instanceRef.current = editor.create(editorRef.current, {
value: data,
language: mode,
theme: theme_mode === "light" ? "vs" : "vs-dark",
theme: themeMode === "light" ? "vs" : "vs-dark",
minimap: { enabled: false },
});
});
@@ -86,7 +82,9 @@ const FileEditor = (props: Props) => {
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t("Cancel")}</Button>
<Button onClick={onClose} variant="outlined">
{t("Cancel")}
</Button>
<Button onClick={onSave} variant="contained">
{t("Save")}
</Button>
+3 -1
View File
@@ -1,5 +1,6 @@
import { useRef, useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { Box, Button, Typography } from "@mui/material";
interface Props {
@@ -9,6 +10,7 @@ interface Props {
const FileInput = (props: Props) => {
const { onChange } = props;
const { t } = useTranslation();
// file input
const inputRef = useRef<any>();
const [loading, setLoading] = useState(false);
@@ -40,7 +42,7 @@ const FileInput = (props: Props) => {
sx={{ flex: "none" }}
onClick={() => inputRef.current?.click()}
>
Choose File
{t("Choose File")}
</Button>
<input
@@ -4,16 +4,19 @@ import { useLockFn, useSetState } from "ahooks";
import { useTranslation } from "react-i18next";
import {
Button,
Collapse,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
Switch,
TextField,
} from "@mui/material";
import { Settings } from "@mui/icons-material";
import { CmdType } from "../../services/types";
import { patchProfile } from "../../services/cmds";
import { patchProfile } from "@/services/cmds";
import { version } from "@root/package.json";
import Notice from "../base/base-notice";
interface Props {
@@ -24,7 +27,7 @@ interface Props {
// edit the profile item
// remote / local file / merge / script
const ProfileEdit = (props: Props) => {
const InfoEditor = (props: Props) => {
const { open, itemData, onClose } = props;
const { t } = useTranslation();
@@ -34,11 +37,15 @@ const ProfileEdit = (props: Props) => {
useEffect(() => {
if (itemData) {
const { option } = itemData;
setForm({ ...itemData });
setOption(itemData.option ?? {});
setOption(option ?? {});
setShowOpt(
itemData.type === "remote" &&
(!!itemData.option?.user_agent || !!itemData.option?.update_interval)
(!!option?.user_agent ||
!!option?.update_interval ||
!!option?.self_proxy ||
!!option?.with_proxy)
);
}
}, [itemData]);
@@ -57,7 +64,6 @@ const ProfileEdit = (props: Props) => {
}
await patchProfile(uid, { uid, name, desc, url, option: option_ });
setShowOpt(false);
mutate("getProfiles");
onClose();
} catch (err: any) {
@@ -84,7 +90,7 @@ const ProfileEdit = (props: Props) => {
<TextField
{...textFieldProps}
disabled
label="Type"
label={t("Type")}
value={type}
sx={{ input: { textTransform: "capitalize" } }}
/>
@@ -92,7 +98,7 @@ const ProfileEdit = (props: Props) => {
<TextField
{...textFieldProps}
autoFocus
label="Name"
label={t("Name")}
value={form.name}
onChange={(e) => setForm({ name: e.target.value })}
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
@@ -100,7 +106,7 @@ const ProfileEdit = (props: Props) => {
<TextField
{...textFieldProps}
label="Descriptions"
label={t("Descriptions")}
value={form.desc}
onChange={(e) => setForm({ desc: e.target.value })}
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
@@ -109,42 +115,83 @@ const ProfileEdit = (props: Props) => {
{type === "remote" && (
<TextField
{...textFieldProps}
label="Subscription URL"
label={t("Subscription URL")}
value={form.url}
onChange={(e) => setForm({ url: e.target.value })}
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
/>
)}
{showOpt && (
{(type === "remote" || type === "local") && (
<TextField
{...textFieldProps}
label="User Agent"
value={option.user_agent}
placeholder="clash-verge/v1.0.0"
onChange={(e) => setOption({ user_agent: e.target.value })}
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
/>
)}
{((type === "remote" && showOpt) || type === "local") && (
<TextField
{...textFieldProps}
label="Update Interval (mins)"
label={t("Update Interval(mins)")}
value={option.update_interval}
onChange={(e) => {
const str = e.target.value?.replace(/\D/, "");
setOption({ update_interval: str != null ? +str : str });
setOption({ update_interval: !!str ? +str : undefined });
}}
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
/>
)}
<Collapse
in={type === "remote" && showOpt}
timeout="auto"
unmountOnExit
>
<TextField
{...textFieldProps}
label="User Agent"
value={option.user_agent}
placeholder={`clash-verge/v${version}`}
onChange={(e) => setOption({ user_agent: e.target.value })}
onKeyDown={(e) => e.key === "Enter" && onUpdate()}
/>
<FormControlLabel
label={t("Use System Proxy")}
labelPlacement="start"
sx={{ ml: 0, my: 1 }}
control={
<Switch
color="primary"
checked={option.with_proxy ?? false}
onChange={(_e, c) =>
setOption((o) => ({
self_proxy: c ? false : o.self_proxy ?? false,
with_proxy: c,
}))
}
/>
}
/>
<FormControlLabel
label={t("Use Clash Proxy")}
labelPlacement="start"
sx={{ ml: 0, my: 1 }}
control={
<Switch
color="primary"
checked={option.self_proxy ?? false}
onChange={(_e, c) =>
setOption((o) => ({
with_proxy: c ? false : o.with_proxy ?? false,
self_proxy: c,
}))
}
/>
}
/>
</Collapse>
</DialogContent>
<DialogActions sx={{ px: 2, pb: 2, position: "relative" }}>
{form.type === "remote" && (
<IconButton
size="small"
color="inherit"
sx={{ position: "absolute", left: 18 }}
onClick={() => setShowOpt((o) => !o)}
>
@@ -152,13 +199,15 @@ const ProfileEdit = (props: Props) => {
</IconButton>
)}
<Button onClick={onClose}>Cancel</Button>
<Button onClick={onClose} variant="outlined">
{t("Cancel")}
</Button>
<Button onClick={onUpdate} variant="contained">
Update
{t("Save")}
</Button>
</DialogActions>
</Dialog>
);
};
export default ProfileEdit;
export default InfoEditor;
+71
View File
@@ -0,0 +1,71 @@
import { useTranslation } from "react-i18next";
import {
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Typography,
} from "@mui/material";
import BaseEmpty from "../base/base-empty";
import { Fragment } from "react";
interface Props {
open: boolean;
logInfo: [string, string][];
onClose: () => void;
}
const LogViewer = (props: Props) => {
const { open, logInfo, onClose } = props;
const { t } = useTranslation();
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t("Script Console")}</DialogTitle>
<DialogContent
sx={{
width: 400,
height: 300,
overflowX: "hidden",
userSelect: "text",
pb: 1,
}}
>
{logInfo.map(([level, log], index) => (
<Fragment key={index.toString()}>
<Typography color="text.secondary" component="div">
<Chip
label={level}
size="small"
variant="outlined"
color={
level === "error" || level === "exception"
? "error"
: "default"
}
sx={{ mr: 1 }}
/>
{log}
</Typography>
<Divider sx={{ my: 0.5 }} />
</Fragment>
))}
{logInfo.length === 0 && <BaseEmpty />}
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="outlined">
{t("Back")}
</Button>
</DialogActions>
</Dialog>
);
};
export default LogViewer;
+43
View File
@@ -0,0 +1,43 @@
import { alpha, Box, styled } from "@mui/material";
const ProfileBox = styled(Box)(({ theme, "aria-selected": selected }) => {
const { mode, primary, text, grey, background } = theme.palette;
const key = `${mode}-${!!selected}`;
const backgroundColor = {
"light-true": alpha(primary.main, 0.2),
"light-false": alpha(background.paper, 0.75),
"dark-true": alpha(primary.main, 0.45),
"dark-false": alpha(grey[700], 0.45),
}[key]!;
const color = {
"light-true": text.secondary,
"light-false": text.secondary,
"dark-true": alpha(text.secondary, 0.85),
"dark-false": alpha(text.secondary, 0.65),
}[key]!;
const h2color = {
"light-true": primary.main,
"light-false": text.primary,
"dark-true": primary.light,
"dark-false": text.primary,
}[key]!;
return {
width: "100%",
display: "block",
cursor: "pointer",
textAlign: "left",
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
padding: "8px 16px",
boxSizing: "border-box",
backgroundColor,
color,
"& h2": { color: h2color },
};
});
export default ProfileBox;
+86 -112
View File
@@ -1,13 +1,11 @@
import dayjs from "dayjs";
import { mutate } from "swr";
import { useEffect, useState } from "react";
import { useLockFn } from "ahooks";
import { useSWRConfig } from "swr";
import { useRecoilState } from "recoil";
import { useTranslation } from "react-i18next";
import {
alpha,
Box,
styled,
Typography,
LinearProgress,
IconButton,
@@ -16,33 +14,19 @@ import {
Menu,
} from "@mui/material";
import { RefreshRounded } from "@mui/icons-material";
import { CmdType } from "../../services/types";
import { atomLoadingCache } from "../../services/states";
import { updateProfile, deleteProfile, viewProfile } from "../../services/cmds";
import parseTraffic from "../../utils/parse-traffic";
import getSystem from "../../utils/get-system";
import ProfileEdit from "./profile-edit";
import { atomLoadingCache } from "@/services/states";
import { updateProfile, deleteProfile, viewProfile } from "@/services/cmds";
import parseTraffic from "@/utils/parse-traffic";
import ProfileBox from "./profile-box";
import InfoEditor from "./info-editor";
import FileEditor from "./file-editor";
import Notice from "../base/base-notice";
const Wrapper = styled(Box)(({ theme }) => ({
width: "100%",
display: "block",
cursor: "pointer",
textAlign: "left",
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
padding: "8px 16px",
boxSizing: "border-box",
}));
const round = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
const OS = getSystem();
interface Props {
selected: boolean;
itemData: CmdType.ProfileItem;
@@ -53,7 +37,6 @@ const ProfileItem = (props: Props) => {
const { selected, itemData, onSelect } = props;
const { t } = useTranslation();
const { mutate } = useSWRConfig();
const [anchorEl, setAnchorEl] = useState<any>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const [loadingCache, setLoadingCache] = useRecoilState(atomLoadingCache);
@@ -62,7 +45,6 @@ const ProfileItem = (props: Props) => {
// local file mode
// remote file mode
// subscription url mode
const hasUrl = !!itemData.url;
const hasExtra = !!extra; // only subscription url has extra info
@@ -83,7 +65,6 @@ const ProfileItem = (props: Props) => {
const handler = () => {
const now = Date.now();
const lastUpdate = updated * 1000;
// 大于一天的不管
if (now - lastUpdate >= 24 * 36e5) return;
@@ -129,15 +110,38 @@ const ProfileItem = (props: Props) => {
}
});
const onUpdate = useLockFn(async (withProxy: boolean) => {
/// 0 不使用任何代理
/// 1 使用配置好的代理
/// 2 至少使用一个代理,根据配置,如果没配置,默认使用系统代理
const onUpdate = useLockFn(async (type: 0 | 1 | 2) => {
setAnchorEl(null);
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
const option: Partial<CmdType.ProfileOption> = {};
if (type === 0) {
option.with_proxy = false;
option.self_proxy = false;
} else if (type === 1) {
// nothing
} else if (type === 2) {
if (itemData.option?.self_proxy) {
option.with_proxy = false;
option.self_proxy = true;
} else {
option.with_proxy = true;
option.self_proxy = false;
}
}
try {
await updateProfile(itemData.uid, { with_proxy: withProxy });
await updateProfile(itemData.uid, option);
mutate("getProfiles");
} catch (err: any) {
Notice.error(err?.message || err.toString());
const errmsg = err?.message || err.toString();
Notice.error(
errmsg.replace(/error sending request for url (\S+?): /, "")
);
} finally {
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));
}
@@ -153,20 +157,13 @@ const ProfileItem = (props: Props) => {
}
});
const boxStyle = {
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
};
const urlModeMenu = [
{ label: "Select", handler: onForceSelect },
{ label: "Edit Info", handler: onEditInfo },
{ label: "Edit File", handler: onEditFile },
{ label: "Open File", handler: onOpenFile },
{ label: "Update", handler: () => onUpdate(false) },
{ label: "Update(Proxy)", handler: () => onUpdate(true) },
{ label: "Update", handler: () => onUpdate(0) },
{ label: "Update(Proxy)", handler: () => onUpdate(2) },
{ label: "Delete", handler: onDelete },
];
const fileModeMenu = [
@@ -177,36 +174,17 @@ const ProfileItem = (props: Props) => {
{ label: "Delete", handler: onDelete },
];
const boxStyle = {
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
};
return (
<>
<Wrapper
sx={({ palette }) => {
const { mode, primary, text, grey } = palette;
const key = `${mode}-${selected}`;
const bgcolor = {
"light-true": alpha(primary.main, 0.15),
"light-false": palette.background.paper,
"dark-true": alpha(primary.main, 0.35),
"dark-false": alpha(grey[700], 0.35),
}[key]!;
const color = {
"light-true": text.secondary,
"light-false": text.secondary,
"dark-true": alpha(text.secondary, 0.75),
"dark-false": alpha(text.secondary, 0.75),
}[key]!;
const h2color = {
"light-true": primary.main,
"light-false": text.primary,
"dark-true": primary.light,
"dark-false": text.primary,
}[key]!;
return { bgcolor, color, "& h2": { color: h2color } };
}}
<ProfileBox
aria-selected={selected}
onClick={() => onSelect(false)}
onContextMenu={(event) => {
const { clientX, clientY } = event;
@@ -215,9 +193,9 @@ const ProfileItem = (props: Props) => {
event.preventDefault();
}}
>
<Box display="flex" justifyContent="space-between">
<Box position="relative">
<Typography
width="calc(100% - 40px)"
width="calc(100% - 36px)"
variant="h6"
component="h2"
noWrap
@@ -230,58 +208,61 @@ const ProfileItem = (props: Props) => {
{hasUrl && (
<IconButton
sx={{
width: 26,
height: 26,
position: "absolute",
p: "3px",
top: -1,
right: -5,
animation: loading ? `1s linear infinite ${round}` : "none",
}}
size="small"
color="inherit"
disabled={loading}
onClick={(e) => {
e.stopPropagation();
onUpdate(false);
onUpdate(1);
}}
>
<RefreshRounded />
<RefreshRounded color="inherit" />
</IconButton>
)}
</Box>
{/* the second line show url's info or description */}
{hasUrl ? (
<Box sx={boxStyle}>
<Typography noWrap title={`From: ${from}`}>
{from}
</Typography>
<Box sx={boxStyle}>
{hasUrl ? (
<>
<Typography noWrap title={`From: ${from}`}>
{from}
</Typography>
<Typography
noWrap
flex="1 0 auto"
fontSize={14}
textAlign="right"
title="updated time"
>
{updated > 0 ? dayjs(updated * 1000).fromNow() : ""}
</Typography>
</Box>
) : (
<Box sx={boxStyle}>
<Typography
noWrap
flex="1 0 auto"
fontSize={14}
textAlign="right"
title={`Updated Time: ${parseExpire(updated)}`}
>
{updated > 0 ? dayjs(updated * 1000).fromNow() : ""}
</Typography>
</>
) : (
<Typography noWrap title={itemData.desc}>
{itemData.desc}
</Typography>
</Box>
)}
)}
</Box>
{/* the third line show extra info or last updated time */}
{hasExtra ? (
<Box sx={{ ...boxStyle, fontSize: 14 }}>
<span title="used / total">
<span title="Used / Total">
{parseTraffic(upload + download)} / {parseTraffic(total)}
</span>
<span title="expire time">{expire}</span>
<span title="Expire Time">{expire}</span>
</Box>
) : (
<Box sx={{ ...boxStyle, fontSize: 14, justifyContent: "flex-end" }}>
<span title="updated time">{parseExpire(updated)}</span>
<span title="Updated Time">{parseExpire(updated)}</span>
</Box>
)}
@@ -290,7 +271,7 @@ const ProfileItem = (props: Props) => {
value={progress}
color="inherit"
/>
</Wrapper>
</ProfileBox>
<Menu
open={!!anchorEl}
@@ -299,9 +280,6 @@ const ProfileItem = (props: Props) => {
anchorPosition={position}
anchorReference="anchorPosition"
transitionDuration={225}
TransitionProps={
OS === "macos" ? { style: { transitionDuration: "225ms" } } : {}
}
onContextMenu={(e) => {
setAnchorEl(null);
e.preventDefault();
@@ -318,22 +296,18 @@ const ProfileItem = (props: Props) => {
))}
</Menu>
{editOpen && (
<ProfileEdit
open={editOpen}
itemData={itemData}
onClose={() => setEditOpen(false)}
/>
)}
<InfoEditor
open={editOpen}
itemData={itemData}
onClose={() => setEditOpen(false)}
/>
{fileOpen && (
<FileEditor
uid={uid}
open={fileOpen}
mode="yaml"
onClose={() => setFileOpen(false)}
/>
)}
<FileEditor
uid={uid}
open={fileOpen}
mode="yaml"
onClose={() => setFileOpen(false)}
/>
</>
);
};
+71 -94
View File
@@ -1,41 +1,29 @@
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import {
alpha,
Box,
Badge,
Chip,
styled,
Typography,
MenuItem,
Menu,
IconButton,
} from "@mui/material";
import { CmdType } from "../../services/types";
import { viewProfile } from "../../services/cmds";
import getSystem from "../../utils/get-system";
import enhance from "../../services/enhance";
import ProfileEdit from "./profile-edit";
import { FeaturedPlayListRounded } from "@mui/icons-material";
import { viewProfile } from "@/services/cmds";
import InfoEditor from "./info-editor";
import FileEditor from "./file-editor";
import ProfileBox from "./profile-box";
import LogViewer from "./log-viewer";
import Notice from "../base/base-notice";
const Wrapper = styled(Box)(({ theme }) => ({
width: "100%",
display: "block",
cursor: "pointer",
textAlign: "left",
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
padding: "8px 16px",
boxSizing: "border-box",
}));
const OS = getSystem();
interface Props {
selected: boolean;
itemData: CmdType.ProfileItem;
enableNum: number;
logInfo?: [string, string][];
onEnable: () => void;
onDisable: () => void;
onMoveTop: () => void;
@@ -49,6 +37,7 @@ const ProfileMore = (props: Props) => {
selected,
itemData,
enableNum,
logInfo = [],
onEnable,
onDisable,
onMoveTop,
@@ -57,19 +46,12 @@ const ProfileMore = (props: Props) => {
} = props;
const { uid, type } = itemData;
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const [editOpen, setEditOpen] = useState(false);
const [fileOpen, setFileOpen] = useState(false);
const [status, setStatus] = useState(enhance.status(uid));
// unlisten when unmount
useEffect(() => enhance.listen(uid, setStatus), [uid]);
// error during enhanced mode
const hasError = selected && status?.status === "error";
const [logOpen, setLogOpen] = useState(false);
const onEditInfo = () => {
setAnchorEl(null);
@@ -95,6 +77,7 @@ const ProfileMore = (props: Props) => {
return fn();
};
const hasError = !!logInfo.find((e) => e[0] === "exception");
const showMove = enableNum > 1 && !hasError;
const enableMenu = [
@@ -125,39 +108,9 @@ const ProfileMore = (props: Props) => {
return (
<>
<Wrapper
sx={({ palette }) => {
// todo
// 区分 selected 和 error 和 mode 下各种颜色的排列组合
const { mode, primary, text, grey, error } = palette;
const key = `${mode}-${selected}`;
const bgkey = hasError ? `${mode}-err` : key;
const bgcolor = {
"light-true": alpha(primary.main, 0.15),
"light-false": palette.background.paper,
"dark-true": alpha(primary.main, 0.35),
"dark-false": alpha(grey[700], 0.35),
"light-err": alpha(error.main, 0.12),
"dark-err": alpha(error.main, 0.3),
}[bgkey]!;
const color = {
"light-true": text.secondary,
"light-false": text.secondary,
"dark-true": alpha(text.secondary, 0.6),
"dark-false": alpha(text.secondary, 0.6),
}[key]!;
const h2color = {
"light-true": primary.main,
"light-false": text.primary,
"dark-true": primary.light,
"dark-false": text.primary,
}[key]!;
return { bgcolor, color, "& h2": { color: h2color } };
}}
<ProfileBox
aria-selected={selected}
onDoubleClick={onEditFile}
// onClick={() => onSelect(false)}
onContextMenu={(event) => {
const { clientX, clientY } = event;
@@ -166,7 +119,12 @@ const ProfileMore = (props: Props) => {
event.preventDefault();
}}
>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
mb={0.5}
>
<Typography
width="calc(100% - 52px)"
variant="h6"
@@ -182,39 +140,57 @@ const ProfileMore = (props: Props) => {
color="primary"
size="small"
variant="outlined"
sx={{ textTransform: "capitalize" }}
sx={{ height: 20, textTransform: "capitalize" }}
/>
</Box>
<Box sx={boxStyle}>
{hasError ? (
<Typography
noWrap
color="error"
sx={{ width: "calc(100% - 75px)" }}
title={status.message}
>
{status.message}
</Typography>
{selected && type === "script" ? (
hasError ? (
<Badge color="error" variant="dot" overlap="circular">
<IconButton
size="small"
edge="start"
color="error"
title="Console"
onClick={() => setLogOpen(true)}
>
<FeaturedPlayListRounded fontSize="inherit" />
</IconButton>
</Badge>
) : (
<IconButton
size="small"
edge="start"
color="inherit"
title="Console"
onClick={() => setLogOpen(true)}
>
<FeaturedPlayListRounded fontSize="inherit" />
</IconButton>
)
) : (
<Typography
noWrap
title={itemData.desc}
sx={{ width: "calc(100% - 75px)" }}
sx={i18n.language === "zh" ? { width: "calc(100% - 75px)" } : {}}
>
{itemData.desc}
</Typography>
)}
<Typography
noWrap
component="span"
title="updated time"
title={`Updated Time: ${parseExpire(itemData.updated)}`}
style={{ fontSize: 14 }}
>
{parseExpire(itemData.updated)}
{!!itemData.updated
? dayjs(itemData.updated! * 1000).fromNow()
: ""}
</Typography>
</Box>
</Wrapper>
</ProfileBox>
<Menu
open={!!anchorEl}
@@ -223,9 +199,6 @@ const ProfileMore = (props: Props) => {
anchorPosition={position}
anchorReference="anchorPosition"
transitionDuration={225}
TransitionProps={
OS === "macos" ? { style: { transitionDuration: "225ms" } } : {}
}
onContextMenu={(e) => {
setAnchorEl(null);
e.preventDefault();
@@ -244,20 +217,24 @@ const ProfileMore = (props: Props) => {
))}
</Menu>
{editOpen && (
<ProfileEdit
open={editOpen}
itemData={itemData}
onClose={() => setEditOpen(false)}
/>
)}
<InfoEditor
open={editOpen}
itemData={itemData}
onClose={() => setEditOpen(false)}
/>
{fileOpen && (
<FileEditor
uid={uid}
open={fileOpen}
mode={type === "merge" ? "yaml" : "javascript"}
onClose={() => setFileOpen(false)}
<FileEditor
uid={uid}
open={fileOpen}
mode={type === "merge" ? "yaml" : "javascript"}
onClose={() => setFileOpen(false)}
/>
{selected && (
<LogViewer
open={logOpen}
logInfo={logInfo}
onClose={() => setLogOpen(false)}
/>
)}
</>
+62 -13
View File
@@ -1,21 +1,25 @@
import { useRef, useState } from "react";
import { useSWRConfig } from "swr";
import { mutate } from "swr";
import { useTranslation } from "react-i18next";
import { useLockFn, useSetState } from "ahooks";
import {
Button,
Collapse,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
IconButton,
InputLabel,
MenuItem,
Select,
Switch,
TextField,
} from "@mui/material";
import { Settings } from "@mui/icons-material";
import { createProfile } from "../../services/cmds";
import { createProfile } from "@/services/cmds";
import Notice from "../base/base-notice";
import FileInput from "./file-input";
@@ -29,7 +33,7 @@ interface Props {
const ProfileNew = (props: Props) => {
const { open, onClose } = props;
const { mutate } = useSWRConfig();
const { t } = useTranslation();
const [form, setForm] = useSetState({
type: "remote",
name: "",
@@ -39,7 +43,11 @@ const ProfileNew = (props: Props) => {
const [showOpt, setShowOpt] = useState(false);
// can add more option
const [option, setOption] = useSetState({ user_agent: "" });
const [option, setOption] = useSetState({
user_agent: "",
with_proxy: false,
self_proxy: false,
});
// file input
const fileDataRef = useRef<string | null>(null);
@@ -83,14 +91,14 @@ const ProfileNew = (props: Props) => {
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle sx={{ pb: 0.5 }}>Create Profile</DialogTitle>
<DialogTitle sx={{ pb: 0.5 }}>{t("Create Profile")}</DialogTitle>
<DialogContent sx={{ width: 336, pb: 1 }}>
<FormControl size="small" fullWidth sx={{ mt: 2, mb: 1 }}>
<InputLabel>Type</InputLabel>
<Select
autoFocus
label="Type"
label={t("Type")}
value={form.type}
onChange={(e) => setForm({ type: e.target.value })}
>
@@ -103,7 +111,7 @@ const ProfileNew = (props: Props) => {
<TextField
{...textFieldProps}
label="Name"
label={t("Name")}
autoComplete="off"
value={form.name}
onChange={(e) => setForm({ name: e.target.value })}
@@ -111,7 +119,7 @@ const ProfileNew = (props: Props) => {
<TextField
{...textFieldProps}
label="Descriptions"
label={t("Descriptions")}
autoComplete="off"
value={form.desc}
onChange={(e) => setForm({ desc: e.target.value })}
@@ -120,7 +128,7 @@ const ProfileNew = (props: Props) => {
{form.type === "remote" && (
<TextField
{...textFieldProps}
label="Subscription Url"
label={t("Subscription URL")}
autoComplete="off"
value={form.url}
onChange={(e) => setForm({ url: e.target.value })}
@@ -131,7 +139,11 @@ const ProfileNew = (props: Props) => {
<FileInput onChange={(val) => (fileDataRef.current = val)} />
)}
{showOpt && (
<Collapse
in={form.type === "remote" && showOpt}
timeout="auto"
unmountOnExit
>
<TextField
{...textFieldProps}
label="User Agent"
@@ -139,13 +151,48 @@ const ProfileNew = (props: Props) => {
value={option.user_agent}
onChange={(e) => setOption({ user_agent: e.target.value })}
/>
)}
<FormControlLabel
label={t("Use System Proxy")}
labelPlacement="start"
sx={{ ml: 0, my: 1 }}
control={
<Switch
color="primary"
checked={option.with_proxy}
onChange={(_e, c) =>
setOption((o) => ({
self_proxy: c ? false : o.self_proxy,
with_proxy: c,
}))
}
/>
}
/>
<FormControlLabel
label={t("Use Clash Proxy")}
labelPlacement="start"
sx={{ ml: 0, my: 1 }}
control={
<Switch
color="primary"
checked={option.self_proxy}
onChange={(_e, c) =>
setOption((o) => ({
with_proxy: c ? false : o.with_proxy,
self_proxy: c,
}))
}
/>
}
/>
</Collapse>
</DialogContent>
<DialogActions sx={{ px: 2, pb: 2, position: "relative" }}>
{form.type === "remote" && (
<IconButton
size="small"
color="inherit"
sx={{ position: "absolute", left: 18 }}
onClick={() => setShowOpt((o) => !o)}
>
@@ -153,9 +200,11 @@ const ProfileNew = (props: Props) => {
</IconButton>
)}
<Button onClick={onClose}>Cancel</Button>
<Button onClick={onClose} variant="outlined">
{t("Cancel")}
</Button>
<Button onClick={onCreate} variant="contained">
Create
{t("Save")}
</Button>
</DialogActions>
</Dialog>
+31 -22
View File
@@ -2,12 +2,11 @@ import useSWR, { useSWRConfig } from "swr";
import { useEffect, useRef, useState } from "react";
import { useLockFn } from "ahooks";
import { Virtuoso } from "react-virtuoso";
import { ApiType } from "../../services/types";
import { updateProxy } from "../../services/api";
import { getProfiles, patchProfile } from "../../services/cmds";
import useSortProxy, { ProxySortType } from "./use-sort-proxy";
import useFilterProxy from "./use-filter-proxy";
import delayManager from "../../services/delay";
import { providerHealthCheck, updateProxy } from "@/services/api";
import { getProfiles, patchProfile } from "@/services/cmds";
import delayManager from "@/services/delay";
import useHeadState from "./use-head-state";
import useFilterSort from "./use-filter-sort";
import ProxyHead from "./proxy-head";
import ProxyItem from "./proxy-item";
@@ -24,13 +23,15 @@ const ProxyGlobal = (props: Props) => {
const { mutate } = useSWRConfig();
const [now, setNow] = useState(curProxy || "DIRECT");
const [showType, setShowType] = useState(true);
const [sortType, setSortType] = useState<ProxySortType>(0);
const [filterText, setFilterText] = useState("");
const [headState, setHeadState] = useHeadState(groupName);
const virtuosoRef = useRef<any>();
const filterProxies = useFilterProxy(proxies, groupName, filterText);
const sortedProxies = useSortProxy(filterProxies, groupName, sortType);
const sortedProxies = useFilterSort(
proxies,
groupName,
headState.filterText,
headState.sortType
);
const { data: profiles } = useSWR("getProfiles", getProfiles);
@@ -68,11 +69,23 @@ const ProxyGlobal = (props: Props) => {
};
const onCheckAll = useLockFn(async () => {
const names = sortedProxies.map((p) => p.name);
await delayManager.checkListDelay({ names, groupName, skipNum: 8 }, () =>
mutate("getProxies")
const providers = new Set(
sortedProxies.map((p) => p.provider!).filter(Boolean)
);
if (providers.size) {
Promise.allSettled(
[...providers].map((p) => providerHealthCheck(p))
).then(() => mutate("getProxies"));
}
await delayManager.checkListDelay(
sortedProxies.filter((p) => !p.provider).map((p) => p.name),
groupName,
16
);
mutate("getProxies");
});
useEffect(() => onLocation(false), [groupName]);
@@ -102,15 +115,11 @@ const ProxyGlobal = (props: Props) => {
<>
<ProxyHead
sx={{ px: 3, my: 0.5, button: { mr: 0.5 } }}
showType={showType}
sortType={sortType}
groupName={groupName}
filterText={filterText}
headState={headState}
onLocation={onLocation}
onCheckDelay={onCheckAll}
onShowType={setShowType}
onSortType={setSortType}
onFilterText={setFilterText}
onHeadState={setHeadState}
/>
<Virtuoso
@@ -122,7 +131,7 @@ const ProxyGlobal = (props: Props) => {
groupName={groupName}
proxy={sortedProxies[index]}
selected={sortedProxies[index].name === now}
showType={showType}
showType={headState.showType}
onClick={onChangeProxy}
sx={{ py: 0, px: 2 }}
/>
+51 -31
View File
@@ -15,12 +15,11 @@ import {
ExpandLessRounded,
ExpandMoreRounded,
} from "@mui/icons-material";
import { ApiType } from "../../services/types";
import { updateProxy } from "../../services/api";
import { getProfiles, patchProfile } from "../../services/cmds";
import useSortProxy, { ProxySortType } from "./use-sort-proxy";
import useFilterProxy from "./use-filter-proxy";
import delayManager from "../../services/delay";
import { providerHealthCheck, updateProxy } from "@/services/api";
import { getProfiles, patchProfile } from "@/services/cmds";
import delayManager from "@/services/delay";
import useHeadState from "./use-head-state";
import useFilterSort from "./use-filter-sort";
import ProxyHead from "./proxy-head";
import ProxyItem from "./proxy-item";
@@ -30,16 +29,17 @@ interface Props {
const ProxyGroup = ({ group }: Props) => {
const { mutate } = useSWRConfig();
const [open, setOpen] = useState(false);
const [now, setNow] = useState(group.now);
const [showType, setShowType] = useState(false);
const [sortType, setSortType] = useState<ProxySortType>(0);
const [filterText, setFilterText] = useState("");
const [headState, setHeadState] = useHeadState(group.name);
const virtuosoRef = useRef<any>();
const filterProxies = useFilterProxy(group.all, group.name, filterText);
const sortedProxies = useSortProxy(filterProxies, group.name, sortType);
const sortedProxies = useFilterSort(
group.all,
group.name,
headState.filterText,
headState.sortType
);
const { data: profiles } = useSWR("getProfiles", getProfiles);
@@ -89,24 +89,48 @@ const ProxyGroup = ({ group }: Props) => {
};
const onCheckAll = useLockFn(async () => {
const names = sortedProxies.map((p) => p.name);
const groupName = group.name;
await delayManager.checkListDelay({ names, groupName, skipNum: 8 }, () =>
mutate("getProxies")
const providers = new Set(
sortedProxies.map((p) => p.provider!).filter(Boolean)
);
if (providers.size) {
Promise.allSettled(
[...providers].map((p) => providerHealthCheck(p))
).then(() => mutate("getProxies"));
}
await delayManager.checkListDelay(
sortedProxies.filter((p) => !p.provider).map((p) => p.name),
group.name,
16
);
mutate("getProxies");
});
// auto scroll to current index
useEffect(() => {
if (open) {
setTimeout(() => onLocation(false), 5);
if (headState.open) {
setTimeout(() => onLocation(false), 10);
}
}, [open]);
}, [headState.open, sortedProxies]);
// auto scroll when sorted changed
const timerRef = useRef<any>();
useEffect(() => {
if (headState.open) {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onLocation(false), 500);
}
}, [headState.open, sortedProxies]);
return (
<>
<ListItem button onClick={() => setOpen(!open)} dense>
<ListItem
button
dense
onClick={() => setHeadState({ open: !headState.open })}
>
<ListItemText
primary={group.name}
secondary={
@@ -120,21 +144,17 @@ const ProxyGroup = ({ group }: Props) => {
}}
/>
{open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
{headState.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
</ListItem>
<Collapse in={open} timeout="auto" unmountOnExit>
<Collapse in={headState.open} timeout="auto" unmountOnExit>
<ProxyHead
sx={{ pl: 4, pr: 3, my: 0.5, button: { mr: 0.5 } }}
showType={showType}
sortType={sortType}
groupName={group.name}
filterText={filterText}
headState={headState}
onLocation={onLocation}
onCheckDelay={onCheckAll}
onShowType={setShowType}
onSortType={setSortType}
onFilterText={setFilterText}
onHeadState={setHeadState}
/>
{!sortedProxies.length && (
@@ -160,7 +180,7 @@ const ProxyGroup = ({ group }: Props) => {
groupName={group.name}
proxy={sortedProxies[index]}
selected={sortedProxies[index].name === now}
showType={showType}
showType={headState.showType}
sx={{ py: 0, pl: 4 }}
onClick={onChangeProxy}
/>
@@ -178,7 +198,7 @@ const ProxyGroup = ({ group }: Props) => {
groupName={group.name}
proxy={proxy}
selected={proxy.name === now}
showType={showType}
showType={headState.showType}
sx={{ py: 0, pl: 4 }}
onClick={onChangeProxy}
/>
+46 -32
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Box, IconButton, TextField, SxProps } from "@mui/material";
import {
AccessTimeRounded,
@@ -13,35 +14,43 @@ import {
SortByAlphaRounded,
SortRounded,
} from "@mui/icons-material";
import delayManager from "../../services/delay";
import type { ProxySortType } from "./use-sort-proxy";
import delayManager from "@/services/delay";
import type { HeadState } from "./use-head-state";
import type { ProxySortType } from "./use-filter-sort";
interface Props {
sx?: SxProps;
groupName: string;
showType: boolean;
sortType: ProxySortType;
filterText: string;
headState: HeadState;
onLocation: () => void;
onCheckDelay: () => void;
onShowType: (val: boolean) => void;
onSortType: (val: ProxySortType) => void;
onFilterText: (val: string) => void;
onHeadState: (val: Partial<HeadState>) => void;
}
const ProxyHead = (props: Props) => {
const { sx = {}, groupName, showType, sortType, filterText } = props;
const { sx = {}, groupName, headState, onHeadState } = props;
const [textState, setTextState] = useState<"url" | "filter" | null>(null);
const { showType, sortType, filterText, textState, testUrl } = headState;
const [testUrl, setTestUrl] = useState(delayManager.getUrl(groupName) || "");
const { t } = useTranslation();
const [autoFocus, setAutoFocus] = useState(false);
useEffect(() => {
// fix the focus conflict
const timer = setTimeout(() => setAutoFocus(true), 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
delayManager.setUrl(groupName, testUrl);
}, [groupName, headState.testUrl]);
return (
<Box sx={{ display: "flex", alignItems: "center", ...sx }}>
<IconButton
size="small"
title="location"
color="inherit"
title={t("Location")}
onClick={props.onLocation}
>
<MyLocationRounded />
@@ -50,11 +59,11 @@ const ProxyHead = (props: Props) => {
<IconButton
size="small"
color="inherit"
title="delay check"
title={t("Delay check")}
onClick={() => {
// Remind the user that it is custom test url
if (testUrl?.trim() && textState !== "filter") {
setTextState("url");
onHeadState({ textState: "url" });
}
props.onCheckDelay();
}}
@@ -65,8 +74,14 @@ const ProxyHead = (props: Props) => {
<IconButton
size="small"
color="inherit"
title={["sort by default", "sort by delay", "sort by name"][sortType]}
onClick={() => props.onSortType(((sortType + 1) % 3) as ProxySortType)}
title={
[t("Sort by default"), t("Sort by delay"), t("Sort by name")][
sortType
]
}
onClick={() =>
onHeadState({ sortType: ((sortType + 1) % 3) as ProxySortType })
}
>
{sortType === 0 && <SortRounded />}
{sortType === 1 && <AccessTimeRounded />}
@@ -76,8 +91,10 @@ const ProxyHead = (props: Props) => {
<IconButton
size="small"
color="inherit"
title="edit test url"
onClick={() => setTextState((ts) => (ts === "url" ? null : "url"))}
title={t("Delay check URL")}
onClick={() =>
onHeadState({ textState: textState === "url" ? null : "url" })
}
>
{textState === "url" ? (
<WifiTetheringRounded />
@@ -89,8 +106,8 @@ const ProxyHead = (props: Props) => {
<IconButton
size="small"
color="inherit"
title="proxy detail"
onClick={() => props.onShowType(!showType)}
title={t("Proxy detail")}
onClick={() => onHeadState({ showType: !showType })}
>
{showType ? <VisibilityRounded /> : <VisibilityOffRounded />}
</IconButton>
@@ -98,9 +115,9 @@ const ProxyHead = (props: Props) => {
<IconButton
size="small"
color="inherit"
title="filter"
title={t("Filter")}
onClick={() =>
setTextState((ts) => (ts === "filter" ? null : "filter"))
onHeadState({ textState: textState === "filter" ? null : "filter" })
}
>
{textState === "filter" ? (
@@ -112,31 +129,28 @@ const ProxyHead = (props: Props) => {
{textState === "filter" && (
<TextField
autoFocus
autoFocus={autoFocus}
hiddenLabel
value={filterText}
size="small"
variant="outlined"
placeholder="Filter conditions"
onChange={(e) => props.onFilterText(e.target.value)}
placeholder={t("Filter conditions")}
onChange={(e) => onHeadState({ filterText: e.target.value })}
sx={{ ml: 0.5, flex: "1 1 auto", input: { py: 0.65, px: 1 } }}
/>
)}
{textState === "url" && (
<TextField
autoFocus
autoFocus={autoFocus}
hiddenLabel
autoSave="off"
autoComplete="off"
value={testUrl}
size="small"
variant="outlined"
placeholder="Test url"
onChange={(e) => {
setTestUrl(e.target.value);
delayManager.setUrl(groupName, e.target.value);
}}
placeholder={t("Delay check URL")}
onChange={(e) => onHeadState({ testUrl: e.target.value })}
sx={{ ml: 0.5, flex: "1 1 auto", input: { py: 0.65, px: 1 } }}
/>
)}
+84 -49
View File
@@ -12,8 +12,8 @@ import {
SxProps,
Theme,
} from "@mui/material";
import { ApiType } from "../../services/types";
import delayManager from "../../services/delay";
import delayManager from "@/services/delay";
import BaseLoading from "../base/base-loading";
interface Props {
groupName: string;
@@ -44,19 +44,37 @@ const TypeBox = styled(Box)(({ theme }) => ({
const ProxyItem = (props: Props) => {
const { groupName, proxy, selected, showType = true, sx, onClick } = props;
// -1/<=0 为 不显示
// -2 为 loading
const [delay, setDelay] = useState(-1);
useEffect(() => {
if (proxy) {
delayManager.setListener(proxy.name, groupName, setDelay);
return () => {
delayManager.removeListener(proxy.name, groupName);
};
}, [proxy.name, groupName]);
useEffect(() => {
if (!proxy) return;
if (!proxy.provider) {
setDelay(delayManager.getDelay(proxy.name, groupName));
return;
}
const { history = [] } = proxy;
if (history.length > 0) {
// 0ms以error显示
setDelay(history[history.length - 1].delay || 1e6);
}
}, [proxy]);
const onDelay = useLockFn(async () => {
return delayManager
.checkDelay(proxy.name, groupName)
.then((result) => setDelay(result))
.catch(() => setDelay(1e6));
setDelay(-2);
setDelay(await delayManager.checkDelay(proxy.name, groupName));
});
return (
@@ -73,14 +91,9 @@ const ProxyItem = (props: Props) => {
? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35);
const color = mode === "light" ? primary.main : primary.light;
const showDelay = delay > 0;
const showIcon = !showDelay && selected;
return {
".the-check": { display: "none" },
".the-delay": { display: showDelay ? "block" : "none" },
".the-icon": { display: showIcon ? "block" : "none" },
"&:hover .the-check": { display: !showDelay ? "block" : "none" },
"&:hover .the-delay": { display: showDelay ? "block" : "none" },
"&:hover .the-icon": { display: "none" },
@@ -96,6 +109,9 @@ const ProxyItem = (props: Props) => {
<>
{proxy.name}
{showType && !!proxy.provider && (
<TypeBox component="span">{proxy.provider}</TypeBox>
)}
{showType && <TypeBox component="span">{proxy.type}</TypeBox>}
{showType && proxy.udp && <TypeBox component="span">UDP</TypeBox>}
</>
@@ -105,45 +121,64 @@ const ProxyItem = (props: Props) => {
<ListItemIcon
sx={{ justifyContent: "flex-end", color: "primary.main" }}
>
<Widget
className="the-check"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelay();
}}
sx={(theme) => ({
":hover": { bgcolor: alpha(theme.palette.primary.main, 0.15) },
})}
>
Check
</Widget>
{delay === -2 && (
<Widget>
<BaseLoading />
</Widget>
)}
<Widget
className="the-delay"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelay();
}}
color={
delay > 500
? "error.main"
: delay < 100
? "success.main"
: "text.secondary"
}
sx={(theme) => ({
":hover": { bgcolor: alpha(theme.palette.primary.main, 0.15) },
})}
>
{delay > 1e5 ? "Error" : delay > 3000 ? "Timeout" : `${delay}ms`}
</Widget>
{!proxy.provider && delay !== -2 && (
// provider的节点不支持检测
<Widget
className="the-check"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelay();
}}
sx={({ palette }) => ({
display: "none", // hover才显示
":hover": { bgcolor: alpha(palette.primary.main, 0.15) },
})}
>
Check
</Widget>
)}
<CheckCircleOutlineRounded
className="the-icon"
sx={{ fontSize: 16 }}
/>
{delay > 0 && (
// 显示延迟
<Widget
className="the-delay"
onClick={(e) => {
if (proxy.provider) return;
e.preventDefault();
e.stopPropagation();
onDelay();
}}
color={
delay > 500
? "error.main"
: delay < 100
? "success.main"
: "text.secondary"
}
sx={({ palette }) =>
!proxy.provider
? { ":hover": { bgcolor: alpha(palette.primary.main, 0.15) } }
: {}
}
>
{delay > 1e5 ? "Error" : delay > 3000 ? "Timeout" : `${delay}ms`}
</Widget>
)}
{delay !== -2 && delay <= 0 && selected && (
// 展示已选择的icon
<CheckCircleOutlineRounded
className="the-icon"
sx={{ fontSize: 16 }}
/>
)}
</ListItemIcon>
</ListItemButton>
</ListItem>
-50
View File
@@ -1,50 +0,0 @@
import { useMemo } from "react";
import { ApiType } from "../../services/types";
import delayManager from "../../services/delay";
const regex1 = /delay([=<>])(\d+|timeout|error)/i;
const regex2 = /type=(.*)/i;
/**
* filter the proxy
* according to the regular conditions
*/
export default function useFilterProxy(
proxies: ApiType.ProxyItem[],
groupName: string,
filterText: string
) {
return useMemo(() => {
if (!proxies) return [];
if (!filterText) return proxies;
const res1 = regex1.exec(filterText);
if (res1) {
const symbol = res1[1];
const symbol2 = res1[2].toLowerCase();
const value =
symbol2 === "error" ? 1e5 : symbol2 === "timeout" ? 3000 : +symbol2;
return proxies.filter((p) => {
const delay = delayManager.getDelay(p.name, groupName);
if (delay < 0) return false;
if (symbol === "=" && symbol2 === "error") return delay >= 1e5;
if (symbol === "=" && symbol2 === "timeout")
return delay < 1e5 && delay >= 3000;
if (symbol === "=") return delay == value;
if (symbol === "<") return delay <= value;
if (symbol === ">") return delay >= value;
return false;
});
}
const res2 = regex2.exec(filterText);
if (res2) {
const type = res2[1].toLowerCase();
return proxies.filter((p) => p.type.toLowerCase().includes(type));
}
return proxies.filter((p) => p.name.includes(filterText.trim()));
}, [proxies, groupName, filterText]);
}
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useMemo, useState } from "react";
import delayManager from "@/services/delay";
// default | delay | alphabet
export type ProxySortType = 0 | 1 | 2;
export default function useFilterSort(
proxies: ApiType.ProxyItem[],
groupName: string,
filterText: string,
sortType: ProxySortType
) {
const [refresh, setRefresh] = useState({});
useEffect(() => {
let last = 0;
delayManager.setGroupListener(groupName, () => {
// 简单节流
const now = Date.now();
if (now - last > 666) {
last = now;
setRefresh({});
}
});
return () => {
delayManager.removeGroupListener(groupName);
};
}, [groupName]);
return useMemo(() => {
const fp = filterProxies(proxies, groupName, filterText);
const sp = sortProxies(fp, groupName, sortType);
return sp;
}, [proxies, groupName, filterText, sortType, refresh]);
}
/**
* 可以通过延迟数/节点类型 过滤
*/
const regex1 = /delay([=<>])(\d+|timeout|error)/i;
const regex2 = /type=(.*)/i;
/**
* filter the proxy
* according to the regular conditions
*/
function filterProxies(
proxies: ApiType.ProxyItem[],
groupName: string,
filterText: string
) {
if (!filterText) return proxies;
const res1 = regex1.exec(filterText);
if (res1) {
const symbol = res1[1];
const symbol2 = res1[2].toLowerCase();
const value =
symbol2 === "error" ? 1e5 : symbol2 === "timeout" ? 3000 : +symbol2;
return proxies.filter((p) => {
const delay = delayManager.getDelay(p.name, groupName);
if (delay < 0) return false;
if (symbol === "=" && symbol2 === "error") return delay >= 1e5;
if (symbol === "=" && symbol2 === "timeout")
return delay < 1e5 && delay >= 3000;
if (symbol === "=") return delay == value;
if (symbol === "<") return delay <= value;
if (symbol === ">") return delay >= value;
return false;
});
}
const res2 = regex2.exec(filterText);
if (res2) {
const type = res2[1].toLowerCase();
return proxies.filter((p) => p.type.toLowerCase().includes(type));
}
return proxies.filter((p) => p.name.includes(filterText.trim()));
}
/**
* sort the proxy
*/
function sortProxies(
proxies: ApiType.ProxyItem[],
groupName: string,
sortType: ProxySortType
) {
if (!proxies) return [];
if (sortType === 0) return proxies;
const list = proxies.slice();
if (sortType === 1) {
list.sort((a, b) => {
const ad = delayManager.getDelay(a.name, groupName);
const bd = delayManager.getDelay(b.name, groupName);
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;
}
+80
View File
@@ -0,0 +1,80 @@
import { useCallback, useEffect, useState } from "react";
import { useRecoilValue } from "recoil";
import { atomCurrentProfile } from "@/services/states";
import { ProxySortType } from "./use-filter-sort";
export interface HeadState {
open?: boolean;
showType: boolean;
sortType: ProxySortType;
filterText: string;
textState: "url" | "filter" | null;
testUrl: string;
}
type HeadStateStorage = Record<string, Record<string, HeadState>>;
const HEAD_STATE_KEY = "proxy-head-state";
const DEFAULT_STATE: HeadState = {
open: false,
showType: false,
sortType: 0,
filterText: "",
textState: null,
testUrl: "",
};
export default function useHeadState(groupName: string) {
const current = useRecoilValue(atomCurrentProfile);
const [state, setState] = useState<HeadState>(DEFAULT_STATE);
useEffect(() => {
if (!current) {
setState(DEFAULT_STATE);
return;
}
try {
const data = JSON.parse(
localStorage.getItem(HEAD_STATE_KEY)!
) as HeadStateStorage;
const value = data[current][groupName] || DEFAULT_STATE;
if (value && typeof value === "object") {
setState({ ...DEFAULT_STATE, ...value });
} else {
setState(DEFAULT_STATE);
}
} catch {}
}, [current, groupName]);
const setHeadState = useCallback(
(obj: Partial<HeadState>) => {
setState((old) => {
const ret = { ...old, ...obj };
setTimeout(() => {
try {
const item = localStorage.getItem(HEAD_STATE_KEY);
let data = (item ? JSON.parse(item) : {}) as HeadStateStorage;
if (!data || typeof data !== "object") data = {};
if (!data[current]) data[current] = {};
data[current][groupName] = ret;
localStorage.setItem(HEAD_STATE_KEY, JSON.stringify(data));
} catch {}
});
return ret;
});
},
[current, groupName]
);
return [state, setHeadState] as const;
}
-38
View File
@@ -1,38 +0,0 @@
import { useMemo } from "react";
import { ApiType } from "../../services/types";
import delayManager from "../../services/delay";
// default | delay | alpha
export type ProxySortType = 0 | 1 | 2;
/**
* sort the proxy
*/
export default function useSortProxy(
proxies: ApiType.ProxyItem[],
groupName: string,
sortType: ProxySortType
) {
return useMemo(() => {
if (!proxies) return [];
if (sortType === 0) return proxies;
const list = proxies.slice();
if (sortType === 1) {
list.sort((a, b) => {
const ad = delayManager.getDelay(a.name, groupName);
const bd = delayManager.getDelay(b.name, groupName);
if (ad === -1) return 1;
if (bd === -1) return -1;
return ad - bd;
});
} else {
list.sort((a, b) => a.name.localeCompare(b.name));
}
return list;
}, [proxies, groupName, sortType]);
}
+72
View File
@@ -0,0 +1,72 @@
import { styled, Box, Typography } from "@mui/material";
const Item = styled(Box)(({ theme }) => ({
display: "flex",
padding: "6px 16px",
color: theme.palette.text.primary,
marginBottom: "6px",
}));
const COLOR = [
"primary",
"secondary",
"info",
"warning",
"error",
"success",
"text",
];
interface Props {
index: number;
value: ApiType.RuleItem;
}
const parseColor = (text: string) => {
let sum = 0;
for (let i = 0; i < text.length; i++) {
sum += text.charCodeAt(i);
}
return COLOR[sum % COLOR.length];
};
const RuleItem = (props: Props) => {
const { index, value } = props;
return (
<Item>
<Typography
color="text.secondary"
variant="body2"
sx={{ lineHeight: 2, minWidth: 30, mr: 2.25, textAlign: "center" }}
>
{index}
</Typography>
<Box>
<Typography component="h6" variant="subtitle1" color="text.primary">
{value.payload || "-"}
</Typography>
<Typography
component="span"
variant="body2"
color="text.secondary"
sx={{ mr: 3, minWidth: 120, display: "inline-block" }}
>
{value.type}
</Typography>
<Typography
component="span"
variant="body2"
color={parseColor(value.proxy)}
>
{value.proxy}
</Typography>
</Box>
</Item>
);
};
export default RuleItem;
@@ -0,0 +1,168 @@
import useSWR from "swr";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Stack,
Tooltip,
Typography,
} from "@mui/material";
import { InfoRounded } from "@mui/icons-material";
import {
changeProfileValid,
getProfiles,
getRuntimeExists,
} from "@/services/cmds";
import { ModalHandler } from "@/hooks/use-modal-handler";
import {
HANDLE_FIELDS,
DEFAULT_FIELDS,
OTHERS_FIELDS,
} from "@/utils/clash-fields";
import Notice from "@/components/base/base-notice";
interface Props {
handler: ModalHandler;
}
const fieldSorter = (a: string, b: string) => {
if (a.includes("-") === a.includes("-")) {
if (a.length === b.length) return a.localeCompare(b);
return a.length - b.length;
} else if (a.includes("-")) return 1;
else if (b.includes("-")) return -1;
return 0;
};
const otherFields = [...OTHERS_FIELDS].sort(fieldSorter);
const handleFields = [...HANDLE_FIELDS, ...DEFAULT_FIELDS].sort(fieldSorter);
const ClashFieldViewer = ({ handler }: Props) => {
const { t } = useTranslation();
const { data: profiles = {}, mutate: mutateProfile } = useSWR(
"getProfiles",
getProfiles
);
const { data: existsKeys = [], mutate: mutateExists } = useSWR(
"getRuntimeExists",
getRuntimeExists
);
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
if (handler) {
handler.current = {
open: () => setOpen(true),
close: () => setOpen(false),
};
}
useEffect(() => {
if (open) {
mutateProfile();
mutateExists();
setSelected(profiles.valid || []);
}
}, [open, profiles.valid]);
const handleChange = (item: string) => {
if (!item) return;
setSelected((old) =>
old.includes(item) ? old.filter((e) => e !== item) : [...old, item]
);
};
const handleSave = async () => {
setOpen(false);
const oldSet = new Set(profiles.valid || []);
const curSet = new Set(selected);
const joinSet = new Set(selected.concat([...oldSet]));
if (curSet.size === oldSet.size && curSet.size === joinSet.size) return;
try {
await changeProfileValid([...curSet]);
mutateProfile();
// Notice.success("Refresh clash config", 1000);
} catch (err: any) {
Notice.error(err?.message || err.toString());
}
};
return (
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>{t("Clash Field")}</DialogTitle>
<DialogContent
sx={{
pb: 0,
width: 320,
height: 300,
overflowY: "auto",
userSelect: "text",
}}
>
{otherFields.map((item) => {
const inSelect = selected.includes(item);
const inConfig = existsKeys.includes(item);
return (
<Stack key={item} mb={0.5} direction="row" alignItems="center">
<Checkbox
checked={inSelect}
size="small"
sx={{ p: 0.5 }}
onChange={() => handleChange(item)}
/>
<Typography width="100%">{item}</Typography>
{!inSelect && inConfig && <WarnIcon />}
</Stack>
);
})}
<Divider sx={{ my: 1 }}>
<Typography color="text.secondary" fontSize={14}>
Clash Verge Control Fields
</Typography>
</Divider>
{handleFields.map((item) => (
<Stack key={item} mb={0.5} direction="row" alignItems="center">
<Checkbox defaultChecked disabled size="small" sx={{ p: 0.5 }} />
<Typography>{item}</Typography>
</Stack>
))}
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={() => setOpen(false)}>
{t("Back")}
</Button>
<Button variant="contained" onClick={handleSave}>
{t("Save")}
</Button>
</DialogActions>
</Dialog>
);
};
function WarnIcon() {
return (
<Tooltip title="The field exists in the config but not enabled.">
<InfoRounded color="warning" sx={{ cursor: "pointer", opacity: 0.5 }} />
</Tooltip>
);
}
export default ClashFieldViewer;
@@ -0,0 +1,103 @@
import useSWR from "swr";
import { useEffect, useState } from "react";
import { useSetRecoilState } from "recoil";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List,
ListItem,
ListItemText,
TextField,
} from "@mui/material";
import { atomClashPort } from "@/services/states";
import { getClashConfig } from "@/services/api";
import { patchClashConfig } from "@/services/cmds";
import { ModalHandler } from "@/hooks/use-modal-handler";
import Notice from "@/components/base/base-notice";
interface Props {
handler: ModalHandler;
}
const ClashPortViewer = ({ handler }: Props) => {
const { t } = useTranslation();
const { data: config, mutate: mutateClash } = useSWR(
"getClashConfig",
getClashConfig
);
const [open, setOpen] = useState(false);
const [port, setPort] = useState(config?.["mixed-port"] ?? 9090);
const setGlobalClashPort = useSetRecoilState(atomClashPort);
if (handler) {
handler.current = {
open: () => setOpen(true),
close: () => setOpen(false),
};
}
useEffect(() => {
if (open && config?.["mixed-port"]) {
setPort(config["mixed-port"]);
}
}, [open, config?.["mixed-port"]]);
const onSave = useLockFn(async () => {
if (port < 1000) {
return Notice.error("The port should not < 1000");
}
if (port > 65536) {
return Notice.error("The port should not > 65536");
}
setOpen(false);
if (port === config?.["mixed-port"]) return;
await patchClashConfig({ "mixed-port": port });
setGlobalClashPort(port);
Notice.success("Change Clash port successfully!", 1000);
mutateClash();
});
return (
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>{t("Clash Port")}</DialogTitle>
<DialogContent sx={{ width: 300 }}>
<List>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary="Mixed Port" />
<TextField
size="small"
autoComplete="off"
sx={{ width: 135 }}
value={port}
onChange={(e) =>
setPort(+e.target.value?.replace(/\D+/, "").slice(0, 5))
}
/>
</ListItem>
</List>
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={() => setOpen(false)}>
{t("Cancel")}
</Button>
<Button onClick={onSave} variant="contained">
{t("Save")}
</Button>
</DialogActions>
</Dialog>
);
};
export default ClashPortViewer;
@@ -0,0 +1,78 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useRecoilValue } from "recoil";
import {
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from "@mui/material";
import { atomThemeMode } from "@/services/states";
import { getRuntimeYaml } from "@/services/cmds";
import "monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js";
import "monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution.js";
import "monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js";
import { editor } from "monaco-editor/esm/vs/editor/editor.api";
interface Props {
open: boolean;
onClose: () => void;
}
const ConfigViewer = (props: Props) => {
const { open, onClose } = props;
const { t } = useTranslation();
const editorRef = useRef<any>();
const instanceRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const themeMode = useRecoilValue(atomThemeMode);
useEffect(() => {
if (!open) return;
getRuntimeYaml().then((data) => {
const dom = editorRef.current;
if (!dom) return;
if (instanceRef.current) instanceRef.current.dispose();
instanceRef.current = editor.create(editorRef.current, {
value: data ?? "# Error\n",
language: "yaml",
theme: themeMode === "light" ? "vs" : "vs-dark",
minimap: { enabled: false },
readOnly: true,
});
});
return () => {
if (instanceRef.current) {
instanceRef.current.dispose();
instanceRef.current = null;
}
};
}, [open]);
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>
{t("Runtime Config")} <Chip label={t("ReadOnly")} size="small" />
</DialogTitle>
<DialogContent sx={{ width: 520, pb: 1 }}>
<div style={{ width: "100%", height: "420px" }} ref={editorRef} />
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={onClose}>
{t("Back")}
</Button>
</DialogActions>
</Dialog>
);
};
export default ConfigViewer;
@@ -0,0 +1,80 @@
import useSWR, { useSWRConfig } from "swr";
import { useState } from "react";
import { useLockFn } from "ahooks";
import { Menu, MenuItem } from "@mui/material";
import { Settings } from "@mui/icons-material";
import { changeClashCore, getVergeConfig } from "@/services/cmds";
import Notice from "@/components/base/base-notice";
const VALID_CORE = [
{ name: "Clash", core: "clash" },
{ name: "Clash Meta", core: "clash-meta" },
];
const CoreSwitch = () => {
const { mutate } = useSWRConfig();
const { data: vergeConfig } = useSWR("getVergeConfig", getVergeConfig);
const [anchorEl, setAnchorEl] = useState<any>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const { clash_core = "clash" } = vergeConfig ?? {};
const onCoreChange = useLockFn(async (core: string) => {
if (core === clash_core) return;
try {
await changeClashCore(core);
mutate("getVergeConfig");
setTimeout(() => {
mutate("getClashConfig");
mutate("getVersion");
}, 100);
setAnchorEl(null);
Notice.success(`Successfully switch to ${core}`, 1000);
} catch (err: any) {
Notice.error(err?.message || err.toString());
}
});
return (
<>
<Settings
fontSize="small"
style={{ cursor: "pointer", opacity: 0.75 }}
onClick={(event) => {
const { clientX, clientY } = event;
setPosition({ top: clientY, left: clientX });
setAnchorEl(event.currentTarget);
}}
/>
<Menu
open={!!anchorEl}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorPosition={position}
anchorReference="anchorPosition"
transitionDuration={225}
onContextMenu={(e) => {
setAnchorEl(null);
e.preventDefault();
}}
>
{VALID_CORE.map((each) => (
<MenuItem
key={each.core}
sx={{ minWidth: 125 }}
selected={each.core === clash_core}
onClick={() => onCoreChange(each.core)}
>
{each.name}
</MenuItem>
))}
</Menu>
</>
);
};
export default CoreSwitch;
@@ -1,5 +1,5 @@
import { cloneElement, isValidElement, ReactNode, useRef } from "react";
import noop from "../../utils/noop";
import noop from "@/utils/noop";
interface Props<Value> {
value?: Value;
@@ -0,0 +1,96 @@
import { useState } from "react";
import { alpha, Box, IconButton, styled } from "@mui/material";
import { DeleteRounded } from "@mui/icons-material";
import parseHotkey from "@/utils/parse-hotkey";
const KeyWrapper = styled("div")(({ theme }) => ({
position: "relative",
width: 165,
minHeight: 36,
"> input": {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 1,
opacity: 0,
},
"> input:focus + .list": {
borderColor: alpha(theme.palette.primary.main, 0.75),
},
".list": {
display: "flex",
alignItems: "center",
flexWrap: "wrap",
width: "100%",
height: "100%",
minHeight: 36,
boxSizing: "border-box",
padding: "3px 4px",
border: "1px solid",
borderRadius: 4,
borderColor: alpha(theme.palette.text.secondary, 0.15),
"&:last-child": {
marginRight: 0,
},
},
".item": {
color: theme.palette.text.primary,
border: "1px solid",
borderColor: alpha(theme.palette.text.secondary, 0.2),
borderRadius: "2px",
padding: "1px 1px",
margin: "2px 0",
marginRight: 8,
},
}));
interface Props {
value: string[];
onChange: (value: string[]) => void;
}
const HotkeyInput = (props: Props) => {
const { value, onChange } = props;
return (
<Box sx={{ display: "flex", alignItems: "center" }}>
<KeyWrapper>
<input
onKeyDown={(e) => {
const evt = e.nativeEvent;
e.preventDefault();
e.stopPropagation();
const key = parseHotkey(evt.key);
if (key === "UNIDENTIFIED") return;
const newList = [...new Set([...value, key])];
onChange(newList);
}}
/>
<div className="list">
{value.map((key) => (
<div key={key} className="item">
{key}
</div>
))}
</div>
</KeyWrapper>
<IconButton
size="small"
title="Delete"
color="inherit"
onClick={() => onChange([])}
>
<DeleteRounded fontSize="inherit" />
</IconButton>
</Box>
);
};
export default HotkeyInput;
@@ -0,0 +1,132 @@
import useSWR from "swr";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
styled,
Typography,
} from "@mui/material";
import { getVergeConfig, patchVergeConfig } from "@/services/cmds";
import { ModalHandler } from "@/hooks/use-modal-handler";
import Notice from "@/components/base/base-notice";
import HotkeyInput from "./hotkey-input";
const ItemWrapper = styled("div")`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
`;
const HOTKEY_FUNC = [
"clash_mode_rule",
"clash_mode_global",
"clash_mode_direct",
"clash_mode_script",
"toggle_system_proxy",
"enable_system_proxy",
"disable_system_proxy",
"toggle_tun_mode",
"enable_tun_mode",
"disable_tun_mode",
];
interface Props {
handler: ModalHandler;
}
const HotkeyViewer = ({ handler }: Props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
if (handler) {
handler.current = {
open: () => setOpen(true),
close: () => setOpen(false),
};
}
const { data: vergeConfig, mutate: mutateVerge } = useSWR(
"getVergeConfig",
getVergeConfig
);
const [hotkeyMap, setHotkeyMap] = useState<Record<string, string[]>>({});
useEffect(() => {
if (!open) return;
const map = {} as typeof hotkeyMap;
vergeConfig?.hotkeys?.forEach((text) => {
const [func, key] = text.split(",").map((e) => e.trim());
if (!func || !key) return;
map[func] = key
.split("+")
.map((e) => e.trim())
.map((k) => (k === "PLUS" ? "+" : k));
});
setHotkeyMap(map);
}, [vergeConfig?.hotkeys, open]);
const onSave = useLockFn(async () => {
const hotkeys = Object.entries(hotkeyMap)
.map(([func, keys]) => {
if (!func || !keys?.length) return "";
const key = keys
.map((k) => k.trim())
.filter(Boolean)
.map((k) => (k === "+" ? "PLUS" : k))
.join("+");
if (!key) return "";
return `${func},${key}`;
})
.filter(Boolean);
try {
patchVergeConfig({ hotkeys });
setOpen(false);
mutateVerge();
} catch (err: any) {
Notice.error(err.message || err.toString());
}
});
return (
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>{t("Hotkey Viewer")}</DialogTitle>
<DialogContent sx={{ width: 450, maxHeight: 330 }}>
{HOTKEY_FUNC.map((func) => (
<ItemWrapper key={func}>
<Typography>{t(func)}</Typography>
<HotkeyInput
value={hotkeyMap[func] ?? []}
onChange={(v) => setHotkeyMap((m) => ({ ...m, [func]: v }))}
/>
</ItemWrapper>
))}
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={() => setOpen(false)}>
{t("Cancel")}
</Button>
<Button onClick={onSave} variant="contained">
{t("Save")}
</Button>
</DialogActions>
</Dialog>
);
};
export default HotkeyViewer;
@@ -1,104 +1,117 @@
import useSWR, { useSWRConfig } from "swr";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
Button,
Dialog,
DialogContent,
DialogTitle,
Stack,
Typography,
} from "@mui/material";
import {
checkService,
installService,
uninstallService,
patchVergeConfig,
} from "../../services/cmds";
import Notice from "../base/base-notice";
import noop from "../../utils/noop";
interface Props {
open: boolean;
enable: boolean;
onClose: () => void;
onError?: (err: Error) => void;
}
const ServiceMode = (props: Props) => {
const { open, enable, onClose, onError = noop } = props;
const { t } = useTranslation();
const { mutate } = useSWRConfig();
const { data: status } = useSWR("checkService", checkService, {
revalidateIfStale: true,
shouldRetryOnError: false,
});
const state = status != null ? status : "pending";
const onInstall = useLockFn(async () => {
try {
await installService();
mutate("checkService");
onClose();
Notice.success("Service installed successfully");
} catch (err: any) {
mutate("checkService");
onError(err);
}
});
const onUninstall = useLockFn(async () => {
try {
if (state === "active" && enable) {
await patchVergeConfig({ enable_service_mode: false });
}
await uninstallService();
mutate("checkService");
onClose();
Notice.success("Service uninstalled successfully");
} catch (err: any) {
mutate("checkService");
onError(err);
}
});
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t("Service Mode")}</DialogTitle>
<DialogContent sx={{ width: 360, userSelect: "text" }}>
<Typography>Current State: {state}</Typography>
{(state === "unknown" || state === "uninstall") && (
<Typography>
Infomation: Please make sure the Clash Verge Service is installed
and enabled
</Typography>
)}
<Stack
direction="row"
spacing={1}
sx={{ mt: 4, justifyContent: "flex-end" }}
>
{state === "uninstall" && (
<Button variant="contained" onClick={onInstall}>
Install
</Button>
)}
{(state === "active" || state === "installed") && (
<Button variant="outlined" onClick={onUninstall}>
Uninstall
</Button>
)}
</Stack>
</DialogContent>
</Dialog>
);
};
export default ServiceMode;
import useSWR, { useSWRConfig } from "swr";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
Button,
Dialog,
DialogContent,
DialogTitle,
Stack,
Typography,
} from "@mui/material";
import {
checkService,
installService,
uninstallService,
patchVergeConfig,
} from "@/services/cmds";
import Notice from "@/components/base/base-notice";
import noop from "@/utils/noop";
interface Props {
open: boolean;
enable: boolean;
onClose: () => void;
onError?: (err: Error) => void;
}
const ServiceMode = (props: Props) => {
const { open, enable, onClose, onError = noop } = props;
const { t } = useTranslation();
const { mutate } = useSWRConfig();
const { data: status } = useSWR("checkService", checkService, {
revalidateIfStale: true,
shouldRetryOnError: false,
});
const state = status != null ? status : "pending";
const onInstall = useLockFn(async () => {
try {
await installService();
mutate("checkService");
onClose();
Notice.success("Service installed successfully");
} catch (err: any) {
mutate("checkService");
onError(err);
}
});
const onUninstall = useLockFn(async () => {
try {
if (state === "active" && enable) {
await patchVergeConfig({ enable_service_mode: false });
}
await uninstallService();
mutate("checkService");
onClose();
Notice.success("Service uninstalled successfully");
} catch (err: any) {
mutate("checkService");
onError(err);
}
});
// fix unhandle error of the service mode
const onDisable = useLockFn(async () => {
await patchVergeConfig({ enable_service_mode: false });
mutate("checkService");
onClose();
});
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t("Service Mode")}</DialogTitle>
<DialogContent sx={{ width: 360, userSelect: "text" }}>
<Typography>Current State: {state}</Typography>
{(state === "unknown" || state === "uninstall") && (
<Typography>
Infomation: Please make sure the Clash Verge Service is installed
and enabled
</Typography>
)}
<Stack
direction="row"
spacing={1}
sx={{ mt: 4, justifyContent: "flex-end" }}
>
{state === "uninstall" && enable && (
<Button variant="contained" onClick={onDisable}>
Disable Service Mode
</Button>
)}
{state === "uninstall" && (
<Button variant="contained" onClick={onInstall}>
Install
</Button>
)}
{(state === "active" || state === "installed") && (
<Button variant="outlined" onClick={onUninstall}>
Uninstall
</Button>
)}
</Stack>
</DialogContent>
</Dialog>
);
};
export default ServiceMode;
@@ -0,0 +1,204 @@
import useSWR from "swr";
import { useEffect, useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
InputAdornment,
List,
ListItem,
ListItemText,
styled,
Switch,
TextField,
Typography,
} from "@mui/material";
import {
getSystemProxy,
getVergeConfig,
patchVergeConfig,
} from "@/services/cmds";
import { ModalHandler } from "@/hooks/use-modal-handler";
import Notice from "@/components/base/base-notice";
interface Props {
handler: ModalHandler;
}
const FlexBox = styled("div")`
display: flex;
margin-top: 4px;
.label {
flex: none;
width: 80px;
}
`;
const SysproxyViewer = ({ handler }: Props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
if (handler) {
handler.current = {
open: () => setOpen(true),
close: () => setOpen(false),
};
}
const { data: vergeConfig, mutate: mutateVerge } = useSWR(
"getVergeConfig",
getVergeConfig
);
const {
enable_system_proxy: enabled,
enable_proxy_guard,
system_proxy_bypass,
proxy_guard_duration,
} = vergeConfig ?? {};
const { data: sysproxy } = useSWR(
open ? "getSystemProxy" : null,
getSystemProxy
);
const [value, setValue] = useState({
guard: enable_proxy_guard,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
});
useEffect(() => {
setValue({
guard: enable_proxy_guard,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
});
}, [vergeConfig]);
const onSave = useLockFn(async () => {
if (value.duration < 5) {
Notice.error("Proxy guard duration at least 5 seconds");
return;
}
const patch: Partial<CmdType.VergeConfig> = {};
if (value.guard !== enable_proxy_guard) {
patch.enable_proxy_guard = value.guard;
}
if (value.duration !== proxy_guard_duration) {
patch.proxy_guard_duration = value.duration;
}
if (value.bypass !== system_proxy_bypass) {
patch.system_proxy_bypass = value.bypass;
}
try {
await patchVergeConfig(patch);
mutateVerge();
setOpen(false);
} catch (err: any) {
Notice.error(err.message || err.toString());
}
});
return (
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>{t("System Proxy Setting")}</DialogTitle>
<DialogContent sx={{ width: 450, maxHeight: 300 }}>
<List>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Proxy Guard")} />
<Switch
edge="end"
disabled={!enabled}
checked={value.guard}
onChange={(_, e) => setValue((v) => ({ ...v, guard: e }))}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Guard Duration")} />
<TextField
disabled={!enabled}
size="small"
value={value.duration}
sx={{ width: 100 }}
InputProps={{
endAdornment: <InputAdornment position="end">s</InputAdornment>,
}}
onChange={(e) => {
setValue((v) => ({
...v,
duration: +e.target.value.replace(/\D/, ""),
}));
}}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px", alignItems: "start" }}>
<ListItemText
primary={t("Proxy Bypass")}
sx={{ padding: "3px 0" }}
/>
<TextField
disabled={!enabled}
size="small"
autoComplete="off"
multiline
rows={3}
sx={{ width: 280 }}
value={value.bypass}
onChange={(e) =>
setValue((v) => ({ ...v, bypass: e.target.value }))
}
/>
</ListItem>
</List>
<Box sx={{ mt: 2.5 }}>
<Typography variant="body1" sx={{ fontSize: "18px", mb: 1 }}>
{t("Current System Proxy")}
</Typography>
<FlexBox>
<Typography className="label">Enable:</Typography>
<Typography className="value">
{(!!sysproxy?.enable).toString()}
</Typography>
</FlexBox>
<FlexBox>
<Typography className="label">Server:</Typography>
<Typography className="value">{sysproxy?.server || "-"}</Typography>
</FlexBox>
<FlexBox>
<Typography className="label">Bypass:</Typography>
<Typography className="value">{sysproxy?.bypass || "-"}</Typography>
</FlexBox>
</Box>
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={() => setOpen(false)}>
{t("Cancel")}
</Button>
<Button onClick={onSave} variant="contained">
{t("Save")}
</Button>
</DialogActions>
</Dialog>
);
};
export default SysproxyViewer;
@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { Button, ButtonGroup } from "@mui/material";
type ThemeValue = CmdType.VergeConfig["theme_mode"];
interface Props {
value?: ThemeValue;
onChange?: (value: ThemeValue) => void;
}
const ThemeModeSwitch = (props: Props) => {
const { value, onChange } = props;
const { t } = useTranslation();
const modes = ["light", "dark", "system"] as const;
return (
<ButtonGroup size="small" sx={{ my: "4px" }}>
{modes.map((mode) => (
<Button
key={mode}
variant={mode === value ? "contained" : "outlined"}
onClick={() => onChange?.(mode)}
sx={{ textTransform: "capitalize" }}
>
{t(`theme.${mode}`)}
</Button>
))}
</ButtonGroup>
);
};
export default ThemeModeSwitch;
+132
View File
@@ -0,0 +1,132 @@
import { useState } from "react";
import {
Divider,
IconButton,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
CheckRounded,
CloseRounded,
DeleteRounded,
EditRounded,
OpenInNewRounded,
} from "@mui/icons-material";
interface Props {
value?: string;
onlyEdit?: boolean;
onChange: (value?: string) => void;
onOpenUrl?: (value?: string) => void;
onDelete?: () => void;
onCancel?: () => void;
}
const WebUIItem = (props: Props) => {
const {
value,
onlyEdit = false,
onChange,
onDelete,
onOpenUrl,
onCancel,
} = props;
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(value);
if (editing || onlyEdit) {
return (
<>
<Stack spacing={0.75} direction="row" mt={1} mb={1} alignItems="center">
<TextField
fullWidth
size="small"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
placeholder={`Support %host %port %secret`}
autoComplete="off"
/>
<IconButton
size="small"
title="Save"
color="inherit"
onClick={() => {
onChange(editValue);
setEditing(false);
}}
>
<CheckRounded fontSize="inherit" />
</IconButton>
<IconButton
size="small"
title="Cancel"
color="inherit"
onClick={() => {
onCancel?.();
setEditing(false);
}}
>
<CloseRounded fontSize="inherit" />
</IconButton>
</Stack>
<Divider />
</>
);
}
const html = value
?.replace("%host", "<span>%host</span>")
.replace("%port", "<span>%port</span>")
.replace("%secret", "<span>%secret</span>");
return (
<>
<Stack spacing={0.75} direction="row" alignItems="center" mt={1} mb={1}>
<Typography
component="div"
width="100%"
title={value}
color={value ? "text.primary" : "text.secondary"}
sx={({ palette }) => ({
"> span": {
color: palette.primary.main,
},
})}
dangerouslySetInnerHTML={{ __html: html || "NULL" }}
/>
<IconButton
size="small"
title="Open URL"
color="inherit"
onClick={() => onOpenUrl?.(value)}
>
<OpenInNewRounded fontSize="inherit" />
</IconButton>
<IconButton
size="small"
title="Edit"
color="inherit"
onClick={() => {
setEditing(true);
setEditValue(value);
}}
>
<EditRounded fontSize="inherit" />
</IconButton>
<IconButton
size="small"
title="Delete"
color="inherit"
onClick={onDelete}
>
<DeleteRounded fontSize="inherit" />
</IconButton>
</Stack>
<Divider />
</>
);
};
export default WebUIItem;
@@ -0,0 +1,165 @@
import useSWR from "swr";
import { useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Typography,
} from "@mui/material";
import {
getClashInfo,
getVergeConfig,
openWebUrl,
patchVergeConfig,
} from "@/services/cmds";
import { ModalHandler } from "@/hooks/use-modal-handler";
import BaseEmpty from "@/components/base/base-empty";
import WebUIItem from "./web-ui-item";
interface Props {
handler: ModalHandler;
onError: (err: Error) => void;
}
const WebUIViewer = ({ handler, onError }: Props) => {
const { t } = useTranslation();
const { data: vergeConfig, mutate: mutateVerge } = useSWR(
"getVergeConfig",
getVergeConfig
);
const webUIList = vergeConfig?.web_ui_list || [];
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
if (handler) {
handler.current = {
open: () => setOpen(true),
close: () => setOpen(false),
};
}
const handleAdd = useLockFn(async (value: string) => {
const newList = [value, ...webUIList];
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false);
await patchVergeConfig({ web_ui_list: newList });
await mutateVerge();
});
const handleChange = useLockFn(async (index: number, value?: string) => {
const newList = [...webUIList];
newList[index] = value ?? "";
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false);
await patchVergeConfig({ web_ui_list: newList });
await mutateVerge();
});
const handleDelete = useLockFn(async (index: number) => {
const newList = [...webUIList];
newList.splice(index, 1);
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false);
await patchVergeConfig({ web_ui_list: newList });
await mutateVerge();
});
const { data: clashInfo } = useSWR("getClashInfo", getClashInfo);
const handleOpenUrl = useLockFn(async (value?: string) => {
if (!value) return;
try {
let url = value.trim().replaceAll("%host", "127.0.0.1");
if (url.includes("%port") || url.includes("%secret")) {
if (!clashInfo) throw new Error("failed to get clash info");
if (!clashInfo.server?.includes(":")) {
throw new Error(
`failed to parse server with status ${clashInfo.status}`
);
}
const port = clashInfo.server
.slice(clashInfo.server.indexOf(":") + 1)
.trim();
url = url.replaceAll("%port", port || "9090");
url = url.replaceAll("%secret", clashInfo.secret || "");
}
await openWebUrl(url);
} catch (e: any) {
onError(e);
}
});
return (
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle display="flex" justifyContent="space-between">
{t("Web UI")}
<Button
variant="contained"
size="small"
disabled={editing}
onClick={() => setEditing(true)}
>
{t("New")}
</Button>
</DialogTitle>
<DialogContent
sx={{
width: 450,
height: 300,
pb: 1,
overflowY: "auto",
userSelect: "text",
}}
>
{editing && (
<WebUIItem
value=""
onlyEdit
onChange={(v) => {
setEditing(false);
handleAdd(v || "");
}}
onCancel={() => setEditing(false)}
/>
)}
{!editing && webUIList.length === 0 && (
<BaseEmpty
text="Empty List"
extra={
<Typography mt={2} sx={{ fontSize: "12px" }}>
Replace host, port, secret with "%host" "%port" "%secret"
</Typography>
}
/>
)}
{webUIList.map((item, index) => (
<WebUIItem
key={index}
value={item}
onChange={(v) => handleChange(index, v)}
onDelete={() => handleDelete(index)}
onOpenUrl={handleOpenUrl}
/>
))}
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={() => setOpen(false)}>
{t("Back")}
</Button>
</DialogActions>
</Dialog>
);
};
export default WebUIViewer;
-51
View File
@@ -1,51 +0,0 @@
import { styled, Switch } from "@mui/material";
// From: https://mui.com/components/switches/
const PaletteSwitch = styled(Switch)(({ theme }) => ({
width: 62,
height: 34,
padding: 7,
"& .MuiSwitch-switchBase": {
margin: 1,
padding: 0,
transform: "translateX(6px)",
"&.Mui-checked": {
color: "#fff",
transform: "translateX(22px)",
"& .MuiSwitch-thumb:before": {
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 20 20"><path fill="${encodeURIComponent(
"#fff"
)}" d="M4.2 2.5l-.7 1.8-1.8.7 1.8.7.7 1.8.6-1.8L6.7 5l-1.9-.7-.6-1.8zm15 8.3a6.7 6.7 0 11-6.6-6.6 5.8 5.8 0 006.6 6.6z"/></svg>')`,
},
"& + .MuiSwitch-track": {
opacity: 1,
backgroundColor: theme.palette.mode === "dark" ? "#8796A5" : "#aab4be",
},
},
},
"& .MuiSwitch-thumb": {
backgroundColor: theme.palette.mode === "dark" ? "#003892" : "#001e3c",
width: 32,
height: 32,
"&:before": {
content: "''",
position: "absolute",
width: "100%",
height: "100%",
left: 0,
top: 0,
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 20 20"><path fill="${encodeURIComponent(
"#fff"
)}" d="M9.305 1.667V3.75h1.389V1.667h-1.39zm-4.707 1.95l-.982.982L5.09 6.072l.982-.982-1.473-1.473zm10.802 0L13.927 5.09l.982.982 1.473-1.473-.982-.982zM10 5.139a4.872 4.872 0 00-4.862 4.86A4.872 4.872 0 0010 14.862 4.872 4.872 0 0014.86 10 4.872 4.872 0 0010 5.139zm0 1.389A3.462 3.462 0 0113.471 10a3.462 3.462 0 01-3.473 3.472A3.462 3.462 0 016.527 10 3.462 3.462 0 0110 6.528zM1.665 9.305v1.39h2.083v-1.39H1.666zm14.583 0v1.39h2.084v-1.39h-2.084zM5.09 13.928L3.616 15.4l.982.982 1.473-1.473-.982-.982zm9.82 0l-.982.982 1.473 1.473.982-.982-1.473-1.473zM9.305 16.25v2.083h1.389V16.25h-1.39z"/></svg>')`,
},
},
"& .MuiSwitch-track": {
opacity: 1,
backgroundColor: theme.palette.mode === "dark" ? "#8796A5" : "#aab4be",
borderRadius: 20 / 2,
},
}));
export default PaletteSwitch;
+65 -51
View File
@@ -1,21 +1,23 @@
import useSWR, { useSWRConfig } from "swr";
import { useSetRecoilState } from "recoil";
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import {
ListItemText,
TextField,
Switch,
Select,
MenuItem,
Typography,
IconButton,
} from "@mui/material";
import { ApiType } from "../../services/types";
import { atomClashPort } from "../../services/states";
import { patchClashConfig } from "../../services/cmds";
import { ArrowForward } from "@mui/icons-material";
import { patchClashConfig } from "@/services/cmds";
import { SettingList, SettingItem } from "./setting";
import { getClashConfig, getVersion, updateConfigs } from "../../services/api";
import Notice from "../base/base-notice";
import GuardState from "./guard-state";
import { getClashConfig, getVersion, updateConfigs } from "@/services/api";
import useModalHandler from "@/hooks/use-modal-handler";
import GuardState from "./mods/guard-state";
import CoreSwitch from "./mods/core-switch";
import WebUIViewer from "./mods/web-ui-viewer";
import ClashFieldViewer from "./mods/clash-field-viewer";
import ClashPortViewer from "./mods/clash-port-viewer";
interface Props {
onError: (err: Error) => void;
@@ -23,8 +25,11 @@ interface Props {
const SettingClash = ({ onError }: Props) => {
const { t } = useTranslation();
const { mutate } = useSWRConfig();
const { data: clashConfig } = useSWR("getClashConfig", getClashConfig);
const { data: clashConfig, mutate: mutateClash } = useSWR(
"getClashConfig",
getClashConfig
);
const { data: versionData } = useSWR("getVersion", getVersion);
const {
@@ -34,32 +39,19 @@ const SettingClash = ({ onError }: Props) => {
"mixed-port": mixedPort,
} = clashConfig ?? {};
const setGlobalClashPort = useSetRecoilState(atomClashPort);
const webUIHandler = useModalHandler();
const fieldHandler = useModalHandler();
const portHandler = useModalHandler();
const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeData = (patch: Partial<ApiType.ConfigData>) => {
mutate("getClashConfig", { ...clashConfig, ...patch }, false);
mutateClash((old) => ({ ...(old! || {}), ...patch }), false);
};
const onUpdateData = async (patch: Partial<ApiType.ConfigData>) => {
await updateConfigs(patch);
await patchClashConfig(patch);
};
const onUpdatePort = async (port: number) => {
if (port < 1000) {
throw new Error("The port should not < 1000");
}
if (port > 65536) {
throw new Error("The port should not > 65536");
}
await patchClashConfig({ "mixed-port": port });
setGlobalClashPort(port);
Notice.success("Change Clash port successfully!");
// update the config
mutate("getClashConfig");
};
// get clash core version
const clashVer = versionData?.premium
? `${versionData.version} Premium`
@@ -67,8 +59,11 @@ const SettingClash = ({ onError }: Props) => {
return (
<SettingList title={t("Clash Setting")}>
<SettingItem>
<ListItemText primary={t("Allow Lan")} />
<WebUIViewer handler={webUIHandler} onError={onError} />
<ClashFieldViewer handler={fieldHandler} />
<ClashPortViewer handler={portHandler} />
<SettingItem label={t("Allow Lan")}>
<GuardState
value={allowLan ?? false}
valueProps="checked"
@@ -81,8 +76,7 @@ const SettingClash = ({ onError }: Props) => {
</GuardState>
</SettingItem>
<SettingItem>
<ListItemText primary={t("IPv6")} />
<SettingItem label={t("IPv6")}>
<GuardState
value={ipv6 ?? false}
valueProps="checked"
@@ -95,42 +89,62 @@ const SettingClash = ({ onError }: Props) => {
</GuardState>
</SettingItem>
<SettingItem>
<ListItemText primary={t("Log Level")} />
<SettingItem label={t("Log Level")}>
<GuardState
value={logLevel ?? "info"}
// clash premium 2022.08.26 值为warn
value={logLevel === "warn" ? "warning" : logLevel ?? "info"}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ "log-level": e })}
onGuard={(e) => onUpdateData({ "log-level": e })}
>
<Select size="small" sx={{ width: 120 }}>
<Select size="small" sx={{ width: 100, "> div": { py: "7.5px" } }}>
<MenuItem value="debug">Debug</MenuItem>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="warning">Warning</MenuItem>
<MenuItem value="warning">Warn</MenuItem>
<MenuItem value="error">Error</MenuItem>
<MenuItem value="silent">Silent</MenuItem>
</Select>
</GuardState>
</SettingItem>
<SettingItem>
<ListItemText primary={t("Mixed Port")} />
<GuardState
<SettingItem label={t("Mixed Port")}>
<TextField
autoComplete="off"
size="small"
value={mixedPort ?? 0}
onCatch={onError}
onFormat={(e: any) => +e.target.value?.replace(/\D+/, "")}
onChange={(e) => onChangeData({ "mixed-port": e })}
onGuard={onUpdatePort}
waitTime={1000}
>
<TextField autoComplete="off" size="small" sx={{ width: 120 }} />
</GuardState>
sx={{ width: 100, input: { py: "7.5px" } }}
onClick={(e) => {
portHandler.current.open();
(e.target as any).blur();
}}
/>
</SettingItem>
<SettingItem>
<ListItemText primary={t("Clash core")} />
<Typography sx={{ py: 1 }}>{clashVer}</Typography>
<SettingItem label={t("Web UI")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => webUIHandler.current.open()}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem label={t("Clash Field")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => fieldHandler.current.open()}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem label={t("Clash Core")} extra={<CoreSwitch />}>
<Typography sx={{ py: "7px" }}>{clashVer}</Typography>
</SettingItem>
</SettingList>
);

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