├── .eslintignore
├── .eslintrc.yml
├── .github
├── dependabot.yml
└── workflows
│ └── push.yml
├── .gitignore
├── CHANGELOG.md
├── Dockerfile
├── LICENSE
├── README.md
├── assets
├── CNAME
├── _headers
├── apple-touch-icon-precomposed.png
├── yacd-128.png
├── yacd-64.png
└── yacd.ico
├── docker-entrypoint.sh
├── docker
└── nginx-default.conf
├── index.html
├── package.json
├── patches
└── country-flag-emoji-polyfill@0.1.4.patch
├── pnpm-lock.yaml
├── postcss.config.js
├── src
├── api
│ ├── configs.ts
│ ├── connections.ts
│ ├── fetch.ts
│ ├── logs.ts
│ ├── mock.ts
│ ├── proxies.ts
│ ├── rule-provider.ts
│ ├── rules.ts
│ ├── traffic.ts
│ └── version.ts
├── app.tsx
├── components
│ ├── Button.module.scss
│ ├── Button.tsx
│ ├── Collapsible.tsx
│ ├── CollapsibleSectionHeader.module.scss
│ ├── CollapsibleSectionHeader.tsx
│ ├── Config.module.scss
│ ├── Config.tsx
│ ├── ConnectionTable.module.scss
│ ├── ConnectionTable.tsx
│ ├── Connections.css
│ ├── Connections.module.scss
│ ├── Connections.tsx
│ ├── ContentHeader.module.scss
│ ├── ContentHeader.tsx
│ ├── Field.module.scss
│ ├── Field.tsx
│ ├── Home.module.scss
│ ├── Home.tsx
│ ├── Icon.tsx
│ ├── Input.module.scss
│ ├── Input.tsx
│ ├── Loading.module.scss
│ ├── Loading.tsx
│ ├── Loading2.module.scss
│ ├── Loading2.tsx
│ ├── LogSearch.ts
│ ├── Logs.module.scss
│ ├── Logs.tsx
│ ├── Modal.module.scss
│ ├── Modal.tsx
│ ├── ModalCloseAllConnections.module.scss
│ ├── ModalCloseAllConnections.tsx
│ ├── Root.module.scss
│ ├── Root.scss
│ ├── Root.tsx
│ ├── Rule.module.scss
│ ├── Rule.tsx
│ ├── Rules.module.scss
│ ├── Rules.tsx
│ ├── Search.module.scss
│ ├── Search.tsx
│ ├── Selection.module.scss
│ ├── Selection.tsx
│ ├── SideBar.module.scss
│ ├── SideBar.tsx
│ ├── StateProvider.tsx
│ ├── SvgYacd.module.scss
│ ├── SvgYacd.tsx
│ ├── ToggleSwitch.module.scss
│ ├── ToggleSwitch.tsx
│ ├── TrafficChart.tsx
│ ├── TrafficChartSample.tsx
│ ├── TrafficNow.module.scss
│ ├── TrafficNow.tsx
│ ├── about
│ │ ├── About.module.scss
│ │ └── About.tsx
│ ├── backend
│ │ ├── Backend.tsx
│ │ ├── BackendForm.module.scss
│ │ ├── BackendForm.tsx
│ │ ├── BackendList.module.scss
│ │ └── BackendList.tsx
│ ├── conns
│ │ └── ConnCtx.tsx
│ ├── error
│ │ ├── BackendErrorFallback.tsx
│ │ ├── ErrorBoundaryFallback.module.scss
│ │ ├── ErrorBoundaryFallback.tsx
│ │ ├── ErrorFallback.tsx
│ │ ├── ErrorFallbackLayout.module.scss
│ │ └── ErrorFallbackLayout.tsx
│ ├── fn
│ │ ├── AppConfigSideEffect.tsx
│ │ └── BackendBeacon.tsx
│ ├── form
│ │ ├── Toggle.module.scss
│ │ └── Toggle.tsx
│ ├── icon
│ │ └── GitHubIcon.tsx
│ ├── proxies
│ │ ├── ClosePrevConns.tsx
│ │ ├── Proxies.module.scss
│ │ ├── Proxies.tsx
│ │ ├── Proxy.module.scss
│ │ ├── Proxy.tsx
│ │ ├── ProxyGroup.module.scss
│ │ ├── ProxyGroup.tsx
│ │ ├── ProxyLatency.module.scss
│ │ ├── ProxyLatency.tsx
│ │ ├── ProxyList.module.scss
│ │ ├── ProxyList.tsx
│ │ ├── ProxyPageFab.tsx
│ │ ├── ProxyProvider.module.scss
│ │ ├── ProxyProvider.tsx
│ │ ├── ProxyProviderList.tsx
│ │ ├── Settings.module.scss
│ │ ├── Settings.tsx
│ │ ├── hooks.tsx
│ │ ├── index.tsx
│ │ └── proxies.hooks.tsx
│ ├── rules
│ │ ├── RuleProviderItem.module.scss
│ │ ├── RuleProviderItem.tsx
│ │ ├── RulesPageFab.tsx
│ │ └── rules.hooks.tsx
│ ├── shared
│ │ ├── BaseModal.module.scss
│ │ ├── BaseModal.tsx
│ │ ├── Basic.module.scss
│ │ ├── Basic.tsx
│ │ ├── Fab.module.scss
│ │ ├── Fab.tsx
│ │ ├── Head.tsx
│ │ ├── RotateIcon.module.scss
│ │ ├── RotateIcon.tsx
│ │ ├── Select.module.scss
│ │ ├── Select.tsx
│ │ ├── Styled.module.scss
│ │ ├── Styled.tsx
│ │ ├── TextFilter.module.scss
│ │ ├── TextFilter.tsx
│ │ ├── ThemeSwitcher.module.scss
│ │ ├── ThemeSwitcher.tsx
│ │ ├── ZapAnimated.module.scss
│ │ ├── ZapAnimated.tsx
│ │ └── rtf.css
│ ├── style
│ │ └── StyleGuide.tsx
│ └── svg
│ │ └── Equalizer.tsx
├── custom.d.ts
├── hooks
│ ├── basic.ts
│ ├── useLineChart.ts
│ ├── useRemainingViewPortHeight.ts
│ └── useTextInput.ts
├── i18n
│ ├── en.ts
│ └── zh.ts
├── misc
│ ├── chart-lib.ts
│ ├── chart.ts
│ ├── constants.ts
│ ├── createResource.ts
│ ├── errors.ts
│ ├── i18n.ts
│ ├── keycode.ts
│ ├── motion.ts
│ ├── pretty-bytes.ts
│ ├── query.ts
│ ├── request-helper.ts
│ ├── shallowEqual.ts
│ ├── storage.ts
│ └── utils.ts
├── store
│ ├── app.ts
│ ├── configs.ts
│ ├── index.ts
│ ├── logs.ts
│ ├── proxies.tsx
│ ├── rules.ts
│ └── types.ts
├── sw.ts
├── swRegistration.ts
└── types.ts
├── tailwind.config.js
├── tsconfig.json
└── vite.config.ts
/.eslintignore:
--------------------------------------------------------------------------------
1 | webpack.config.js
2 | server.js
3 |
--------------------------------------------------------------------------------
/.eslintrc.yml:
--------------------------------------------------------------------------------
1 | ---
2 | parser: '@typescript-eslint/parser'
3 | parserOptions:
4 | project: tsconfig.json
5 | sourceType: module
6 |
7 | settings:
8 | react:
9 | version: detect
10 |
11 | plugins:
12 | - simple-import-sort
13 | - unused-imports
14 | - jsx-a11y
15 |
16 | extends:
17 | # - react-app
18 | - 'plugin:@typescript-eslint/recommended'
19 | - 'plugin:jsx-a11y/recommended'
20 | - 'plugin:react/recommended'
21 | - 'plugin:react-hooks/recommended'
22 | - prettier
23 |
24 | env:
25 | node: true
26 |
27 | globals:
28 | __DEV__: true
29 |
30 | # more rules here https://github.com/facebook/create-react-app/blob/main/packages/eslint-config-react-app/index.js
31 | rules:
32 | '@typescript-eslint/no-empty-interface': 'off'
33 | '@typescript-eslint/interface-name-prefix': 'off'
34 | '@typescript-eslint/explicit-function-return-type': 'off'
35 | '@typescript-eslint/no-explicit-any': 'off'
36 | '@typescript-eslint/camelcase': 'off'
37 | 'no-use-before-define': 'off'
38 | # '@typescript-eslint/no-unused-vars':
39 | # - 'error'
40 | # - argsIgnorePattern: '^_'
41 | ## disable '@typescript-eslint/no-unused-vars' and use 'unused-imports' plugin/rules instead
42 | '@typescript-eslint/no-unused-vars': 'off'
43 | 'unused-imports/no-unused-imports': 'error'
44 | 'unused-imports/no-unused-vars':
45 | - 'warn'
46 | - argsIgnorePattern: '^_'
47 | '@typescript-eslint/no-use-before-define':
48 | - error
49 | - functions: false
50 | # disable this temporarily since we have a lot of JS files
51 | # and typescript-eslint runs against JS files too
52 | '@typescript-eslint/explicit-module-boundary-types': off
53 | '@typescript-eslint/ban-ts-comment': 'off'
54 | '@typescript-eslint/ban-ts-ignore': 'off'
55 | react-hooks/rules-of-hooks: error
56 | react-hooks/exhaustive-deps:
57 | - warn
58 | - additionalHooks: useRecoilCallback
59 | simple-import-sort/imports: error
60 | 'react/prop-types': warn
61 | # quotes: ["error", "single"]
62 | # strict: ["error", "never"]
63 | # no-console: "warn"
64 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | # Maintain dependencies for GitHub Actions
4 | - package-ecosystem: 'github-actions'
5 | directory: '/'
6 | schedule:
7 | interval: 'daily'
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.
2 | .*
3 | !.eslintrc.yml
4 | *.tar.gz
5 | *.tar.xz
6 | *.log
7 | !.github
8 |
9 | node_modules/
10 | public/
11 | experimental/
12 | deploy_ghpages.sh
13 | tags
14 | .eslintcache
15 | __test_*.sh
16 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG COMMIT_SHA=""
2 |
3 | FROM --platform=$BUILDPLATFORM node:alpine AS builder
4 | WORKDIR /app
5 |
6 | RUN npm i -g pnpm
7 | COPY pnpm-lock.yaml package.json ./
8 | COPY ./patches/ ./patches/
9 | RUN pnpm i
10 |
11 | COPY . .
12 | RUN pnpm build \
13 | # remove source maps - people like small image
14 | && rm public/*.map || true
15 |
16 | FROM --platform=$TARGETPLATFORM alpine
17 | # the brotli module is only in the alpine *edge* repo
18 | # https://pkgs.alpinelinux.org/package/edge/main/x86/nginx-mod-http-brotli
19 | RUN apk add \
20 | nginx \
21 | nginx-mod-http-brotli \
22 | --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community
23 | COPY docker/nginx-default.conf /etc/nginx/http.d/default.conf
24 | RUN rm -rf /usr/share/nginx/html/*
25 | COPY --from=builder /app/public /usr/share/nginx/html
26 | ENV YACD_DEFAULT_BACKEND "http://127.0.0.1:9090"
27 | ADD docker-entrypoint.sh /
28 | CMD ["/docker-entrypoint.sh"]
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Haishan
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | > Yet Another [Clash](https://github.com/Dreamacro/clash) [Dashboard](https://github.com/Dreamacro/clash-dashboard)
6 |
7 | ## Usage
8 |
9 | The site [http://yacd.haishan.me](http://yacd.haishan.me) is served with HTTP not HTTPS is because many browsers block requests to HTTP resources from a HTTPS website. If you think it's not safe, you could just download the [zip of the gh-pages](https://github.com/haishanh/yacd/archive/gh-pages.zip), unzip and serve those static files with a web server(like Nginx).
10 |
11 | **Docker image**
12 |
13 | - Docker Hub [`haishanh/yacd`](https://hub.docker.com/r/haishanh/yacd)
14 | - GitHub Container Registry [`ghcr.io/haishanh/yacd`](https://github.com/haishanh/yacd/pkgs/container/yacd)
15 |
16 | ```sh
17 | docker run -p 1234:80 -d --name yacd --rm ghcr.io/haishanh/yacd:master
18 |
19 | # and then open http://localhost:1234 in your browser
20 | ```
21 |
22 | **Supported URL query params**
23 |
24 | | Param | Description |
25 | | -------- | ---------------------------------------------------------------------------------- |
26 | | hostname | Hostname of the clash backend API (usually the host part of `external-controller`) |
27 | | port | Port of the clash backend API (usually the port part of `external-controller`) |
28 | | secret | Clash API secret (`secret` in your config.yaml) |
29 | | theme | UI color scheme (dark, light, auto) |
30 |
31 | ## Development
32 |
33 | ```sh
34 | # install dependencies
35 | # you may install pnpm with `npm i -g pnpm`
36 | pnpm i
37 |
38 | # start the dev server
39 | # then go to the url printed on the screen
40 | pnpm start
41 |
42 |
43 | # build optimized assets
44 | # ready to deploy assets will be in the directory `public`
45 | pnpm build
46 | ```
47 |
--------------------------------------------------------------------------------
/assets/CNAME:
--------------------------------------------------------------------------------
1 | yacd.haishan.me
2 |
--------------------------------------------------------------------------------
/assets/_headers:
--------------------------------------------------------------------------------
1 | # for netlify hosting
2 | # https://docs.netlify.com/routing/headers/#syntax-for-the-headers-file
3 |
4 | /*
5 | X-Frame-Options: DENY
6 | X-XSS-Protection: 1; mode=block
7 | X-Content-Type-Options: nosniff
8 | Referrer-Policy: same-origin
9 | /*.css
10 | Cache-Control: public, max-age=31536000, immutable
11 | /*.js
12 | Cache-Control: public, max-age=31536000, immutable
13 |
--------------------------------------------------------------------------------
/assets/apple-touch-icon-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/haishanh/yacd/d37a1ffa992d45560fcdfb008a2a6a5b76062654/assets/apple-touch-icon-precomposed.png
--------------------------------------------------------------------------------
/assets/yacd-128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/haishanh/yacd/d37a1ffa992d45560fcdfb008a2a6a5b76062654/assets/yacd-128.png
--------------------------------------------------------------------------------
/assets/yacd-64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/haishanh/yacd/d37a1ffa992d45560fcdfb008a2a6a5b76062654/assets/yacd-64.png
--------------------------------------------------------------------------------
/assets/yacd.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/haishanh/yacd/d37a1ffa992d45560fcdfb008a2a6a5b76062654/assets/yacd.ico
--------------------------------------------------------------------------------
/docker-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | sed -i "s|http://127.0.0.1:9090|$YACD_DEFAULT_BACKEND|" /usr/share/nginx/html/index.html
3 | exec nginx -g "daemon off;"
4 |
--------------------------------------------------------------------------------
/docker/nginx-default.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name localhost;
4 | # access_log /var/log/nginx/host.access.log main;
5 | gzip on;
6 | gzip_vary on;
7 | gzip_comp_level 4;
8 | gzip_min_length 256;
9 | gzip_types application/atom+xml application/javascript application/json application/ld+json application/manifest+json application/rss+xml application/vnd.geo+json application/vnd.ms-fontobject application/x-font-ttf application/x-web-app-manifest+json application/xhtml+xml application/xml font/opentype image/bmp image/svg+xml image/x-icon text/cache-manifest text/css text/plain text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy;
10 |
11 | brotli on;
12 | brotli_comp_level 6;
13 | brotli_static on;
14 | brotli_types application/atom+xml application/javascript application/json application/rss+xml
15 | application/vnd.ms-fontobject application/x-font-opentype application/x-font-truetype
16 | application/x-font-ttf application/x-javascript application/xhtml+xml application/xml
17 | font/eot font/opentype font/otf font/truetype image/svg+xml image/vnd.microsoft.icon
18 | image/x-icon image/x-win-bitmap text/css text/javascript text/plain text/xml;
19 |
20 | location / {
21 | root /usr/share/nginx/html;
22 | index index.html index.htm;
23 | }
24 |
25 | location ~ assets\/.*\.(?:css|js|woff2?|svg|gif|map)$ {
26 | root /usr/share/nginx/html;
27 | try_files $uri $uri/ /index.html;
28 | add_header Cache-Control "public, max-age=31536000, immutable";
29 | # access_log off;
30 | }
31 |
32 | #error_page 404 /404.html;
33 |
34 | # redirect server error pages to the static page /50x.html
35 | #
36 | error_page 500 502 503 504 /50x.html;
37 | location = /50x.html {
38 | root /usr/share/nginx/html;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | yacd
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "yacd",
3 | "version": "0.3.8",
4 | "description": "Yet another Clash dashboard",
5 | "type": "module",
6 | "prettier": {
7 | "printWidth": 100,
8 | "singleQuote": true
9 | },
10 | "scripts": {
11 | "lint": "eslint --fix --cache src",
12 | "dev": "vite",
13 | "start": "vite",
14 | "build": "vite build",
15 | "serve": "vite preview",
16 | "pretty": "prettier --log-level warn --write 'src/**/*.{js,scss,ts,tsx,md}'",
17 | "fmt": "pnpm lint && pnpm pretty"
18 | },
19 | "browserslist": [
20 | ">0.25%",
21 | "not ie 11",
22 | "not op_mini all"
23 | ],
24 | "keywords": [
25 | "react",
26 | "clash"
27 | ],
28 | "author": "Haishan (https://haishan.me)",
29 | "private": true,
30 | "license": "MIT",
31 | "dependencies": {
32 | "@fontsource/roboto-mono": "5.0.8",
33 | "@radix-ui/react-menubar": "^1.0.4",
34 | "@reach/tooltip": "0.18.0",
35 | "@reach/visually-hidden": "0.18.0",
36 | "@tanstack/react-query": "4.35.3",
37 | "@tanstack/react-table": "^8.10.3",
38 | "chart.js": "4.4.0",
39 | "clsx": "^2.0.0",
40 | "country-flag-emoji-polyfill": "0.1.4",
41 | "date-fns": "2.30.0",
42 | "framer-motion": "10.16.4",
43 | "history": "5.3.0",
44 | "i18next": "23.5.1",
45 | "i18next-browser-languagedetector": "7.1.0",
46 | "immer": "10.0.2",
47 | "invariant": "^2.2.4",
48 | "is-network-error": "1.0.0",
49 | "jotai": "^2.4.3",
50 | "lodash-es": "^4.17.21",
51 | "modern-normalize": "2.0.0",
52 | "react": "18.2.0",
53 | "react-dom": "18.2.0",
54 | "react-error-boundary": "4.0.11",
55 | "react-feather": "^2.0.10",
56 | "react-i18next": "13.2.2",
57 | "react-icons": "4.11.0",
58 | "react-modal": "3.16.1",
59 | "react-router": "6.16.0",
60 | "react-router-dom": "6.16.0",
61 | "react-tabs": "6.0.2",
62 | "react-tiny-fab": "4.0.4",
63 | "react-window": "^1.8.9",
64 | "reselect": "4.1.8",
65 | "sonner": "^1.0.3",
66 | "tslib": "2.6.2",
67 | "use-asset": "1.0.4",
68 | "workbox-core": "7.0.0",
69 | "workbox-expiration": "7.0.0",
70 | "workbox-precaching": "7.0.0",
71 | "workbox-routing": "7.0.0",
72 | "workbox-strategies": "7.0.0"
73 | },
74 | "devDependencies": {
75 | "@fontsource/inter": "5.0.8",
76 | "@types/invariant": "2.2.35",
77 | "@types/lodash-es": "4.17.9",
78 | "@types/react": "18.2.23",
79 | "@types/react-dom": "18.2.8",
80 | "@types/react-modal": "3.16.1",
81 | "@types/react-window": "1.8.6",
82 | "@typescript-eslint/eslint-plugin": "6.7.3",
83 | "@typescript-eslint/parser": "6.7.3",
84 | "@vitejs/plugin-react": "4.1.0",
85 | "autoprefixer": "10.4.16",
86 | "eslint": "8.50.0",
87 | "eslint-config-airbnb-base": "15.0.0",
88 | "eslint-config-prettier": "9.0.0",
89 | "eslint-plugin-flowtype": "8.0.3",
90 | "eslint-plugin-import": "2.28.1",
91 | "eslint-plugin-jsx-a11y": "6.7.1",
92 | "eslint-plugin-react": "7.33.2",
93 | "eslint-plugin-react-hooks": "4.6.0",
94 | "eslint-plugin-simple-import-sort": "^10.0.0",
95 | "eslint-plugin-unused-imports": "3.0.0",
96 | "postcss": "8.4.31",
97 | "postcss-import": "15.1.0",
98 | "postcss-simple-vars": "^7.0.1",
99 | "prettier": "3.0.3",
100 | "resize-observer-polyfill": "^1.5.1",
101 | "sass": "1.68.0",
102 | "tailwindcss": "3.3.3",
103 | "typescript": "5.2.2",
104 | "vite": "4.4.9",
105 | "vite-plugin-pwa": "0.16.5"
106 | },
107 | "pnpm": {
108 | "patchedDependencies": {
109 | "country-flag-emoji-polyfill@0.1.4": "patches/country-flag-emoji-polyfill@0.1.4.patch"
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/patches/country-flag-emoji-polyfill@0.1.4.patch:
--------------------------------------------------------------------------------
1 | diff --git a/package.json b/package.json
2 | index f0404bd2df0103b7c92aceec1eb502e0d3a43fa1..f8177258cabef73a24994fa9463ea3be4b34b4ac 100644
3 | --- a/package.json
4 | +++ b/package.json
5 | @@ -8,12 +8,12 @@
6 | "type": "module",
7 | "source": "src/index.ts",
8 | "exports": {
9 | - "require": "./dist/index.cjs",
10 | - "default": "./dist/index.modern.js"
11 | + ".": {
12 | + "import": "./dist/index.modern.js",
13 | + "require": "./dist/index.cjs"
14 | + },
15 | + "./TwemojiCountryFlags.woff2": "./dist/TwemojiCountryFlags.woff2"
16 | },
17 | - "main": "./dist/index.cjs",
18 | - "module": "./dist/index.module.js",
19 | - "unpkg": "./dist/index.umd.js",
20 | "types": "./dist/index.d.ts",
21 | "files": [
22 | "dist"
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | // '--breakpoint-not-small': 'screen and (min-width: 30em)',
2 | // '--breakpoint-medium': 'screen and (min-width: 30em) and (max-width: 60em)',
3 | // '--breakpoint-large': 'screen and (min-width: 60em)',
4 | export default {
5 | plugins: {
6 | 'postcss-import': {},
7 | 'postcss-simple-vars': {},
8 | tailwindcss: {},
9 | autoprefixer: {},
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/src/api/configs.ts:
--------------------------------------------------------------------------------
1 | import { getURLAndInit } from 'src/misc/request-helper';
2 | import { ClashGeneralConfig } from 'src/store/types';
3 | import { ClashAPIConfig } from 'src/types';
4 |
5 | import { handleFetchError, query, QueryCtx, req } from './fetch';
6 |
7 | const endpoint = '/configs';
8 |
9 | export async function fetchConfigs2(ctx: QueryCtx) {
10 | const json = await query(ctx);
11 | if (!json) {
12 | throw new Error('TODO');
13 | }
14 | return json;
15 | }
16 |
17 | export function updateConfigs(apiConfig: ClashAPIConfig) {
18 | return async (o: Partial) => {
19 | const { url, init } = getURLAndInit(apiConfig);
20 | const body = JSON.stringify(configsPatchWorkaround(o));
21 | return await fetch(url + endpoint, { ...init, body, method: 'PATCH' });
22 | };
23 | }
24 |
25 | export async function fetchConfigs(apiConfig: ClashAPIConfig) {
26 | const { url, init } = getURLAndInit(apiConfig);
27 | try {
28 | return await req(url + endpoint, init);
29 | } catch (err) {
30 | handleFetchError(err, { endpoint, apiConfig });
31 | }
32 | }
33 |
34 | // TODO support PUT /configs
35 | // req body
36 | // { Path: string }
37 |
38 | type ClashConfigPartial = Partial;
39 | function configsPatchWorkaround(o: ClashConfigPartial) {
40 | // backward compatibility for older clash using `socket-port`
41 | if ('socks-port' in o) {
42 | o['socket-port'] = o['socks-port'];
43 | }
44 | return o;
45 | }
46 |
--------------------------------------------------------------------------------
/src/api/connections.ts:
--------------------------------------------------------------------------------
1 | import { ClashAPIConfig } from 'src/types';
2 |
3 | import { buildWebSocketURL, getURLAndInit } from '../misc/request-helper';
4 |
5 | const endpoint = '/connections';
6 |
7 | const subscribers = [];
8 |
9 | let ws: WebSocket;
10 |
11 | // see also https://github.com/Dreamacro/clash/blob/dev/constant/metadata.go#L41
12 | type UUID = string;
13 | type ConnNetwork = 'tcp' | 'udp';
14 | type ConnType = 'HTTP' | 'HTTP Connect' | 'Socks5' | 'Redir' | 'Unknown';
15 | export type ConnectionItem = {
16 | id: UUID;
17 | metadata: {
18 | network: ConnNetwork;
19 | type: ConnType;
20 | sourceIP: string;
21 | destinationIP: string;
22 | sourcePort: string;
23 | destinationPort: string;
24 | host: string;
25 | processPath: string;
26 | };
27 | upload: number;
28 | download: number;
29 | // e.g. "2019-11-30T22:48:13.416668+08:00",
30 | start: string;
31 | chains: string[];
32 | // e.g. 'Match', 'DomainKeyword'
33 | rule: string;
34 | rulePayload?: string;
35 | };
36 | type ConnectionsData = {
37 | downloadTotal: number;
38 | uploadTotal: number;
39 | connections: Array;
40 | };
41 |
42 | function appendData(s: string) {
43 | let o: ConnectionsData;
44 | try {
45 | o = JSON.parse(s);
46 | } catch (err) {
47 | // eslint-disable-next-line no-console
48 | console.log('JSON.parse error', JSON.parse(s));
49 | }
50 | subscribers.forEach((f) => f(o));
51 | }
52 |
53 | type UnsubscribeFn = () => void;
54 |
55 | export function fetchData(apiConfig: ClashAPIConfig, listener?: unknown): UnsubscribeFn | void {
56 | if (ws && ws.readyState <= WebSocket.OPEN) {
57 | if (listener) return subscribe(listener);
58 | return;
59 | }
60 |
61 | const url = buildWebSocketURL(apiConfig, endpoint);
62 | ws = new WebSocket(url);
63 |
64 | const onFrozen = () => {
65 | if (ws.readyState <= WebSocket.OPEN) ws.close();
66 | };
67 | const onResume = () => {
68 | if (ws.readyState <= WebSocket.OPEN) return;
69 | document.removeEventListener('freeze', onFrozen);
70 | document.removeEventListener('resume', onResume);
71 | fetchData(apiConfig);
72 | };
73 |
74 | document.addEventListener('freeze', onFrozen, { capture: true, once: true });
75 | document.addEventListener('resume', onResume, { capture: true, once: true });
76 |
77 | ws.addEventListener('message', (event) => appendData(event.data));
78 | if (listener) return subscribe(listener);
79 | }
80 |
81 | function subscribe(listener: unknown): UnsubscribeFn {
82 | const x = subscribers.indexOf(listener);
83 | if (x < 0) subscribers.push(listener);
84 | return function unsubscribe() {
85 | const idx = subscribers.indexOf(listener);
86 | subscribers.splice(idx, 1);
87 | };
88 | }
89 |
90 | export async function closeAllConnections(apiConfig: ClashAPIConfig) {
91 | const { url, init } = getURLAndInit(apiConfig);
92 | return await fetch(url + endpoint, { ...init, method: 'DELETE' });
93 | }
94 |
95 | export async function fetchConns(apiConfig: ClashAPIConfig) {
96 | const { url, init } = getURLAndInit(apiConfig);
97 | return await fetch(url + endpoint, { ...init });
98 | }
99 |
100 | export async function closeConnById(apiConfig: ClashAPIConfig, id: string) {
101 | const { url: baseURL, init } = getURLAndInit(apiConfig);
102 | const url = `${baseURL}${endpoint}/${id}`;
103 | return await fetch(url, { ...init, method: 'DELETE' });
104 | }
105 |
--------------------------------------------------------------------------------
/src/api/fetch.ts:
--------------------------------------------------------------------------------
1 | import isNetworkError from 'is-network-error';
2 |
3 | import {
4 | YacdBackendGeneralError,
5 | YacdBackendUnauthorizedError,
6 | YacdFetchNetworkError,
7 | } from '$src/misc/errors';
8 | import { getURLAndInit } from '$src/misc/request-helper';
9 | import { ClashAPIConfig, FetchCtx } from '$src/types';
10 |
11 | export type QueryCtx = {
12 | queryKey: readonly [string, ClashAPIConfig];
13 | };
14 |
15 | export function req(url: string, init: RequestInit) {
16 | if (import.meta.env.DEV) {
17 | return import('./mock').then((mod) => mod.mock(url, init));
18 | }
19 | return fetch(url, init);
20 | }
21 |
22 | export async function query(ctx: QueryCtx) {
23 | const endpoint = ctx.queryKey[0];
24 | const apiConfig = ctx.queryKey[1];
25 | const { url, init } = getURLAndInit(apiConfig);
26 |
27 | let res: Response;
28 | try {
29 | res = await req(url + endpoint, init);
30 | } catch (err) {
31 | handleFetchError(err, { endpoint, apiConfig });
32 | }
33 | await validateFetchResponse(res, { endpoint, apiConfig });
34 | if (res.ok) {
35 | return await res.json();
36 | }
37 | // can return undefined
38 | }
39 |
40 | export function handleFetchError(err: unknown, ctx: FetchCtx) {
41 | if (isNetworkError(err)) throw new YacdFetchNetworkError('', ctx);
42 | throw err;
43 | }
44 |
45 | async function validateFetchResponse(res: Response, ctx: FetchCtx) {
46 | if (res.status === 401) throw new YacdBackendUnauthorizedError('', ctx);
47 | if (!res.ok)
48 | throw new YacdBackendGeneralError('', {
49 | ...ctx,
50 | response: await simplifyRes(res),
51 | });
52 | return res;
53 | }
54 |
55 | export type SimplifiedResponse = {
56 | status: number;
57 | headers: string[];
58 | data?: any;
59 | };
60 |
61 | async function simplifyRes(res: Response): Promise {
62 | const headers: string[] = [];
63 | for (const [k, v] of res.headers) {
64 | headers.push(`${k}: ${v}`);
65 | }
66 |
67 | let data: any;
68 | try {
69 | data = await res.text();
70 | } catch (e) {
71 | // ignore
72 | }
73 |
74 | return {
75 | status: res.status,
76 | headers,
77 | data,
78 | };
79 | }
80 |
--------------------------------------------------------------------------------
/src/api/logs.ts:
--------------------------------------------------------------------------------
1 | import { pad0 } from 'src/misc/utils';
2 | import { Log } from 'src/store/types';
3 | import { LogsAPIConfig } from 'src/types';
4 |
5 | import { buildLogsWebSocketURL, getURLAndInit } from '../misc/request-helper';
6 |
7 | type AppendLogFn = (x: Log) => void;
8 | enum WebSocketReadyState {
9 | Connecting = 0,
10 | Open = 1,
11 | Closing = 2,
12 | Closed = 3,
13 | }
14 |
15 | const endpoint = '/logs';
16 | const textDecoder = new TextDecoder('utf-8');
17 |
18 | const getRandomStr = () => {
19 | return Math.floor((1 + Math.random()) * 0x10000).toString(16);
20 | };
21 |
22 | let even = false;
23 | let fetched = false;
24 | let decoded = '';
25 | let ws: WebSocket;
26 | let prevAppendLogFn: AppendLogFn;
27 |
28 | function appendData(s: string, callback: AppendLogFn) {
29 | let o: Partial;
30 | try {
31 | o = JSON.parse(s);
32 | } catch (err) {
33 | // eslint-disable-next-line no-console
34 | console.log('JSON.parse error', JSON.parse(s));
35 | }
36 |
37 | const now = new Date();
38 | const time = formatDate(now);
39 | // mutate input param in place intentionally
40 | o.time = time;
41 | o.id = +now - 0 + getRandomStr();
42 | o.even = even = !even;
43 | callback(o as Log);
44 | }
45 |
46 | function formatDate(d: Date) {
47 | // 19-03-09 12:45
48 | const YY = d.getFullYear() % 100;
49 | const MM = pad0(d.getMonth() + 1, 2);
50 | const dd = pad0(d.getDate(), 2);
51 | const HH = pad0(d.getHours(), 2);
52 | const mm = pad0(d.getMinutes(), 2);
53 | const ss = pad0(d.getSeconds(), 2);
54 | return `${YY}-${MM}-${dd} ${HH}:${mm}:${ss}`;
55 | }
56 |
57 | function pump(reader: ReadableStreamDefaultReader, appendLog: AppendLogFn) {
58 | return reader.read().then(({ done, value }) => {
59 | const str = textDecoder.decode(value, { stream: !done });
60 | decoded += str;
61 |
62 | const splits = decoded.split('\n');
63 |
64 | const lastSplit = splits[splits.length - 1];
65 |
66 | for (let i = 0; i < splits.length - 1; i++) {
67 | appendData(splits[i], appendLog);
68 | }
69 |
70 | if (done) {
71 | appendData(lastSplit, appendLog);
72 | decoded = '';
73 |
74 | // eslint-disable-next-line no-console
75 | console.log('GET /logs streaming done');
76 | fetched = false;
77 | return;
78 | } else {
79 | decoded = lastSplit;
80 | }
81 | return pump(reader, appendLog);
82 | });
83 | }
84 |
85 | /** loose hashing of the connection configuration */
86 | function makeConnStr(c: LogsAPIConfig) {
87 | const keys = Object.keys(c);
88 | keys.sort();
89 | return keys.map((k) => c[k]).join('|');
90 | }
91 |
92 | let prevConnStr: string;
93 | let controller: AbortController;
94 |
95 | export function fetchLogs(apiConfig: LogsAPIConfig, appendLog: AppendLogFn) {
96 | if (apiConfig.logLevel === 'uninit') return;
97 | if (fetched || (ws && ws.readyState === WebSocketReadyState.Open)) return;
98 | prevAppendLogFn = appendLog;
99 | const url = buildLogsWebSocketURL(apiConfig, endpoint);
100 | ws = new WebSocket(url);
101 | ws.addEventListener('error', () => {
102 | fetchLogsWithFetch(apiConfig, appendLog);
103 | });
104 | ws.addEventListener('message', function (event) {
105 | appendData(event.data, appendLog);
106 | });
107 | }
108 |
109 | export function stop() {
110 | ws.close();
111 | if (controller) controller.abort();
112 | }
113 |
114 | export function reconnect(apiConfig: LogsAPIConfig) {
115 | if (!prevAppendLogFn || !ws) return;
116 | ws.close();
117 | fetched = false;
118 | fetchLogs(apiConfig, prevAppendLogFn);
119 | }
120 |
121 | function fetchLogsWithFetch(apiConfig: LogsAPIConfig, appendLog: AppendLogFn) {
122 | if (controller && makeConnStr(apiConfig) !== prevConnStr) {
123 | controller.abort();
124 | } else if (fetched) {
125 | return;
126 | }
127 |
128 | fetched = true;
129 | prevConnStr = makeConnStr(apiConfig);
130 |
131 | controller = new AbortController();
132 | const signal = controller.signal;
133 |
134 | const { url, init } = getURLAndInit(apiConfig);
135 | fetch(url + endpoint + '?level=' + apiConfig.logLevel, {
136 | ...init,
137 | signal,
138 | }).then(
139 | (response) => {
140 | const reader = response.body.getReader();
141 | pump(reader, appendLog);
142 | },
143 | (err) => {
144 | fetched = false;
145 | if (signal.aborted) return;
146 |
147 | // eslint-disable-next-line no-console
148 | console.log('GET /logs error:', err.message);
149 | },
150 | );
151 | }
152 |
--------------------------------------------------------------------------------
/src/api/mock.ts:
--------------------------------------------------------------------------------
1 | const MOCK_HANDLERS = [
2 | {
3 | key: 'GET/',
4 | enabled: true,
5 | handler: (_u: string, _i: RequestInit) => {
6 | // throw new Error();
7 | // return deserializeError();
8 | return json({ hello: 'clash' });
9 | },
10 | },
11 | {
12 | key: 'GET/configs',
13 | enabled: false,
14 | handler: (_u: string, _i: RequestInit) => {
15 | return apiError('{"name": "hello"}');
16 | // return json(makeConfig());
17 | },
18 | },
19 | {
20 | key: 'GET/notfound',
21 | handler: (_u: string, _i: RequestInit) => {
22 | return deserializeError();
23 | },
24 | },
25 | ];
26 |
27 | export async function mock(url: string, init: RequestInit) {
28 | const method = init.method || 'GET';
29 | const pathname = new URL(url).pathname;
30 | const key = `${method}${pathname}`;
31 | const item = MOCK_HANDLERS.find((h) => {
32 | if (h.enabled && h.key === key) return h;
33 | });
34 | if (item) {
35 | console.warn('Using mocked API', key);
36 | return (await item?.handler(url, init)) as Response;
37 | }
38 | return fetch(url, init);
39 | }
40 |
41 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
42 | async function json(data: T) {
43 | await sleep(1);
44 | return {
45 | ok: true,
46 | json: async () => {
47 | await sleep(16);
48 | return data;
49 | },
50 | };
51 | }
52 |
53 | async function apiError(data: T) {
54 | await sleep(1);
55 | const headers = new Headers();
56 | headers.append('x-test-1', 'test-1');
57 | headers.append('x-test-2', 'test-3');
58 | return {
59 | ok: false,
60 | status: 400,
61 | headers,
62 | text: async () => {
63 | await sleep(16);
64 | return data;
65 | },
66 | };
67 | }
68 |
69 | async function deserializeError() {
70 | await sleep(1);
71 | return {
72 | ok: true,
73 | json: async () => {
74 | await sleep(16);
75 | throw new Error();
76 | },
77 | };
78 | }
79 |
80 | function sleep(ms: number) {
81 | return new Promise((resolve) => setTimeout(resolve, ms));
82 | }
83 |
84 | function makeConfig() {
85 | return {
86 | port: 0,
87 | 'socks-port': 7891,
88 | 'redir-port': 0,
89 | 'tproxy-port': 0,
90 | 'mixed-port': 7890,
91 | 'allow-lan': true,
92 | 'bind-address': '*',
93 | mode: 'rule',
94 | 'log-level': 'info',
95 | authentication: [],
96 | ipv6: false,
97 | };
98 | }
99 |
--------------------------------------------------------------------------------
/src/api/proxies.ts:
--------------------------------------------------------------------------------
1 | import { ClashAPIConfig } from '$src/types';
2 |
3 | import { getURLAndInit } from '../misc/request-helper';
4 |
5 | const endpoint = '/proxies';
6 |
7 | /*
8 | $ curl "http://127.0.0.1:8080/proxies/Proxy" -XPUT -d '{ "name": "ss3" }' -i
9 | HTTP/1.1 400 Bad Request
10 | Content-Type: text/plain; charset=utf-8
11 |
12 | {"error":"Selector update error: Proxy does not exist"}
13 |
14 | ~
15 | $ curl "http://127.0.0.1:8080/proxies/GLOBAL" -XPUT -d '{ "name": "Proxy" }' -i
16 | HTTP/1.1 204 No Content
17 | */
18 |
19 | export async function fetchProxies(config: ClashAPIConfig) {
20 | const { url, init } = getURLAndInit(config);
21 | const res = await fetch(url + endpoint, init);
22 | return await res.json();
23 | }
24 |
25 | export async function requestToSwitchProxy(
26 | apiConfig: ClashAPIConfig,
27 | groupName: string,
28 | name: string,
29 | ) {
30 | const body = { name };
31 | const { url, init } = getURLAndInit(apiConfig);
32 | const group = encodeURIComponent(groupName);
33 | const fullURL = `${url}${endpoint}/${group}`;
34 | return await fetch(fullURL, {
35 | ...init,
36 | method: 'PUT',
37 | body: JSON.stringify(body),
38 | });
39 | }
40 |
41 | export async function requestDelayForProxy(
42 | apiConfig: ClashAPIConfig,
43 | name: string,
44 | latencyTestUrl = 'http://www.gstatic.com/generate_204',
45 | ) {
46 | const { url, init } = getURLAndInit(apiConfig);
47 | const qs = `timeout=5000&url=${encodeURIComponent(latencyTestUrl)}`;
48 | const fullURL = `${url}${endpoint}/${encodeURIComponent(name)}/delay?${qs}`;
49 | return await fetch(fullURL, init);
50 | }
51 |
52 | export async function fetchProviderProxies(config: ClashAPIConfig) {
53 | const { url, init } = getURLAndInit(config);
54 | const res = await fetch(url + '/providers/proxies', init);
55 | if (res.status === 404) {
56 | return { providers: {} };
57 | }
58 | return await res.json();
59 | }
60 |
61 | export async function updateProviderByName(config: ClashAPIConfig, name: string) {
62 | const { url, init } = getURLAndInit(config);
63 | const options = { ...init, method: 'PUT' };
64 | return await fetch(url + '/providers/proxies/' + encodeURIComponent(name), options);
65 | }
66 |
67 | export async function healthcheckProviderByName(config: ClashAPIConfig, name: string) {
68 | const { url, init } = getURLAndInit(config);
69 | const options = { ...init, method: 'GET' };
70 | return await fetch(
71 | url + '/providers/proxies/' + encodeURIComponent(name) + '/healthcheck',
72 | options,
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/src/api/rule-provider.ts:
--------------------------------------------------------------------------------
1 | import { getURLAndInit } from 'src/misc/request-helper';
2 | import { ClashAPIConfig } from 'src/types';
3 |
4 | import { query, QueryCtx } from './fetch';
5 |
6 | export type RuleProvider = RuleProviderAPIItem & { idx: number };
7 |
8 | export type RuleProviderAPIItem = {
9 | behavior: string;
10 | name: string;
11 | ruleCount: number;
12 | type: 'Rule';
13 | // example value "2020-06-30T16:23:01.44143802+08:00"
14 | updatedAt: string;
15 | vehicleType: 'HTTP' | 'File';
16 | };
17 |
18 | type RuleProviderAPIData = {
19 | providers: Record;
20 | };
21 |
22 | function normalizeAPIResponse(data: RuleProviderAPIData) {
23 | const providers = data.providers;
24 | const names = Object.keys(providers);
25 | const byName: Record = {};
26 |
27 | // attach an idx to each item
28 | for (let i = 0; i < names.length; i++) {
29 | const name = names[i];
30 | byName[name] = { ...providers[name], idx: i };
31 | }
32 |
33 | return { byName, names };
34 | }
35 |
36 | export async function fetchRuleProviders(ctx: QueryCtx) {
37 | const data = (await query(ctx)) || { providers: {} };
38 | return normalizeAPIResponse(data);
39 | }
40 |
41 | export async function refreshRuleProviderByName({
42 | name,
43 | apiConfig,
44 | }: {
45 | name: string;
46 | apiConfig: ClashAPIConfig;
47 | }) {
48 | const { url, init } = getURLAndInit(apiConfig);
49 | try {
50 | const res = await fetch(url + `/providers/rules/${name}`, {
51 | method: 'PUT',
52 | ...init,
53 | });
54 | return res.ok;
55 | } catch (err) {
56 | // log and ignore
57 | // eslint-disable-next-line no-console
58 | console.log('failed to PUT /providers/rules/:name', err);
59 | return false;
60 | }
61 | }
62 |
63 | export async function updateRuleProviders({
64 | names,
65 | apiConfig,
66 | }: {
67 | names: string[];
68 | apiConfig: ClashAPIConfig;
69 | }) {
70 | for (let i = 0; i < names.length; i++) {
71 | // run in sequence
72 | await refreshRuleProviderByName({ name: names[i], apiConfig });
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/api/rules.ts:
--------------------------------------------------------------------------------
1 | import invariant from 'invariant';
2 | import { ClashAPIConfig } from 'src/types';
3 |
4 | import { query } from './fetch';
5 |
6 | type RuleItem = RuleAPIItem & { id: number };
7 |
8 | type RuleAPIItem = {
9 | type: string;
10 | payload: string;
11 | proxy: string;
12 | };
13 |
14 | function normalizeAPIResponse(json: { rules: Array }): Array {
15 | invariant(
16 | json.rules && json.rules.length >= 0,
17 | 'there is no valid rules list in the rules API response',
18 | );
19 |
20 | // attach an id
21 | return json.rules.map((r: RuleAPIItem, i: number) => ({ ...r, id: i }));
22 | }
23 |
24 | export async function fetchRules(ctx: { queryKey: readonly [string, ClashAPIConfig] }) {
25 | const json = (await query(ctx)) || { rules: [] };
26 | return normalizeAPIResponse(json);
27 | }
28 |
--------------------------------------------------------------------------------
/src/api/traffic.ts:
--------------------------------------------------------------------------------
1 | import { ClashAPIConfig } from '$src/types';
2 |
3 | import { buildWebSocketURL } from '../misc/request-helper';
4 |
5 | const endpoint = '/traffic';
6 |
7 | const Size = 150;
8 |
9 | type Traffic = { up: number; down: number };
10 |
11 | let ws: WebSocket;
12 |
13 | const traffic = {
14 | labels: Array(Size).fill(0),
15 | up: Array(Size),
16 | down: Array(Size),
17 |
18 | size: Size,
19 | subscribers: [],
20 | appendData(o: Traffic) {
21 | this.up.shift();
22 | this.down.shift();
23 | this.labels.shift();
24 |
25 | const l = Date.now();
26 | this.up.push(o.up);
27 | this.down.push(o.down);
28 | this.labels.push(l);
29 |
30 | this.subscribers.forEach((f: (x: Traffic) => void) => f(o));
31 | },
32 |
33 | subscribe(listener: (x: any) => void) {
34 | this.subscribers.push(listener);
35 | return () => {
36 | const idx = this.subscribers.indexOf(listener);
37 | this.subscribers.splice(idx, 1);
38 | };
39 | },
40 | };
41 |
42 | function parseAndAppend(x: string) {
43 | traffic.appendData(JSON.parse(x));
44 | }
45 |
46 | export function fetchData(apiConfig: ClashAPIConfig) {
47 | // TODO if apiConfig changed, should we reset?
48 | if (ws && ws.readyState <= WebSocket.OPEN) return traffic;
49 |
50 | const url = buildWebSocketURL(apiConfig, endpoint);
51 | ws = new WebSocket(url);
52 |
53 | const onFrozen = () => {
54 | if (ws.readyState <= WebSocket.OPEN) ws.close();
55 | };
56 |
57 | const onResume = () => {
58 | if (ws.readyState <= WebSocket.OPEN) return;
59 | document.removeEventListener('freeze', onFrozen);
60 | document.removeEventListener('resume', onResume);
61 |
62 | traffic.up.fill(0);
63 | traffic.down.fill(undefined);
64 | traffic.labels.fill(undefined);
65 | fetchData(apiConfig);
66 | };
67 |
68 | document.addEventListener('freeze', onFrozen, { capture: true, once: true });
69 | document.addEventListener('resume', onResume, { capture: true, once: true });
70 |
71 | ws.addEventListener('error', function (_ev) {
72 | console.log('error', _ev);
73 | //
74 | });
75 | // ws.addEventListener('close', (_ev) => {});
76 | ws.addEventListener('message', function (event) {
77 | parseAndAppend(event.data);
78 | });
79 | return traffic;
80 | }
81 |
--------------------------------------------------------------------------------
/src/api/version.ts:
--------------------------------------------------------------------------------
1 | import { query, QueryCtx } from './fetch';
2 |
3 | type VersionData = {
4 | version?: string;
5 | premium?: boolean;
6 | };
7 |
8 | export async function fetchVersion(ctx: QueryCtx): Promise {
9 | const json = (await query(ctx)) || {};
10 | return json;
11 | }
12 |
--------------------------------------------------------------------------------
/src/app.tsx:
--------------------------------------------------------------------------------
1 | // import 'modern-normalize/modern-normalize.css';
2 | import './misc/i18n';
3 | import '@fontsource/roboto-mono/latin-400.css';
4 | import '@fontsource/inter/latin-400.css';
5 | import '@fontsource/inter/latin-800.css';
6 |
7 | import inter400 from '@fontsource/inter/files/inter-latin-400-normal.woff2';
8 | import inter800 from '@fontsource/inter/files/inter-latin-800-normal.woff2';
9 | import robotoMono400 from '@fontsource/roboto-mono/files/roboto-mono-latin-400-normal.woff2';
10 | import flagfont from 'country-flag-emoji-polyfill/TwemojiCountryFlags.woff2';
11 | import * as React from 'react';
12 | import { createRoot } from 'react-dom/client';
13 | import Modal from 'react-modal';
14 |
15 | import Root from './components/Root';
16 | import * as swRegistration from './swRegistration';
17 |
18 | init();
19 |
20 | const rootEl = document.getElementById('app');
21 | const root = createRoot(rootEl);
22 |
23 | function insertLinkElement(href: string) {
24 | const l = document.createElement('link');
25 | l.href = href;
26 | l.rel = 'preload';
27 | l.as = 'font';
28 | l.type = 'font/woff2';
29 | l.crossOrigin = '';
30 |
31 | document.head.appendChild(l);
32 | }
33 |
34 | function init() {
35 | // preload woff2 font files
36 | insertLinkElement(inter400);
37 | insertLinkElement(inter800);
38 | insertLinkElement(robotoMono400);
39 | insertLinkElement(flagfont);
40 | }
41 |
42 | Modal.setAppElement(rootEl);
43 |
44 | root.render(
45 |
46 |
47 | ,
48 | );
49 |
50 | setTimeout(() => {
51 | import('country-flag-emoji-polyfill')
52 | .then((mod) => {
53 | mod && mod.polyfillCountryFlagEmojis('Twemoji Country Flags', flagfont);
54 | })
55 | .catch(() => {
56 | /* noop */
57 | });
58 | }, 1);
59 |
60 | swRegistration.register();
61 |
62 | // eslint-disable-next-line no-console
63 | console.log('Checkout the repo: https://github.com/haishanh/yacd');
64 | // eslint-disable-next-line
65 | console.log('Version:', __VERSION__);
66 | if (__COMMIT_HASH__) {
67 | // eslint-disable-next-line
68 | console.log('Commit hash:', __COMMIT_HASH__);
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/Button.module.scss:
--------------------------------------------------------------------------------
1 | .btn {
2 | -webkit-appearance: none;
3 | outline: none;
4 | user-select: none;
5 | position: relative;
6 | cursor: pointer;
7 | display: inline-flex;
8 | align-items: center;
9 | justify-content: center;
10 | color: var(--color-btn-fg);
11 | background: var(--color-btn-bg);
12 | border: 1px solid #555;
13 | border-radius: 100px;
14 | &:focus {
15 | border-color: var(--color-focus-blue);
16 | }
17 | &:hover {
18 | background: #387cec;
19 | border: 1px solid #387cec;
20 | color: #fff;
21 | }
22 | &:active {
23 | transform: scale(0.97);
24 | }
25 |
26 | padding: 10px 13px;
27 |
28 | &.circular {
29 | padding: 8px;
30 | }
31 |
32 | &.minimal {
33 | border-color: transparent;
34 | background: none;
35 | padding: 6px 12px;
36 | &:focus {
37 | border-color: var(--color-focus-blue);
38 | }
39 | &:hover {
40 | color: #fff;
41 | background: #387cec;
42 | border: 1px solid #387cec;
43 | }
44 | }
45 | }
46 |
47 | .btn:disabled {
48 | opacity: 0.5;
49 | }
50 |
51 | .btnStart {
52 | margin-right: 5px;
53 | display: inline-flex;
54 | align-items: center;
55 | justify-content: center;
56 | }
57 |
58 | .loadingContainer {
59 | position: absolute;
60 | top: 50%;
61 | left: 50%;
62 | transform: translate(-50%, -50%);
63 | display: inline-flex;
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import cx from 'clsx';
2 | import * as React from 'react';
3 |
4 | import s0 from './Button.module.scss';
5 | import { LoadingDot } from './shared/Basic';
6 |
7 | const { forwardRef, useCallback } = React;
8 |
9 | type ButtonInternalProps = {
10 | children?: React.ReactNode;
11 | label?: string;
12 | text?: string;
13 | start?: React.ReactNode | (() => React.ReactNode);
14 | };
15 |
16 | type ButtonProps = {
17 | isLoading?: boolean;
18 | onClick?: (e: React.MouseEvent) => unknown;
19 | disabled?: boolean;
20 | kind?: 'primary' | 'minimal' | 'circular';
21 | className?: string;
22 | title?: string;
23 | } & ButtonInternalProps;
24 |
25 | function Button(props: ButtonProps, ref: React.Ref) {
26 | const {
27 | onClick,
28 | disabled = false,
29 | isLoading,
30 | kind = 'primary',
31 | className,
32 | children,
33 | label,
34 | text,
35 | start,
36 | ...restProps
37 | } = props;
38 | const internalProps = { children, label, text, start };
39 | const internalOnClick = useCallback>(
40 | (e) => {
41 | if (isLoading) return;
42 | onClick && onClick(e);
43 | },
44 | [isLoading, onClick],
45 | );
46 | const btnClassName = cx(
47 | s0.btn,
48 | { [s0.minimal]: kind === 'minimal', [s0.circular]: kind === 'circular' },
49 | className,
50 | );
51 | return (
52 |
72 | );
73 | }
74 |
75 | function ButtonInternal({ children, label, text, start }: ButtonInternalProps) {
76 | return (
77 | <>
78 | {start ? (
79 | {typeof start === 'function' ? start() : start}
80 | ) : null}
81 | {children || label || text}
82 | >
83 | );
84 | }
85 |
86 | export default forwardRef(Button);
87 |
--------------------------------------------------------------------------------
/src/components/Collapsible.tsx:
--------------------------------------------------------------------------------
1 | import type { MutableRefObject } from 'react';
2 | import * as React from 'react';
3 | import ResizeObserver from 'resize-observer-polyfill';
4 |
5 | import { framerMotionResource } from '../misc/motion';
6 |
7 | const { memo, useState, useRef, useEffect } = React;
8 |
9 | function usePrevious(value: any) {
10 | const ref = useRef();
11 | useEffect(() => void (ref.current = value), [value]);
12 | return ref.current;
13 | }
14 |
15 | function useMeasure(): [MutableRefObject, { height: number }] {
16 | const ref = useRef();
17 | const [bounds, set] = useState({ height: 0 });
18 | useEffect(() => {
19 | const ro = new ResizeObserver(([entry]) => set(entry.contentRect));
20 | if (ref.current) ro.observe(ref.current);
21 | return () => ro.disconnect();
22 | }, []);
23 | return [ref, bounds];
24 | }
25 |
26 | const variantsCollpapsibleWrap = {
27 | initialOpen: {
28 | height: 'auto',
29 | transition: { duration: 0 },
30 | },
31 | open: (height: number) => ({
32 | height,
33 | opacity: 1,
34 | visibility: 'visible',
35 | transition: { duration: 0.3 },
36 | }),
37 | closed: {
38 | height: 0,
39 | opacity: 0,
40 | visibility: 'hidden',
41 | transition: { duration: 0.3 },
42 | },
43 | };
44 |
45 | const variantsCollpapsibleChildContainer = {
46 | open: {
47 | x: 0,
48 | },
49 | closed: {
50 | x: 20,
51 | },
52 | };
53 |
54 | type CollapsibleProps = { children: React.ReactNode; isOpen?: boolean };
55 |
56 | const Collapsible = memo(({ children, isOpen }: CollapsibleProps) => {
57 | const module = framerMotionResource.read();
58 | const motion = module.motion;
59 | const previous = usePrevious(isOpen);
60 | const [refToMeature, { height }] = useMeasure();
61 | return (
62 |
63 |
68 |
69 | {children}
70 |
71 |
72 |
73 | );
74 | });
75 |
76 | Collapsible.displayName = 'MemoCollapsible';
77 |
78 | export default Collapsible;
79 |
--------------------------------------------------------------------------------
/src/components/CollapsibleSectionHeader.module.scss:
--------------------------------------------------------------------------------
1 | .header {
2 | display: flex;
3 | align-items: center;
4 |
5 | &:focus {
6 | outline: none;
7 | }
8 |
9 | .arrow {
10 | display: inline-flex;
11 | transform: rotate(0deg);
12 | transition: transform 0.3s;
13 |
14 | &.isOpen {
15 | transform: rotate(180deg);
16 | }
17 |
18 | &:focus {
19 | outline: var(--color-focus-blue) solid 1px;
20 | }
21 | }
22 | }
23 |
24 | .btn {
25 | margin-left: 5px;
26 | }
27 |
28 | /* TODO duplicate with connQty in Connections.module.css */
29 | .qty {
30 | font-family: var(--font-normal);
31 | font-size: 0.75em;
32 | margin-left: 3px;
33 | padding: 2px 7px;
34 | display: inline-flex;
35 | justify-content: center;
36 | align-items: center;
37 | background-color: var(--bg-near-transparent);
38 | border-radius: 30px;
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/CollapsibleSectionHeader.tsx:
--------------------------------------------------------------------------------
1 | import cx from 'clsx';
2 | import * as React from 'react';
3 | import { ChevronDown } from 'react-feather';
4 |
5 | import Button from './Button';
6 | import s from './CollapsibleSectionHeader.module.scss';
7 | import { SectionNameType } from './shared/Basic';
8 |
9 | type Props = {
10 | name: string;
11 | type: string;
12 | qty?: number;
13 | toggle?: () => void;
14 | isOpen?: boolean;
15 | };
16 |
17 | export default function Header({ name, type, toggle, isOpen, qty }: Props) {
18 | const handleKeyDown = React.useCallback(
19 | (e: React.KeyboardEvent) => {
20 | e.preventDefault();
21 | if (e.key === 'Enter' || e.key === ' ') {
22 | toggle();
23 | }
24 | },
25 | [toggle],
26 | );
27 | return (
28 |
36 |
37 |
38 |
39 |
40 | {typeof qty === 'number' ?
{qty} : null}
41 |
42 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/Config.module.scss:
--------------------------------------------------------------------------------
1 | .root,
2 | .section {
3 | display: grid;
4 | grid-template-columns: repeat(auto-fill, minmax(345px, 1fr));
5 | max-width: 900px;
6 | gap: 5px;
7 | @media screen and (min-width: 30em) {
8 | gap: 15px;
9 | }
10 |
11 | .item {
12 | margin-top: 11px;
13 | label {
14 | padding-left: 12px;
15 | }
16 | }
17 | }
18 |
19 | .root,
20 | .section {
21 | padding: 6px 15px 10px;
22 | @media screen and (min-width: 30em) {
23 | padding: 10px 40px 15px;
24 | }
25 | }
26 |
27 | .sep {
28 | max-width: 900px;
29 | padding: 0 15px;
30 | @media screen and (min-width: 30em) {
31 | padding: 0 40px;
32 | }
33 | > div {
34 | border-top: 1px dashed #373737;
35 | }
36 | }
37 |
38 | .label {
39 | padding: 11px 0;
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/ConnectionTable.module.scss:
--------------------------------------------------------------------------------
1 | .tr {
2 | display: grid;
3 | grid-template-columns: repeat(var(--col-count, 11), minmax(max-content, auto));
4 | }
5 |
6 | .pointer {
7 | cursor: pointer;
8 | }
9 |
10 | .table {
11 | border: none;
12 | border-collapse: collapse;
13 | thead tr {
14 | position: sticky;
15 | top: 0;
16 | background: var(--color-background);
17 | }
18 | th {
19 | padding: 8px 13px;
20 | height: 50px;
21 | font-weight: initial;
22 | font-size: 0.8em;
23 | text-align: left;
24 | white-space: nowrap;
25 | }
26 | td {
27 | border: none;
28 | white-space: nowrap;
29 | padding: 8px 13px;
30 | font-size: 0.9em;
31 | font-family: var(--font-normal);
32 | }
33 | & > tbody > tr:nth-of-type(odd) > * {
34 | background: var(--color-row-odd);
35 | }
36 | }
37 |
38 | .thWrap {
39 | user-select: none;
40 | display: inline-flex;
41 | align-items: center;
42 | justify-content: space-between;
43 | &:hover {
44 | color: var(--color-text-highlight);
45 | }
46 | }
47 |
48 | .sortIconContainer {
49 | display: inline-flex;
50 | margin-left: 10px;
51 | width: 16px;
52 | height: 16px;
53 | }
54 |
55 | .rotate180 {
56 | transform: rotate(180deg);
57 | }
58 |
--------------------------------------------------------------------------------
/src/components/ConnectionTable.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | flexRender,
3 | getCoreRowModel,
4 | getSortedRowModel,
5 | SortingState,
6 | useReactTable,
7 | } from '@tanstack/react-table';
8 | import cx from 'clsx';
9 | import { formatDistance } from 'date-fns';
10 | import React from 'react';
11 | import { ChevronDown } from 'react-feather';
12 |
13 | import prettyBytes from '../misc/pretty-bytes';
14 | import s from './ConnectionTable.module.scss';
15 | import { MutableConnRefCtx } from './conns/ConnCtx';
16 |
17 | const fullColumns = [
18 | { header: 'Id', accessorKey: 'id' },
19 | { header: 'Host', accessorKey: 'host' },
20 | { header: 'Process', accessorKey: 'process' },
21 | {
22 | header: 'DL',
23 | accessorKey: 'download',
24 | cell: (info: any) => prettyBytes(info.getValue()),
25 | },
26 | {
27 | header: 'UL',
28 | accessorKey: 'upload',
29 | cell: (info: any) => prettyBytes(info.getValue()),
30 | },
31 | {
32 | header: 'DL Speed',
33 | accessorKey: 'downloadSpeedCurr',
34 | cell: (info: any) => prettyBytes(info.getValue()) + '/s',
35 | },
36 | {
37 | header: 'UL Speed',
38 | accessorKey: 'uploadSpeedCurr',
39 | cell: (info: any) => prettyBytes(info.getValue()) + '/s',
40 | },
41 | { header: 'Chains', accessorKey: 'chains' },
42 | { header: 'Rule', accessorKey: 'rule' },
43 | {
44 | header: 'Time',
45 | accessorKey: 'start',
46 | cell: (info: any) => formatDistance(info.getValue(), 0),
47 | },
48 | { header: 'Source', accessorKey: 'source' },
49 | { header: 'Destination IP', accessorKey: 'destinationIP' },
50 | { header: 'Type', accessorKey: 'type' },
51 | ];
52 |
53 | const COLUMN_SORT = [{ id: 'id', desc: true }];
54 |
55 | const columns = fullColumns;
56 | const columnsWithoutProcess = fullColumns.filter((item) => item.accessorKey !== 'process');
57 |
58 | function Table({ data }: { data: any }) {
59 | const connCtx = React.useContext(MutableConnRefCtx);
60 | const [sorting, setSorting] = React.useState(COLUMN_SORT);
61 | const table = useReactTable({
62 | columns: connCtx.hasProcessPath ? columns : columnsWithoutProcess,
63 | data,
64 | state: {
65 | sorting,
66 | columnVisibility: { id: false },
67 | },
68 | onSortingChange: setSorting,
69 | getCoreRowModel: getCoreRowModel(),
70 | getSortedRowModel: getSortedRowModel(),
71 | });
72 | return (
73 |
74 |
75 | {table.getHeaderGroups().map((headerGroup) => {
76 | return (
77 |
78 | {headerGroup.headers.map((header) => {
79 | return (
80 |
85 |
86 | {flexRender(header.column.columnDef.header, header.getContext())}
87 | {header.column.getIsSorted() ? (
88 |
95 |
96 |
97 | ) : null}
98 |
99 | |
100 | );
101 | })}
102 |
103 | );
104 | })}
105 |
106 |
107 | {table.getRowModel().rows.map((row) => {
108 | return (
109 |
110 | {row.getVisibleCells().map((cell) => {
111 | return (
112 | {flexRender(cell.column.columnDef.cell, cell.getContext())} |
113 | );
114 | })}
115 |
116 | );
117 | })}
118 |
119 |
120 | );
121 | }
122 |
123 | export default Table;
124 |
--------------------------------------------------------------------------------
/src/components/Connections.css:
--------------------------------------------------------------------------------
1 | .react-tabs {
2 | -webkit-tap-highlight-color: transparent;
3 | }
4 |
5 | .react-tabs__tab-list {
6 | margin: 0 0 10px;
7 | padding: 0 30px;
8 | }
9 |
10 | .react-tabs__tab {
11 | display: inline-flex;
12 | align-items: center;
13 | border: 1px solid transparent;
14 | border-radius: 5px;
15 | bottom: -1px;
16 | position: relative;
17 | list-style: none;
18 | padding: 6px 10px;
19 | cursor: pointer;
20 | font-size: 1.2em;
21 | opacity: 0.5;
22 | }
23 |
24 | .react-tabs__tab--selected {
25 | opacity: 1;
26 | }
27 |
28 | .react-tabs__tab--disabled {
29 | color: GrayText;
30 | cursor: default;
31 | }
32 |
33 | .react-tabs__tab:focus {
34 | border-color: hsl(208, 99%, 50%);
35 | outline: none;
36 | }
37 |
38 | .react-tabs__tab:focus:after {
39 | content: '';
40 | position: absolute;
41 | }
42 |
43 | .react-tabs__tab-panel {
44 | display: none;
45 | }
46 |
47 | .react-tabs__tab-panel--selected {
48 | display: block;
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/Connections.module.scss:
--------------------------------------------------------------------------------
1 | .placeHolder {
2 | height: 100%;
3 | display: flex;
4 | align-items: center;
5 | justify-content: center;
6 | color: var(--color-background);
7 | opacity: 0.1;
8 | }
9 |
10 | .connQty {
11 | font-family: var(--font-normal);
12 | font-size: 0.75em;
13 | margin-left: 3px;
14 | padding: 2px 7px;
15 | display: inline-flex;
16 | justify-content: center;
17 | align-items: center;
18 | background-color: var(--bg-near-transparent);
19 | border-radius: 30px;
20 | }
21 |
22 | .inputWrapper {
23 | margin: 0 30px;
24 | width: 100%;
25 | max-width: 350px;
26 | justify-self: flex-end;
27 | }
28 |
29 | .input {
30 | appearance: none;
31 | -webkit-appearance: none;
32 | background-color: var(--color-input-bg);
33 | background-image: none;
34 | border-radius: 18px;
35 | border: 1px solid var(--color-input-border);
36 | box-sizing: border-box;
37 | color: #c1c1c1;
38 | display: inline-block;
39 | font-size: inherit;
40 | height: 36px;
41 | outline: none;
42 | padding: 0 15px;
43 | transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
44 | width: 100%;
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/ContentHeader.module.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | height: 76px;
3 | display: flex;
4 | align-items: center;
5 | }
6 |
7 | .h1 {
8 | padding: 0 15px;
9 | font-size: 1.7em;
10 | font-weight: bold;
11 | @media screen and (min-width: 30em) {
12 | padding: 0 40px;
13 | font-size: 2em;
14 | }
15 | text-align: left;
16 | margin: 0;
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/ContentHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import s0 from './ContentHeader.module.scss';
4 |
5 | type Props = {
6 | title: string;
7 | };
8 |
9 | export function ContentHeader({ title }: Props) {
10 | return (
11 |
12 |
{title}
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/Field.module.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | position: relative;
3 | padding: 10px 0;
4 | input {
5 | appearance: none;
6 | -webkit-appearance: none;
7 | background-color: transparent;
8 | background-image: none;
9 | border: none;
10 | border-radius: 0;
11 | border-bottom: 1px solid var(--color-input-border);
12 | box-sizing: border-box;
13 | color: inherit;
14 | display: inline-block;
15 | font-size: inherit;
16 | height: 40px;
17 | outline: none;
18 | padding: 0;
19 | width: 100%;
20 | &:focus {
21 | border-color: var(--color-focus-blue);
22 | }
23 | }
24 |
25 | label {
26 | position: absolute;
27 | left: 0;
28 | bottom: 22px;
29 | transition: transform 150ms ease-in-out;
30 | transform-origin: 0 0;
31 | font-size: 0.9em;
32 | &.floatAbove {
33 | transform: scale(0.9) translateY(-25px);
34 | }
35 | }
36 |
37 | input {
38 | &:focus + label {
39 | color: var(--color-focus-blue);
40 | transform: scale(0.9) translateY(-25px);
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/Field.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import s from './Field.module.scss';
4 |
5 | const { useCallback } = React;
6 |
7 | type Props = {
8 | name: string;
9 | value?: string | number;
10 | type?: 'text' | 'number';
11 | onChange?: (...args: any[]) => any;
12 | id?: string;
13 | label?: string;
14 | placeholder?: string;
15 | };
16 |
17 | export default function Field({ id, label, value, onChange, ...props }: Props) {
18 | const valueOnChange = useCallback((e: any) => onChange(e), [onChange]);
19 | return (
20 |
21 |
22 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/Home.module.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 6px 15px;
3 | @media screen and (min-width: 30em) {
4 | padding: 10px 40px;
5 | }
6 | }
7 | .chart {
8 | margin-top: 15px;
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/Home.tsx:
--------------------------------------------------------------------------------
1 | import React, { Suspense } from 'react';
2 | import { useTranslation } from 'react-i18next';
3 |
4 | import { ContentHeader } from './ContentHeader';
5 | import s0 from './Home.module.scss';
6 | import Loading from './Loading';
7 | import TrafficChart from './TrafficChart';
8 | import TrafficNow from './TrafficNow';
9 |
10 | export default function Home() {
11 | const { t } = useTranslation();
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | }>
21 |
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/Icon.tsx:
--------------------------------------------------------------------------------
1 | import cx from 'clsx';
2 | import React from 'react';
3 |
4 | type Props = {
5 | id: string;
6 | width?: number;
7 | height?: number;
8 | className?: string;
9 | };
10 |
11 | const Icon = ({ id, width = 20, height = 20, className, ...props }: Props) => {
12 | const c = cx('icon', id, className);
13 | const href = '#' + id;
14 | return (
15 |
18 | );
19 | };
20 |
21 | export default React.memo(Icon);
22 |
--------------------------------------------------------------------------------
/src/components/Input.module.scss:
--------------------------------------------------------------------------------
1 | .input {
2 | appearance: none;
3 | -webkit-appearance: none;
4 | background-color: var(--color-input-bg);
5 | background-image: none;
6 | border-radius: 4px;
7 | border: 1px solid var(--color-input-border);
8 | box-sizing: border-box;
9 | color: inherit;
10 | display: inline-block;
11 | font-size: inherit;
12 | height: 40px;
13 | outline: none;
14 | padding: 0 8px;
15 | width: 100%;
16 | }
17 |
18 | .input:focus {
19 | box-shadow: rgba(66, 153, 225, 0.6) 0px 0px 0px 3px;
20 | }
21 |
22 | input::-webkit-outer-spin-button,
23 | input::-webkit-inner-spin-button {
24 | -webkit-appearance: none;
25 | margin: 0;
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/Input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import s0 from './Input.module.scss';
4 |
5 | const { useState, useRef, useEffect, useCallback } = React;
6 |
7 | type InputProps = {
8 | value?: string | number;
9 | type?: string;
10 | onChange?: React.ChangeEventHandler;
11 | onBlur?: React.FocusEventHandler;
12 | name?: string;
13 | placeholder?: string;
14 | };
15 |
16 | export default function Input(props: InputProps) {
17 | return ;
18 | }
19 |
20 | export function SelfControlledInput({ value, ...restProps }: InputProps) {
21 | const [internalValue, setInternalValue] = useState(value);
22 | const refValue = useRef(value);
23 | useEffect(() => {
24 | if (refValue.current !== value) {
25 | // ideally we should only do this when this input is not focused
26 | setInternalValue(value);
27 | }
28 | refValue.current = value;
29 | }, [value]);
30 | const onChange = useCallback(
31 | (e: React.ChangeEvent) => setInternalValue(e.target.value),
32 | [setInternalValue],
33 | );
34 |
35 | return ;
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/Loading.module.scss:
--------------------------------------------------------------------------------
1 | .loading {
2 | width: 100%;
3 | height: 100%;
4 | display: flex;
5 | justify-content: center;
6 | align-items: center;
7 | }
8 |
9 | .spinner {
10 | width: 20px;
11 | height: 20px;
12 | display: inline-block;
13 | vertical-align: middle;
14 | animation: rotate 1s steps(12, end) infinite;
15 | background: transparent
16 | url("data:image/svg+xml;charset=utf8, %3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 100 100'%3E%3Cpath fill='none' d='M0 0h100v100H0z'/%3E%3Crect width='7' height='20' x='46.5' y='40' fill='%23E9E9E9' rx='5' ry='5' transform='translate(0 -30)'/%3E%3Crect width='7' height='20' x='46.5' y='40' fill='%23989697' rx='5' ry='5' transform='rotate(30 105.98 65)'/%3E%3Crect width='7' height='20' x='46.5' y='40' fill='%239B999A' rx='5' ry='5' transform='rotate(60 75.98 65)'/%3E%3Crect width='7' height='20' x='46.5' y='40' fill='%23A3A1A2' rx='5' ry='5' transform='rotate(90 65 65)'/%3E%3Crect width='7' height='20' x='46.5' y='40' fill='%23ABA9AA' rx='5' ry='5' transform='rotate(120 58.66 65)'/%3E%3Crect width='7' height='20' x='46.5' y='40' fill='%23B2B2B2' rx='5' ry='5' transform='rotate(150 54.02 65)'/%3E%3Crect width='7' height='20' x='46.5' y='40' fill='%23BAB8B9' rx='5' ry='5' transform='rotate(180 50 65)'/%3E%3Crect width='7' height='20' x='46.5' y='40' fill='%23C2C0C1' rx='5' ry='5' transform='rotate(-150 45.98 65)'/%3E%3Crect width='7' height='20' x='46.5' y='40' fill='%23CBCBCB' rx='5' ry='5' transform='rotate(-120 41.34 65)'/%3E%3Crect width='7' height='20' x='46.5' y='40' fill='%23D2D2D2' rx='5' ry='5' transform='rotate(-90 35 65)'/%3E%3Crect width='7' height='20' x='46.5' y='40' fill='%23DADADA' rx='5' ry='5' transform='rotate(-60 24.02 65)'/%3E%3Crect width='7' height='20' x='46.5' y='40' fill='%23E2E2E2' rx='5' ry='5' transform='rotate(-30 -5.98 65)'/%3E%3C/svg%3E")
17 | no-repeat;
18 | background-size: 100%;
19 | }
20 |
21 | @keyframes rotate {
22 | 0% {
23 | transform: rotate3d(0, 0, 1, 0deg);
24 | }
25 | 100% {
26 | transform: rotate3d(0, 0, 1, 360deg);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/Loading.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import s from './Loading.module.scss';
4 |
5 | type Props = {
6 | height?: string;
7 | };
8 |
9 | const Loading = ({ height }: Props) => {
10 | const style = height ? { height } : {};
11 | return (
12 |
15 | );
16 | };
17 |
18 | export default Loading;
19 |
--------------------------------------------------------------------------------
/src/components/Loading2.module.scss:
--------------------------------------------------------------------------------
1 | .lo {
2 | opacity: 0.5;
3 | width: 100%;
4 | height: 100%;
5 | display: flex;
6 | justify-content: center;
7 | align-items: center;
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/Loading2.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import s0 from './Loading2.module.scss';
4 | import SvgYacd from './SvgYacd';
5 |
6 | function Loading() {
7 | return (
8 |
9 |
10 |
11 | );
12 | }
13 |
14 | export default Loading;
15 |
--------------------------------------------------------------------------------
/src/components/LogSearch.ts:
--------------------------------------------------------------------------------
1 | import { getSearchText, updateSearchText } from '$src/store/logs';
2 | import { State } from '$src/store/types';
3 |
4 | import Search from './Search';
5 | import { connect } from './StateProvider';
6 |
7 | const mapState = (s: State) => ({ searchText: getSearchText(s), updateSearchText });
8 | export default connect(mapState)(Search);
9 |
--------------------------------------------------------------------------------
/src/components/Logs.module.scss:
--------------------------------------------------------------------------------
1 | .logMeta {
2 | display: flex;
3 | align-items: center;
4 | flex-wrap: wrap;
5 | font-size: 0.9em;
6 | }
7 |
8 | .logType {
9 | color: #eee;
10 | flex-shrink: 0;
11 | text-align: center;
12 | width: 66px;
13 | border-radius: 100px;
14 | padding: 3px 5px;
15 | margin: 0 8px;
16 | }
17 |
18 | .logTime {
19 | flex-shrink: 0;
20 | color: #999;
21 | font-size: 14px;
22 | }
23 |
24 | .logText {
25 | flex-shrink: 0;
26 | display: flex;
27 | font-family: 'Roboto Mono', Menlo, monospace;
28 | align-items: center;
29 | padding: 8px 0;
30 | /* force wrap */
31 | width: 100%;
32 | white-space: pre;
33 | overflow: auto;
34 | }
35 |
36 | /*******************/
37 |
38 | .logsWrapper {
39 | margin: 0;
40 | padding: 0;
41 | color: var(--color-text);
42 |
43 | :global {
44 | .log {
45 | padding: 10px 40px;
46 | background: var(--color-background);
47 | }
48 | .log.even {
49 | background: var(--color-background);
50 | }
51 | }
52 | }
53 |
54 | /*******************/
55 |
56 | .logPlaceholder {
57 | display: flex;
58 | flex-direction: column;
59 | align-items: center;
60 | justify-content: center;
61 | color: #2d2d30;
62 |
63 | div:nth-child(2) {
64 | color: var(--color-text-secondary);
65 | font-size: 1.4em;
66 | opacity: 0.6;
67 | }
68 | }
69 |
70 | .logPlaceholderIcon {
71 | opacity: 0.3;
72 | }
73 |
74 | .search {
75 | max-width: 1000px;
76 | }
77 |
--------------------------------------------------------------------------------
/src/components/Modal.module.scss:
--------------------------------------------------------------------------------
1 | .overlay {
2 | position: fixed;
3 | top: 0;
4 | right: 0;
5 | left: 0;
6 | bottom: 0;
7 | background: #444;
8 | }
9 |
10 | .content {
11 | outline: none;
12 | position: relative;
13 | color: var(--color-text);
14 | background: #444;
15 | top: 50%;
16 | left: 50%;
17 | transform: translate(-50%, -50%);
18 | padding: 20px;
19 | border-radius: 10px;
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/Modal.tsx:
--------------------------------------------------------------------------------
1 | import cx from 'clsx';
2 | import * as React from 'react';
3 | import Modal, { Props as ReactModalProps } from 'react-modal';
4 |
5 | import s0 from './Modal.module.scss';
6 |
7 | type Props = ReactModalProps & {
8 | isOpen: boolean;
9 | onRequestClose: (...args: any[]) => any;
10 | children: React.ReactNode;
11 | className?: string;
12 | overlayClassName?: string;
13 | };
14 |
15 | function ModalAPIConfig({
16 | isOpen,
17 | onRequestClose,
18 | className,
19 | overlayClassName,
20 | children,
21 | ...otherProps
22 | }: Props) {
23 | const contentCls = cx(className, s0.content);
24 | const overlayCls = cx(overlayClassName, s0.overlay);
25 | return (
26 |
33 | {children}
34 |
35 | );
36 | }
37 |
38 | export default React.memo(ModalAPIConfig);
39 |
--------------------------------------------------------------------------------
/src/components/ModalCloseAllConnections.module.scss:
--------------------------------------------------------------------------------
1 | .overlay {
2 | background-color: rgba(0, 0, 0, 0.6);
3 | }
4 | .cnt {
5 | background-color: var(--bg-modal);
6 | color: var(--color-text);
7 | max-width: 300px;
8 | line-height: 1.4;
9 | transform: translate(-50%, -50%) scale(1.2);
10 | opacity: 0.6;
11 | transition: all 0.3s ease;
12 | }
13 | .afterOpen {
14 | opacity: 1;
15 | transform: translate(-50%, -50%) scale(1);
16 | }
17 |
18 | .btngrp {
19 | display: flex;
20 | align-items: center;
21 | justify-content: center;
22 | margin-top: 30px;
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/ModalCloseAllConnections.tsx:
--------------------------------------------------------------------------------
1 | import cx from 'clsx';
2 | import React from 'react';
3 | import Modal from 'react-modal';
4 |
5 | import Button from './Button';
6 | import modalStyle from './Modal.module.scss';
7 | import s from './ModalCloseAllConnections.module.scss';
8 |
9 | const { useRef, useCallback, useMemo } = React;
10 |
11 | export default function Comp({
12 | isOpen,
13 | onRequestClose,
14 | primaryButtonOnTap,
15 | }: {
16 | isOpen: boolean;
17 | onRequestClose: (x: any) => void;
18 | primaryButtonOnTap: () => void;
19 | }) {
20 | const primaryButtonRef = useRef(null);
21 | const onAfterOpen = useCallback(() => {
22 | primaryButtonRef.current.focus();
23 | }, []);
24 | const className = useMemo(
25 | () => ({
26 | base: cx(modalStyle.content, s.cnt),
27 | afterOpen: s.afterOpen,
28 | beforeClose: '',
29 | }),
30 | [],
31 | );
32 | return (
33 |
40 | Are you sure you want to close all connections?
41 |
42 |
45 | {/* im lazy :) */}
46 |
47 |
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/Root.module.scss:
--------------------------------------------------------------------------------
1 | .app {
2 | position: fixed;
3 | top: 0;
4 | bottom: 0;
5 | left: 0;
6 | right: 0;
7 |
8 | display: flex;
9 | align-items: stretch;
10 |
11 | background: var(--color-background);
12 | color: var(--color-text);
13 |
14 | @media (max-width: 768px) {
15 | flex-direction: column;
16 | }
17 | }
18 |
19 | .content {
20 | flex-grow: 1;
21 | overflow: auto;
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/Root.tsx:
--------------------------------------------------------------------------------
1 | import './Root.scss';
2 |
3 | import { QueryClientProvider } from '@tanstack/react-query';
4 | import cx from 'clsx';
5 | import { useAtom } from 'jotai';
6 | import * as React from 'react';
7 | import { ErrorBoundary, ErrorBoundaryProps } from 'react-error-boundary';
8 | import { RouteObject } from 'react-router';
9 | import { HashRouter as Router, useRoutes } from 'react-router-dom';
10 | import { Toaster } from 'sonner';
11 | import { About } from 'src/components/about/About';
12 | import Loading from 'src/components/Loading';
13 | import { Head } from 'src/components/shared/Head';
14 | import { queryClient } from 'src/misc/query';
15 |
16 | import { AppConfigSideEffect } from '$src/components/fn/AppConfigSideEffect';
17 | import { ENDPOINT } from '$src/misc/constants';
18 | import { darkModePureBlackToggleAtom } from '$src/store/app';
19 |
20 | import { actions, initialState } from '../store';
21 | import { Backend } from './backend/Backend';
22 | import { MutableConnRefCtx } from './conns/ConnCtx';
23 | import { ErrorFallback } from './error/ErrorFallback';
24 | import { BackendBeacon } from './fn/BackendBeacon';
25 | import Home from './Home';
26 | import Loading2 from './Loading2';
27 | import s0 from './Root.module.scss';
28 | import SideBar from './SideBar';
29 | import StateProvider from './StateProvider';
30 |
31 | const { lazy, Suspense } = React;
32 |
33 | const Connections = lazy(() => import('./Connections'));
34 | const Config = lazy(() => import('./Config'));
35 | const Logs = lazy(() => import('./Logs'));
36 | const Proxies = lazy(() => import('./proxies/Proxies'));
37 | const Rules = lazy(() => import('./Rules'));
38 | const StyleGuide = lazy(() => import('$src/components/style/StyleGuide'));
39 |
40 | const routes = [
41 | { path: '/', element: },
42 | {
43 | path: '/connections',
44 | element: (
45 |
46 |
47 |
48 | ),
49 | },
50 | { path: '/configs', element: },
51 | { path: '/logs', element: },
52 | { path: '/proxies', element: },
53 | { path: '/rules', element: },
54 | { path: '/about', element: },
55 | process.env.NODE_ENV === 'development' ? { path: '/style', element: } : false,
56 | ].filter(Boolean) as RouteObject[];
57 |
58 | function RouteInnerApp() {
59 | return useRoutes(routes);
60 | }
61 |
62 | function SideBarApp() {
63 | return (
64 |
65 |
66 |
67 |
68 | }>
69 |
70 |
71 |
72 |
73 | );
74 | }
75 |
76 | function App() {
77 | return useRoutes([
78 | { path: '/backend', element: },
79 | { path: '*', element: },
80 | ]);
81 | }
82 |
83 | function AppShell({ children }: { children: React.ReactNode }) {
84 | const [pureBlackDark] = useAtom(darkModePureBlackToggleAtom);
85 | const clazz = cx({ pureBlackDark });
86 | return (
87 | <>
88 |
89 | {children}
90 | >
91 | );
92 | }
93 |
94 | const onErrorReset: ErrorBoundaryProps['onReset'] = (_details) => {
95 | queryClient.invalidateQueries([ENDPOINT.config]);
96 | };
97 |
98 | const Root = () => (
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 | }>
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | );
115 |
116 | export default Root;
117 |
--------------------------------------------------------------------------------
/src/components/Rule.module.scss:
--------------------------------------------------------------------------------
1 | .rule {
2 | display: flex;
3 | align-items: center;
4 | padding: 6px 15px;
5 | @media screen and (min-width: 30em) {
6 | padding: 10px 40px;
7 | }
8 | }
9 |
10 | .left {
11 | width: 40px;
12 | padding-right: 15px;
13 | color: var(--color-text-secondary);
14 | opacity: 0.4;
15 | }
16 |
17 | .a {
18 | display: flex;
19 | align-items: center;
20 | font-size: 12px;
21 | opacity: 0.8;
22 | }
23 |
24 | .b {
25 | padding: 10px 0;
26 | font-family: 'Roboto Mono', Menlo, monospace;
27 | font-size: 16px;
28 | @media screen and (min-width: 30em) {
29 | font-size: 19px;
30 | }
31 | }
32 |
33 | .type {
34 | width: 110px;
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/Rule.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import s0 from './Rule.module.scss';
4 |
5 | const colorMap = {
6 | _default: '#59caf9',
7 | DIRECT: '#f5bc41',
8 | REJECT: '#cb3166',
9 | };
10 |
11 | function getStyleFor({ proxy }) {
12 | let color = colorMap._default;
13 | if (colorMap[proxy]) {
14 | color = colorMap[proxy];
15 | }
16 | return { color };
17 | }
18 |
19 | type Props = {
20 | id?: number;
21 | type?: string;
22 | payload?: string;
23 | proxy?: string;
24 | };
25 |
26 | function Rule({ type, payload, proxy, id }: Props) {
27 | const styleProxy = getStyleFor({ proxy });
28 | return (
29 |
30 |
{id}
31 |
32 |
{payload}
33 |
34 |
{type}
35 |
{proxy}
36 |
37 |
38 |
39 | );
40 | }
41 |
42 | export default Rule;
43 |
--------------------------------------------------------------------------------
/src/components/Rules.module.scss:
--------------------------------------------------------------------------------
1 | .header {
2 | display: grid;
3 | grid-template-columns: 1fr minmax(auto, 330px);
4 | align-items: center;
5 |
6 | /*
7 | * the content header has some padding
8 | * we need to apply some right padding to this container then
9 | */
10 | padding-right: 15px;
11 | @media screen and (min-width: 30em) {
12 | padding-right: 40px;
13 | }
14 | }
15 |
16 | .RuleProviderItemWrapper {
17 | padding: 6px 15px;
18 | @media screen and (min-width: 30em) {
19 | padding: 10px 40px;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/Rules.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { areEqual, VariableSizeList } from 'react-window';
4 |
5 | import { RuleProviderItem } from '$src/components/rules/RuleProviderItem';
6 | import { useRuleAndProvider } from '$src/components/rules/rules.hooks';
7 | import { RulesPageFab } from '$src/components/rules/RulesPageFab';
8 | import { TextFilter } from '$src/components/shared/TextFilter';
9 | import { useApiConfig } from '$src/store/app';
10 | import { ruleFilterTextAtom } from '$src/store/rules';
11 | import { ClashAPIConfig, RuleType } from '$src/types';
12 |
13 | import useRemainingViewPortHeight from '../hooks/useRemainingViewPortHeight';
14 | import { ContentHeader } from './ContentHeader';
15 | import Rule from './Rule';
16 | import s from './Rules.module.scss';
17 |
18 | const { memo } = React;
19 |
20 | const paddingBottom = 30;
21 |
22 | type ItemData = {
23 | rules: any[];
24 | provider: any;
25 | apiConfig: ClashAPIConfig;
26 | };
27 |
28 | function itemKey(index: number, { rules, provider }: ItemData) {
29 | const providerQty = provider.names.length;
30 |
31 | if (index < providerQty) {
32 | return provider.names[index];
33 | }
34 | const item = rules[index - providerQty];
35 | return item.id;
36 | }
37 |
38 | function getItemSizeFactory({ provider }) {
39 | return function getItemSize(idx: number) {
40 | const providerQty = provider.names.length;
41 | if (idx < providerQty) {
42 | // provider
43 | return 110;
44 | }
45 | // rule
46 | return 80;
47 | };
48 | }
49 |
50 | type RowProps = {
51 | index: number;
52 | style: React.CSSProperties;
53 | data: {
54 | apiConfig: ClashAPIConfig;
55 | rules: RuleType[];
56 | provider: { names: string[]; byName: any };
57 | };
58 | };
59 |
60 | const Row = memo(({ index, style, data }: RowProps) => {
61 | const { rules, provider, apiConfig } = data;
62 | const providerQty = provider.names.length;
63 |
64 | if (index < providerQty) {
65 | const name = provider.names[index];
66 | const item = provider.byName[name];
67 | return (
68 |
69 |
70 |
71 | );
72 | }
73 |
74 | const r = rules[index - providerQty];
75 | return (
76 |
77 |
78 |
79 | );
80 | }, areEqual);
81 |
82 | Row.displayName = 'MemoRow';
83 |
84 | export default function Rules() {
85 | const apiConfig = useApiConfig();
86 | const [refRulesContainer, containerHeight] = useRemainingViewPortHeight();
87 | const { rules, provider } = useRuleAndProvider(apiConfig);
88 | const getItemSize = getItemSizeFactory({ provider });
89 |
90 | const { t } = useTranslation();
91 |
92 | return (
93 |
94 |
95 |
96 |
97 |
98 |
99 |
107 | {Row}
108 |
109 |
110 | {provider && provider.names && provider.names.length > 0 ? (
111 |
112 | ) : null}
113 |
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/src/components/Search.module.scss:
--------------------------------------------------------------------------------
1 | .RuleSearch {
2 | padding: 0 40px 5px;
3 | }
4 |
5 | .RuleSearchContainer {
6 | position: relative;
7 | height: 40px;
8 | }
9 |
10 | .inputWrapper {
11 | position: absolute;
12 | top: 50%;
13 | transform: translateY(-50%);
14 | left: 0;
15 | width: 100%;
16 | }
17 |
18 | .input {
19 | appearance: none;
20 | -webkit-appearance: none;
21 | background-color: var(--color-input-bg);
22 | background-image: none;
23 | border-radius: 20px;
24 | border: 1px solid var(--color-input-border);
25 | box-sizing: border-box;
26 | color: #c1c1c1;
27 | display: inline-block;
28 | font-size: inherit;
29 | height: 40px;
30 | outline: none;
31 | padding: 0 15px 0 35px;
32 | transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
33 | width: 100%;
34 | }
35 |
36 | .iconWrapper {
37 | position: absolute;
38 | top: 50%;
39 | transform: translateY(-50%);
40 | left: 10px;
41 | display: flex;
42 | justify-content: center;
43 | align-items: center;
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/Search.tsx:
--------------------------------------------------------------------------------
1 | import debounce from 'lodash-es/debounce';
2 | import React, { useCallback, useMemo, useState } from 'react';
3 | import { Search as SearchIcon } from 'react-feather';
4 |
5 | import { DispatchFn } from '$src/store/types';
6 |
7 | import s0 from './Search.module.scss';
8 |
9 | function RuleSearch({
10 | dispatch,
11 | searchText,
12 | updateSearchText,
13 | }: {
14 | dispatch: DispatchFn;
15 | searchText: string;
16 | updateSearchText: (x: string) => any;
17 | }) {
18 | const [text, setText] = useState(searchText);
19 | const updateSearchTextInternal = useCallback(
20 | (v: string) => {
21 | dispatch(updateSearchText(v));
22 | },
23 | [dispatch, updateSearchText],
24 | );
25 | const updateSearchTextDebounced = useMemo(
26 | () => debounce(updateSearchTextInternal, 300),
27 | [updateSearchTextInternal],
28 | );
29 | const onChange = (e: React.ChangeEvent) => {
30 | setText(e.target.value);
31 | updateSearchTextDebounced(e.target.value);
32 | };
33 |
34 | return (
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
48 | export default RuleSearch;
49 |
--------------------------------------------------------------------------------
/src/components/Selection.module.scss:
--------------------------------------------------------------------------------
1 | .fieldset {
2 | margin: 0;
3 | padding: 0;
4 | border: 0;
5 | display: flex;
6 | flex-wrap: wrap;
7 | }
8 |
9 | .input + .cnt {
10 | border: 1px solid transparent;
11 | border-radius: 8px;
12 | cursor: pointer;
13 | margin-right: 5px;
14 | margin-bottom: 5px;
15 | }
16 |
17 | .input:focus + .cnt {
18 | border-color: #387cec;
19 | }
20 |
21 | .input:checked + .cnt {
22 | border-color: #387cec;
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/Selection.tsx:
--------------------------------------------------------------------------------
1 | import cx from 'clsx';
2 | import React from 'react';
3 |
4 | import s from './Selection.module.scss';
5 |
6 | type SelectionProps = {
7 | OptionComponent?: (...args: any[]) => any;
8 | optionPropsList?: any[];
9 | selectedIndex?: number;
10 | onChange?: (...args: any[]) => any;
11 | };
12 |
13 | export function Selection2({
14 | OptionComponent,
15 | optionPropsList,
16 | selectedIndex,
17 | onChange,
18 | }: SelectionProps) {
19 | const inputCx = cx('visually-hidden', s.input);
20 | const onInputChange = (e: React.ChangeEvent) => {
21 | onChange(e.target.value);
22 | };
23 | return (
24 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/SideBar.module.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | display: flex;
3 | flex-direction: column;
4 | padding: 8px;
5 | @media (max-width: 768px) {
6 | padding: 0;
7 | }
8 | }
9 |
10 | .logoPlaceholder {
11 | height: 15px;
12 | @media (max-width: 768px) {
13 | display: none;
14 | }
15 | }
16 |
17 | .rows {
18 | flex: 1;
19 | @media (max-width: 768px) {
20 | display: flex;
21 | justify-content: space-between;
22 | overflow: auto;
23 | }
24 | }
25 |
26 | /* a router link */
27 | .row {
28 | color: var(--color-text);
29 | text-decoration: none;
30 | border-radius: 1000px;
31 |
32 | display: flex;
33 | align-items: center;
34 | padding: 6px 16px;
35 | @media screen and (min-width: 30em) {
36 | padding: 8px 20px;
37 | }
38 |
39 | @media (max-width: 768px) {
40 | border-radius: 0;
41 | }
42 |
43 | @media (max-width: 768px) {
44 | flex-direction: column;
45 | }
46 |
47 | svg {
48 | color: var(--color-icon);
49 | width: 22px;
50 | height: 22px;
51 |
52 | @media screen and (min-width: 30em) {
53 | width: 24px;
54 | height: 24px;
55 | }
56 | }
57 | }
58 |
59 | .rowActive {
60 | --bg: hsla(217deg, 83%, 57%, 0.2);
61 | --fg: hsl(217deg 83% 57%);
62 | color: var(--fg);
63 | background: var(--bg);
64 |
65 | @media (max-width: 768px) {
66 | background: none;
67 | }
68 | }
69 |
70 | .label {
71 | padding-left: 14px;
72 | font-size: 0.75em;
73 | white-space: nowrap;
74 | @media (max-width: 768px) {
75 | padding-left: 0;
76 | padding-top: 5px;
77 | }
78 |
79 | @media screen and (min-width: 30em) {
80 | font-size: 1em;
81 | }
82 | }
83 |
84 | .footer {
85 | display: flex;
86 | flex-direction: column;
87 | align-items: center;
88 | }
89 |
90 | @media (max-width: 768px) {
91 | .footer {
92 | display: none;
93 | }
94 | }
95 |
96 | .iconWrapper {
97 | --sz: 40px;
98 |
99 | width: var(--sz);
100 | height: var(--sz);
101 | display: flex;
102 | justify-content: center;
103 | align-items: center;
104 |
105 | outline: none;
106 | padding: 5px;
107 | color: var(--color-text);
108 | border-radius: 100%;
109 | border: 1px solid transparent;
110 | }
111 | .iconWrapper:hover {
112 | opacity: 0.6;
113 | }
114 | .iconWrapper:focus {
115 | border-color: var(--color-focus-blue);
116 | }
117 |
--------------------------------------------------------------------------------
/src/components/SideBar.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip } from '@reach/tooltip';
2 | import cx from 'clsx';
3 | import * as React from 'react';
4 | import { Info } from 'react-feather';
5 | import { useTranslation } from 'react-i18next';
6 | import { FcAreaChart, FcDocument, FcGlobe, FcLink, FcRuler, FcSettings } from 'react-icons/fc';
7 | import { Link, useLocation } from 'react-router-dom';
8 |
9 | import { ThemeSwitcher } from './shared/ThemeSwitcher';
10 | import s from './SideBar.module.scss';
11 |
12 | const icons = {
13 | activity: FcAreaChart,
14 | globe: FcGlobe,
15 | command: FcRuler,
16 | file: FcDocument,
17 | settings: FcSettings,
18 | link: FcLink,
19 | };
20 |
21 | const SideBarRow = React.memo(function SideBarRow({
22 | isActive,
23 | to,
24 | iconId,
25 | labelText,
26 | }: SideBarRowProps) {
27 | const Comp = icons[iconId];
28 | const className = cx(s.row, isActive ? s.rowActive : null);
29 | return (
30 |
31 |
32 | {labelText}
33 |
34 | );
35 | });
36 |
37 | interface SideBarRowProps {
38 | isActive: boolean;
39 | to: string;
40 | iconId?: string;
41 | labelText?: string;
42 | }
43 |
44 | const pages = [
45 | { to: '/', iconId: 'activity', labelText: 'Overview' },
46 | { to: '/proxies', iconId: 'globe', labelText: 'Proxies' },
47 | { to: '/rules', iconId: 'command', labelText: 'Rules' },
48 | { to: '/connections', iconId: 'link', labelText: 'Conns' },
49 | { to: '/configs', iconId: 'settings', labelText: 'Config' },
50 | { to: '/logs', iconId: 'file', labelText: 'Logs' },
51 | ];
52 |
53 | export default function SideBar() {
54 | const { t } = useTranslation();
55 | const location = useLocation();
56 | return (
57 |
58 |
59 |
60 | {pages.map(({ to, iconId, labelText }) => (
61 |
68 | ))}
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/src/components/StateProvider.tsx:
--------------------------------------------------------------------------------
1 | import * as immer from 'immer';
2 | import React, { ReactNode } from 'react';
3 |
4 | import { State } from '$src/store/types';
5 |
6 | // in logs store we update logs in place
7 | // outside of immer produce
8 | // this is just workaround
9 | immer.setAutoFreeze(false);
10 |
11 | const { createContext, memo, useMemo, useRef, useEffect, useCallback, useContext, useState } =
12 | React;
13 |
14 | export { immer };
15 |
16 | const StateContext = createContext(null);
17 | const DispatchContext = createContext(null);
18 | const ActionsContext = createContext(null);
19 |
20 | export function useStoreState() {
21 | return useContext(StateContext);
22 | }
23 |
24 | export function useStoreDispatch() {
25 | return useContext(DispatchContext);
26 | }
27 |
28 | export function useStoreActions() {
29 | return useContext(ActionsContext);
30 | }
31 |
32 | // boundActionCreators
33 | export default function Provider({
34 | initialState,
35 | actions = {},
36 | children,
37 | }: {
38 | initialState: Partial;
39 | actions: any;
40 | children: ReactNode;
41 | }) {
42 | const stateRef = useRef(initialState);
43 | const [state, setState] = useState(initialState);
44 | const getState = useCallback(() => stateRef.current, []);
45 | useEffect(() => {
46 | if (process.env.NODE_ENV === 'development') {
47 | (window as any).getState2 = getState;
48 | }
49 | }, [getState]);
50 | const dispatch = useCallback(
51 | (actionId: string | ((a: any, b: any) => any), fn: (s: any) => void) => {
52 | if (typeof actionId === 'function') return actionId(dispatch, getState);
53 |
54 | const stateNext = immer.produce(getState(), fn);
55 | if (stateNext !== stateRef.current) {
56 | if (process.env.NODE_ENV === 'development') {
57 | // eslint-disable-next-line no-console
58 | console.log(actionId, stateNext);
59 | }
60 | stateRef.current = stateNext;
61 | setState(stateNext);
62 | }
63 | },
64 | [getState],
65 | );
66 | const boundActions = useMemo(() => bindActions(actions, dispatch), [actions, dispatch]);
67 |
68 | return (
69 |
70 |
71 | {children}
72 |
73 |
74 | );
75 | }
76 |
77 | export function connect(mapStateToProps: any) {
78 | return (Component: any) => {
79 | const MemoComponent = memo(Component);
80 | function Connected(props: any) {
81 | const state = useContext(StateContext);
82 | const dispatch = useContext(DispatchContext);
83 | const mapped = mapStateToProps(state, props);
84 | const nextProps = { dispatch, ...props, ...mapped };
85 | return ;
86 | }
87 | return Connected;
88 | };
89 | }
90 |
91 | // steal from https://github.com/reduxjs/redux/blob/master/src/bindActionCreators.ts
92 | function bindAction(action: any, dispatch: any) {
93 | return function (...args: any[]) {
94 | return dispatch(action.apply(this, args));
95 | };
96 | }
97 |
98 | function bindActions(actions: any, dispatch: any) {
99 | const boundActions = {};
100 | for (const key in actions) {
101 | const action = actions[key];
102 | if (typeof action === 'function') {
103 | boundActions[key] = bindAction(action, dispatch);
104 | } else if (typeof action === 'object') {
105 | boundActions[key] = bindActions(action, dispatch);
106 | }
107 | }
108 | return boundActions;
109 | }
110 |
--------------------------------------------------------------------------------
/src/components/SvgYacd.module.scss:
--------------------------------------------------------------------------------
1 | .path {
2 | stroke-dasharray: 890;
3 | stroke-dashoffset: 890;
4 | animation: dash 3s ease-in-out forwards normal infinite;
5 | }
6 |
7 | @keyframes dash {
8 | from {
9 | stroke-dashoffset: 890;
10 | }
11 | to {
12 | stroke-dashoffset: 0;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/SvgYacd.tsx:
--------------------------------------------------------------------------------
1 | import cx from 'clsx';
2 | import * as React from 'react';
3 |
4 | import s from './SvgYacd.module.scss';
5 |
6 | type Props = {
7 | width?: number;
8 | height?: number;
9 | animate?: boolean;
10 | c0?: string;
11 | c1?: string;
12 | shapeStroke?: string;
13 | eye?: string;
14 | eyeStroke?: string;
15 | mouth?: string;
16 | };
17 |
18 | export default function SvgYacd({
19 | width = 320,
20 | height = 320,
21 | animate = false,
22 | c0 = 'currentColor',
23 | shapeStroke = '#eee',
24 | eye = '#eee',
25 | eyeStroke = '#eee',
26 | mouth = '#eee',
27 | }: Props) {
28 | const faceClassName = cx({ [s.path]: animate });
29 | return (
30 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/ToggleSwitch.module.scss:
--------------------------------------------------------------------------------
1 | .ToggleSwitch {
2 | user-select: none;
3 | border-radius: 4px;
4 | border: 1px solid #525252;
5 | color: var(--color-text);
6 | background: var(--color-toggle-bg);
7 | display: flex;
8 | position: relative;
9 | outline: none;
10 |
11 | &:focus {
12 | border-color: var(--color-focus-blue);
13 | }
14 |
15 | input {
16 | position: absolute;
17 | left: 0;
18 | opacity: 0;
19 | }
20 |
21 | label {
22 | z-index: 2;
23 | display: flex;
24 | align-items: center;
25 | justify-content: center;
26 | padding: 10px 0;
27 | cursor: pointer;
28 | }
29 | }
30 |
31 | .slider {
32 | z-index: 1;
33 | position: absolute;
34 | display: block;
35 | left: 0;
36 | height: 100%;
37 | transition: left 0.2s ease-out;
38 | background: var(--color-toggle-selected);
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/ToggleSwitch.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useMemo } from 'react';
2 |
3 | import s0 from './ToggleSwitch.module.scss';
4 |
5 | type Props = {
6 | options?: any[];
7 | value?: string;
8 | name?: string;
9 | onChange?: (...args: any[]) => any;
10 | };
11 |
12 | function ToggleSwitch({ options, value, name, onChange }: Props) {
13 | const idxSelected = useMemo(() => options.map((o) => o.value).indexOf(value), [options, value]);
14 |
15 | const getPortionPercentage = useCallback(
16 | (idx: number) => {
17 | const w = Math.floor(100 / options.length);
18 | if (idx === options.length - 1) {
19 | return 100 - options.length * w + w;
20 | } else if (idx > -1) {
21 | return w;
22 | }
23 | },
24 | [options],
25 | );
26 |
27 | const sliderStyle = useMemo(() => {
28 | return {
29 | width: getPortionPercentage(idxSelected) + '%',
30 | left: idxSelected * getPortionPercentage(0) + '%',
31 | };
32 | }, [idxSelected, getPortionPercentage]);
33 |
34 | return (
35 |
36 |
37 | {options.map((o, idx) => {
38 | const id = `${name}-${o.label}`;
39 | const className = idx === 0 ? '' : 'border-left';
40 | return (
41 |
59 | );
60 | })}
61 |
62 | );
63 | }
64 |
65 | export default React.memo(ToggleSwitch);
66 |
--------------------------------------------------------------------------------
/src/components/TrafficChart.tsx:
--------------------------------------------------------------------------------
1 | import { useAtom } from 'jotai';
2 | import * as React from 'react';
3 | import { useTranslation } from 'react-i18next';
4 |
5 | import { fetchData } from '../api/traffic';
6 | import useLineChart from '../hooks/useLineChart';
7 | import { chartJSResource, chartStyles, commonDataSetProps } from '../misc/chart';
8 | import { selectedChartStyleIndexAtom, useApiConfig } from '../store/app';
9 |
10 | const { useMemo } = React;
11 |
12 | const chartWrapperStyle: React.CSSProperties = {
13 | // make chartjs chart responsive
14 | position: 'relative',
15 | maxWidth: 1000,
16 | };
17 |
18 | export default function TrafficChart() {
19 | const [selectedChartStyleIndex] = useAtom(selectedChartStyleIndexAtom);
20 | const apiConfig = useApiConfig();
21 | const ChartMod = chartJSResource.read();
22 | const traffic = fetchData(apiConfig);
23 | const { t } = useTranslation();
24 | const data = useMemo(
25 | () => ({
26 | labels: traffic.labels,
27 | datasets: [
28 | {
29 | ...commonDataSetProps,
30 | ...chartStyles[selectedChartStyleIndex].up,
31 | label: t('Up'),
32 | data: traffic.up,
33 | },
34 | {
35 | ...commonDataSetProps,
36 | ...chartStyles[selectedChartStyleIndex].down,
37 | label: t('Down'),
38 | data: traffic.down,
39 | },
40 | ],
41 | }),
42 | [traffic, selectedChartStyleIndex, t],
43 | );
44 |
45 | useLineChart(ChartMod.Chart, 'trafficChart', data, traffic);
46 |
47 | return (
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/TrafficChartSample.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import useLineChart from '../hooks/useLineChart';
4 | import { chartJSResource, chartStyles, commonDataSetProps } from '../misc/chart';
5 |
6 | const { useMemo } = React;
7 |
8 | const extraChartOptions: import('chart.js').ChartOptions<'line'> = {
9 | plugins: {
10 | legend: { display: false },
11 | },
12 | scales: {
13 | x: { display: false, type: 'category' },
14 | y: { display: false, type: 'linear' },
15 | },
16 | };
17 |
18 | const data1 = [23e3, 35e3, 46e3, 33e3, 90e3, 68e3, 23e3, 45e3];
19 | const data2 = [184e3, 183e3, 196e3, 182e3, 190e3, 186e3, 182e3, 189e3];
20 | const labels = data1;
21 |
22 | export default function TrafficChart({ id }: { id: string }) {
23 | const ChartMod = chartJSResource.read();
24 |
25 | const data = useMemo(
26 | () => ({
27 | labels,
28 | datasets: [
29 | {
30 | ...commonDataSetProps,
31 | ...chartStyles[id].up,
32 | data: data1,
33 | },
34 | {
35 | ...commonDataSetProps,
36 | ...chartStyles[id].down,
37 | data: data2,
38 | },
39 | ],
40 | }),
41 | [id],
42 | );
43 |
44 | const eleId = 'chart-' + id;
45 | useLineChart(ChartMod.Chart, eleId, data, null, extraChartOptions);
46 |
47 | return (
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/TrafficNow.module.scss:
--------------------------------------------------------------------------------
1 | .TrafficNow {
2 | color: var(--color-text);
3 | display: flex;
4 | align-items: center;
5 | flex-wrap: wrap;
6 |
7 | display: grid;
8 | grid-template-columns: repeat(auto-fit, 180px);
9 | grid-gap: 10px;
10 |
11 | .sec {
12 | padding: 10px;
13 | background-color: var(--color-bg-card);
14 | border-radius: 10px;
15 | box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.1);
16 | div:nth-child(1) {
17 | color: var(--color-text-secondary);
18 | font-size: 0.7em;
19 | }
20 | div:nth-child(2) {
21 | padding: 10px 0 0;
22 | font-size: 1.8em;
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/TrafficNow.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useTranslation } from 'react-i18next';
3 |
4 | import { useApiConfig } from '$src/store/app';
5 | import { ClashAPIConfig } from '$src/types';
6 |
7 | import * as connAPI from '../api/connections';
8 | import { fetchData } from '../api/traffic';
9 | import prettyBytes from '../misc/pretty-bytes';
10 | import s0 from './TrafficNow.module.scss';
11 |
12 | const { useState, useEffect, useCallback } = React;
13 |
14 | export default function TrafficNow() {
15 | const apiConfig = useApiConfig();
16 | const { t } = useTranslation();
17 | const { upStr, downStr } = useSpeed(apiConfig);
18 | const { upTotal, dlTotal, connNumber } = useConnection(apiConfig);
19 | return (
20 |
21 |
22 |
{t('Upload')}
23 |
{upStr}
24 |
25 |
26 |
{t('Download')}
27 |
{downStr}
28 |
29 |
30 |
{t('Upload Total')}
31 |
{upTotal}
32 |
33 |
34 |
{t('Download Total')}
35 |
{dlTotal}
36 |
37 |
38 |
{t('Active Connections')}
39 |
{connNumber}
40 |
41 |
42 | );
43 | }
44 |
45 | function useSpeed(apiConfig: ClashAPIConfig) {
46 | const [speed, setSpeed] = useState({ upStr: '0 B/s', downStr: '0 B/s' });
47 | useEffect(() => {
48 | return fetchData(apiConfig).subscribe((o) =>
49 | setSpeed({
50 | upStr: prettyBytes(o.up) + '/s',
51 | downStr: prettyBytes(o.down) + '/s',
52 | }),
53 | );
54 | }, [apiConfig]);
55 | return speed;
56 | }
57 |
58 | function useConnection(apiConfig: ClashAPIConfig) {
59 | const [state, setState] = useState({
60 | upTotal: '0 B',
61 | dlTotal: '0 B',
62 | connNumber: 0,
63 | });
64 | const read = useCallback(
65 | ({ downloadTotal, uploadTotal, connections }) => {
66 | setState({
67 | upTotal: prettyBytes(uploadTotal),
68 | dlTotal: prettyBytes(downloadTotal),
69 | connNumber: connections.length,
70 | });
71 | },
72 | [setState],
73 | );
74 | useEffect(() => {
75 | return connAPI.fetchData(apiConfig, read);
76 | }, [apiConfig, read]);
77 | return state;
78 | }
79 |
--------------------------------------------------------------------------------
/src/components/about/About.module.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 6px 15px;
3 | @media screen and (min-width: 30em) {
4 | padding: 10px 40px;
5 | }
6 |
7 | p {
8 | margin: 5px 0;
9 | }
10 | }
11 |
12 | .mono {
13 | font-family: var(--font-mono);
14 | }
15 |
16 | .link {
17 | color: var(--color-text-secondary);
18 | display: inline-flex;
19 | gap: 5px;
20 | align-items: center;
21 | }
22 |
23 | .link:hover {
24 | color: var(--color-text-highlight);
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/about/About.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import * as React from 'react';
3 | import { fetchVersion } from 'src/api/version';
4 | import { ContentHeader } from 'src/components/ContentHeader';
5 | import { useApiConfig } from 'src/store/app';
6 |
7 | import { GitHubIcon } from '../icon/GitHubIcon';
8 | import s from './About.module.scss';
9 |
10 | function Version({ name, link, version }: { name: string; link: string; version: string }) {
11 | return (
12 |
25 | );
26 | }
27 |
28 | export function About() {
29 | const apiConfig = useApiConfig();
30 | const { data: version } = useQuery(['/version', apiConfig], fetchVersion);
31 | return (
32 | <>
33 |
34 | {version && version.version ? (
35 |
36 | ) : null}
37 |
38 | >
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/backend/Backend.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { BackendList } from '$src/components/backend/BackendList';
4 |
5 | import { Sep } from '../shared/Basic';
6 | import { ThemeSwitcher } from '../shared/ThemeSwitcher';
7 | import { BackendForm } from './BackendForm';
8 |
9 | export function Backend() {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/backend/BackendForm.module.scss:
--------------------------------------------------------------------------------
1 | .header {
2 | display: flex;
3 | justify-content: center;
4 | align-items: center;
5 | padding: 15px;
6 |
7 | .icon {
8 | --stroke: var(--color-text-secondary);
9 |
10 | color: #20497e;
11 | opacity: 0.4;
12 | transition: opacity 400ms;
13 | &:hover {
14 | opacity: 1;
15 | }
16 | }
17 | }
18 |
19 | .body {
20 | padding: 15px 0 0;
21 | }
22 |
23 | .hostnamePort {
24 | display: flex;
25 |
26 | div {
27 | flex: 1 1 auto;
28 | }
29 |
30 | div:nth-child(2) {
31 | flex-grow: 0;
32 | flex-basis: 120px;
33 | margin-left: 10px;
34 | }
35 | }
36 |
37 | .error {
38 | height: 20px;
39 | font-size: 0.8em;
40 | color: #ff8b8b;
41 | margin-bottom: 5px;
42 | }
43 |
44 | .footer {
45 | padding: 5px 0 10px;
46 | display: flex;
47 | justify-content: flex-end;
48 | align-items: center;
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/backend/BackendList.module.scss:
--------------------------------------------------------------------------------
1 | .ul {
2 | position: relative;
3 | margin: 0;
4 | padding: 0;
5 | list-style: none;
6 | line-height: 1.8;
7 |
8 | --width-max-content: 230px;
9 | }
10 |
11 | .li {
12 | position: relative;
13 | margin: 5px 0;
14 | padding: 10px 0;
15 | border-radius: 10px;
16 | display: grid;
17 | place-content: center;
18 | grid-template-columns: 40px 1fr;
19 | column-gap: 10px;
20 | border: 1px solid var(--bg-near-transparent);
21 |
22 | &.isSelected {
23 | border-color: #387cec;
24 | }
25 |
26 | .right {
27 | display: grid;
28 | column-gap: 10px;
29 | grid-template-columns: 1fr 40px;
30 | grid-auto-rows: 30px;
31 | }
32 | }
33 |
34 | .li:hover {
35 | background-color: var(--bg-near-transparent);
36 | }
37 |
38 | .close {
39 | opacity: 0;
40 | place-self: center;
41 | cursor: pointer;
42 | }
43 |
44 | .li:hover .close,
45 | .li:hover .eye {
46 | opacity: 1;
47 | }
48 | .close:focus,
49 | .eye:focus {
50 | opacity: 1;
51 | }
52 |
53 | .eye {
54 | opacity: 0;
55 | place-self: center;
56 | cursor: pointer;
57 | }
58 |
59 | .url,
60 | .secret,
61 | .metaLabel {
62 | white-space: nowrap;
63 | overflow: hidden;
64 | text-overflow: ellipsis;
65 | }
66 |
67 | .btn {
68 | outline: none;
69 | appearance: none;
70 | border: 1px solid transparent;
71 | background-color: transparent;
72 | color: inherit;
73 | display: flex;
74 | align-items: center;
75 | padding: 5px;
76 | border-radius: 100px;
77 | }
78 | .btn:focus {
79 | border-color: var(--color-focus-blue);
80 | }
81 | .btn:hover:enabled {
82 | background-color: var(--color-focus-blue);
83 | color: white;
84 | }
85 | .btn:active:enabled {
86 | transform: scale(0.97);
87 | }
88 | .btn:disabled {
89 | color: var(--color-text-secondary);
90 | }
91 |
92 | .url,
93 | .metaLabel {
94 | cursor: pointer;
95 | &:hover {
96 | color: var(--color-text-highlight);
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/components/conns/ConnCtx.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | const ref = {
4 | hasProcessPath: false,
5 | };
6 |
7 | export const MutableConnRefCtx = React.createContext(ref);
8 |
--------------------------------------------------------------------------------
/src/components/error/BackendErrorFallback.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import type { FallbackProps } from 'react-error-boundary';
3 | import { useTranslation } from 'react-i18next';
4 | import { useNavigate } from 'react-router-dom';
5 |
6 | import { SimplifiedResponse } from '$src/api/fetch';
7 | import { FetchCtx } from '$src/types';
8 |
9 | import Button from '../Button';
10 | import { Sep } from '../shared/Basic';
11 | import { ErrorFallbackLayout } from './ErrorFallbackLayout';
12 |
13 | function useStuff(resetErrorBoundary: FallbackProps['resetErrorBoundary']) {
14 | const { t } = useTranslation();
15 | const navigate = useNavigate();
16 | const onClick = useCallback(
17 | (e: React.MouseEvent) => {
18 | e.preventDefault();
19 | resetErrorBoundary();
20 | navigate('/backend');
21 | },
22 | [navigate, resetErrorBoundary],
23 | );
24 | return { t, onClick };
25 | }
26 |
27 | export function FetchNetworkErrorFallback(props: {
28 | ctx: FetchCtx;
29 | resetErrorBoundary: FallbackProps['resetErrorBoundary'];
30 | }) {
31 | const { resetErrorBoundary, ctx } = props;
32 | const { t, onClick } = useStuff(resetErrorBoundary);
33 | return (
34 |
35 | Failed to connect to the backend {ctx.apiConfig.baseURL}
36 |
37 |
38 |
39 | );
40 | }
41 |
42 | export function BackendUnauthorizedErrorFallback(props: {
43 | ctx: FetchCtx;
44 | resetErrorBoundary: FallbackProps['resetErrorBoundary'];
45 | }) {
46 | const { resetErrorBoundary, ctx } = props;
47 | const { t, onClick } = useStuff(resetErrorBoundary);
48 | return (
49 |
50 | Unauthorized to connect to the backend {ctx.apiConfig.baseURL}
51 | {ctx.apiConfig.secret ? (
52 | You might using a wrong secret
53 | ) : (
54 | You probably need to provide a secret
55 | )}
56 |
57 |
58 |
59 | );
60 | }
61 |
62 | export function BackendGeneralErrorFallback(props: {
63 | ctx: FetchCtx & { response: SimplifiedResponse };
64 | resetErrorBoundary: FallbackProps['resetErrorBoundary'];
65 | }) {
66 | const { resetErrorBoundary, ctx } = props;
67 | const { t, onClick } = useStuff(resetErrorBoundary);
68 | const { response } = ctx;
69 | return (
70 |
71 | Unexpected response from the backend {ctx.apiConfig.baseURL}
72 |
73 |
74 |
75 |
76 |
Response Status
77 |
{response.status}
78 |
Response Headers
79 |
80 | {response.headers.map((h) => {
81 | return - {h}
;
82 | })}
83 |
84 | {response.data ? (
85 | <>
86 |
Response Body
87 |
{response.data}
88 | >
89 | ) : null}
90 |
91 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/src/components/error/ErrorBoundaryFallback.module.scss:
--------------------------------------------------------------------------------
1 | .link {
2 | display: inline-flex;
3 | align-items: center;
4 |
5 | color: var(--color-text-secondary);
6 | &:hover,
7 | &:active {
8 | color: #387cec;
9 | }
10 |
11 | svg {
12 | margin-right: 5px;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/error/ErrorBoundaryFallback.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { ErrorFallbackLayout } from '$src/components/error/ErrorFallbackLayout';
4 |
5 | import { GitHubIcon } from '../icon/GitHubIcon';
6 | import sx from './ErrorBoundaryFallback.module.scss';
7 | const yacdRepoIssueUrl = 'https://github.com/haishanh/yacd/issues';
8 |
9 | type Props = {
10 | message?: string;
11 | detail?: string;
12 | };
13 |
14 | function ErrorBoundaryFallback({ message, detail }: Props) {
15 | return (
16 |
17 | {message ? {message}
: null}
18 | {detail ? {detail}
: null}
19 |
20 |
21 |
22 | haishanh/yacd
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | export default ErrorBoundaryFallback;
30 |
--------------------------------------------------------------------------------
/src/components/error/ErrorFallback.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { FallbackProps } from 'react-error-boundary';
3 |
4 | import {
5 | deriveMessageFromError,
6 | YacdBackendGeneralError,
7 | YacdBackendUnauthorizedError,
8 | YacdFetchNetworkError,
9 | } from '$src/misc/errors';
10 |
11 | import {
12 | BackendGeneralErrorFallback,
13 | BackendUnauthorizedErrorFallback,
14 | FetchNetworkErrorFallback,
15 | } from './BackendErrorFallback';
16 | import ErrorBoundaryFallback from './ErrorBoundaryFallback';
17 |
18 | export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
19 | if (error instanceof YacdFetchNetworkError) {
20 | return ;
21 | }
22 |
23 | if (error instanceof YacdBackendUnauthorizedError) {
24 | return (
25 |
26 | );
27 | }
28 |
29 | if (error instanceof YacdBackendGeneralError) {
30 | return ;
31 | }
32 |
33 | const { message, detail } = deriveMessageFromError(error);
34 | return ;
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/error/ErrorFallbackLayout.module.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | position: fixed;
3 | top: 0;
4 | bottom: 0;
5 | left: 0;
6 | right: 0;
7 | overflow: hidden;
8 |
9 | padding: 15px;
10 | background: var(--color-background);
11 | color: var(--color-text);
12 | text-align: center;
13 | }
14 |
15 | .yacd {
16 | color: #20497e;
17 | opacity: 0.4;
18 | display: flex;
19 | justify-content: center;
20 | align-items: center;
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/error/ErrorFallbackLayout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import SvgYacd from '../SvgYacd';
4 | import sx from './ErrorFallbackLayout.module.scss';
5 |
6 | export function ErrorFallbackLayout(props: { children: React.ReactNode }) {
7 | return (
8 |
9 |
10 |
19 |
20 | {props.children}
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/fn/AppConfigSideEffect.tsx:
--------------------------------------------------------------------------------
1 | import { useAtom } from 'jotai';
2 | import { useEffect } from 'react';
3 |
4 | import { saveState } from '$src/misc/storage';
5 | import { throttle } from '$src/misc/utils';
6 | import {
7 | autoCloseOldConnsAtom,
8 | clashAPIConfigsAtom,
9 | collapsibleIsOpenAtom,
10 | hideUnavailableProxiesAtom,
11 | latencyTestUrlAtom,
12 | logStreamingPausedAtom,
13 | proxySortByAtom,
14 | selectedChartStyleIndexAtom,
15 | selectedClashAPIConfigIndexAtom,
16 | themeAtom,
17 | } from '$src/store/app';
18 | import { StateApp } from '$src/store/types';
19 |
20 | let stateRef: StateApp;
21 |
22 | function save0() {
23 | if (stateRef) saveState(stateRef);
24 | }
25 |
26 | const save = throttle(save0, 500);
27 |
28 | export function AppConfigSideEffect() {
29 | const [selectedClashAPIConfigIndex] = useAtom(selectedClashAPIConfigIndexAtom);
30 | const [clashAPIConfigs] = useAtom(clashAPIConfigsAtom);
31 | const [latencyTestUrl] = useAtom(latencyTestUrlAtom);
32 | const [selectedChartStyleIndex] = useAtom(selectedChartStyleIndexAtom);
33 | const [theme] = useAtom(themeAtom);
34 | const [collapsibleIsOpen] = useAtom(collapsibleIsOpenAtom);
35 | const [proxySortBy] = useAtom(proxySortByAtom);
36 | const [hideUnavailableProxies] = useAtom(hideUnavailableProxiesAtom);
37 | const [autoCloseOldConns] = useAtom(autoCloseOldConnsAtom);
38 | const [logStreamingPaused] = useAtom(logStreamingPausedAtom);
39 | useEffect(() => {
40 | stateRef = {
41 | autoCloseOldConns,
42 | clashAPIConfigs,
43 | collapsibleIsOpen,
44 | hideUnavailableProxies,
45 | latencyTestUrl,
46 | logStreamingPaused,
47 | proxySortBy,
48 | selectedChartStyleIndex,
49 | selectedClashAPIConfigIndex,
50 | theme,
51 | };
52 | save();
53 | }, [
54 | autoCloseOldConns,
55 | clashAPIConfigs,
56 | collapsibleIsOpen,
57 | hideUnavailableProxies,
58 | latencyTestUrl,
59 | logStreamingPaused,
60 | proxySortBy,
61 | selectedChartStyleIndex,
62 | selectedClashAPIConfigIndex,
63 | theme,
64 | ]);
65 | return null;
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/fn/BackendBeacon.tsx:
--------------------------------------------------------------------------------
1 | import React, { Suspense } from 'react';
2 |
3 | import { useClashConfig } from '$src/store/configs';
4 |
5 | export function BackendBeacon() {
6 | return (
7 |
8 |
9 |
10 | );
11 | }
12 |
13 | function BackendBeaconCore() {
14 | useClashConfig();
15 | return null;
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/form/Toggle.module.scss:
--------------------------------------------------------------------------------
1 | .toggle {
2 | --height: 28px;
3 | --width: 44px;
4 | --knob-size: 24px;
5 |
6 | display: inline-flex;
7 | align-items: center;
8 | cursor: pointer;
9 | position: relative;
10 | width: var(--width);
11 | height: var(--height);
12 |
13 | .input {
14 | opacity: 0;
15 | width: 0;
16 |
17 | &:checked + .track {
18 | background-color: #047aff;
19 | }
20 |
21 | &:checked + .track:before {
22 | transform: translateX(calc(var(--width) - var(--height)));
23 | }
24 |
25 | &:disabled + .track {
26 | opacity: 0.8;
27 | cursor: not-allowed;
28 | }
29 |
30 | &:focus-visible + .track {
31 | outline: solid 1px var(--color-focus-blue);
32 | outline-offset: 2px;
33 | }
34 | }
35 |
36 | .track {
37 | width: var(--width);
38 | height: var(--height);
39 | position: absolute;
40 | cursor: pointer;
41 | top: 0;
42 | left: 0;
43 | bottom: 0;
44 | transition:
45 | transform 0.15s,
46 | background-color 0.15s;
47 | background: var(--bg-toggle-track);
48 | border-radius: 100px;
49 | &:before {
50 | position: absolute;
51 | content: '';
52 | width: var(--knob-size);
53 | height: var(--knob-size);
54 | background-color: #fff;
55 | transition: all 0.4s;
56 | border-radius: 100%;
57 | top: calc((var(--height) - var(--knob-size)) / 2);
58 | left: calc((var(--height) - var(--knob-size)) / 2);
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/form/Toggle.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useRef, useState } from 'react';
2 |
3 | import s0 from './Toggle.module.scss';
4 |
5 | type ToggleInputProps = {
6 | id: string;
7 | checked?: boolean;
8 | disabled?: boolean;
9 | onChange?: (v: boolean) => unknown;
10 | };
11 |
12 | export function ToggleInput({
13 | id,
14 | checked: theirChecked,
15 | disabled,
16 | onChange: theirOnChange,
17 | }: ToggleInputProps) {
18 | const [checked, setChecked] = useState(!!theirChecked);
19 | const theirCheckedRef = useRef(!!theirChecked);
20 | useEffect(() => {
21 | if (theirCheckedRef.current !== theirChecked) setChecked(!!theirChecked);
22 | theirCheckedRef.current = !!theirChecked;
23 | }, [theirChecked]);
24 | const onChange = useCallback(
25 | (e: boolean) => {
26 | if (disabled) return;
27 | setChecked(e);
28 | if (theirOnChange) theirOnChange(e);
29 | },
30 | [disabled, theirOnChange],
31 | );
32 | return (
33 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/icon/GitHubIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export function GitHubIcon(props: { width?: number; height?: number; size?: number }) {
4 | const width = props.width || props.size || 16;
5 | const height = props.height || props.size || 16;
6 | return (
7 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/proxies/ClosePrevConns.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Button from 'src/components/Button';
3 | import { FlexCenter } from 'src/components/shared/Styled';
4 |
5 | const { useRef, useEffect } = React;
6 |
7 | type Props = {
8 | onClickPrimaryButton?: () => void;
9 | onClickSecondaryButton?: () => void;
10 | };
11 |
12 | export function ClosePrevConns({ onClickPrimaryButton, onClickSecondaryButton }: Props) {
13 | const primaryButtonRef = useRef(null);
14 | const secondaryButtonRef = useRef(null);
15 | useEffect(() => {
16 | primaryButtonRef.current.focus();
17 | }, []);
18 |
19 | const handleKeyDown = (e: React.KeyboardEvent) => {
20 | if (e.code === 'ArrowRight') {
21 | secondaryButtonRef.current.focus();
22 | } else if (e.code === 'ArrowLeft') {
23 | primaryButtonRef.current.focus();
24 | }
25 | };
26 |
27 | return (
28 | // eslint-disable-next-line jsx-a11y/no-static-element-interactions
29 |
30 |
Close Connections?
31 |
32 | Click [Yes] to close those connections that are still using the old selected proxy in this
33 | group
34 |
35 |
36 |
37 |
40 |
41 |
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/proxies/Proxies.module.scss:
--------------------------------------------------------------------------------
1 | .topBar {
2 | position: sticky;
3 | top: 0;
4 |
5 | display: flex;
6 | align-items: center;
7 | justify-content: space-between;
8 | flex-wrap: wrap;
9 | z-index: 1;
10 | background-color: var(--color-background2);
11 | backdrop-filter: blur(36px);
12 | }
13 |
14 | .topBarRight {
15 | display: flex;
16 | align-items: center;
17 | flex-wrap: wrap;
18 | flex: 1;
19 | justify-content: flex-end;
20 |
21 | margin-right: 20px;
22 | }
23 |
24 | .textFilterContainer {
25 | max-width: 350px;
26 | min-width: 150px;
27 | flex: 1;
28 | margin-right: 8px;
29 | }
30 |
31 | .group {
32 | padding: 10px 15px;
33 | @media screen and (min-width: 30em) {
34 | padding: 10px 40px;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/proxies/Proxy.module.scss:
--------------------------------------------------------------------------------
1 | .proxy {
2 | margin: 3px;
3 | padding: 5px;
4 | position: relative;
5 | border-radius: 8px;
6 | overflow: hidden;
7 |
8 | display: flex;
9 | flex-direction: column;
10 | justify-content: space-between;
11 |
12 | outline: none;
13 | border: 1px solid transparent;
14 | &:focus {
15 | border: 1px solid var(--color-focus-blue);
16 | }
17 |
18 | max-width: 200px;
19 | @media screen and (min-width: 30em) {
20 | min-width: 200px;
21 | border-radius: 10px;
22 | padding: 10px;
23 | }
24 |
25 | background-color: var(--color-bg-proxy);
26 | &.now {
27 | background-color: var(--color-focus-blue);
28 | color: #ddd;
29 | }
30 | &.error {
31 | opacity: 0.5;
32 | }
33 | &.selectable {
34 | transition: transform 0.2s ease-in-out;
35 | cursor: pointer;
36 | &:hover {
37 | border-color: hsl(0deg, 0%, var(--card-hover-border-lightness));
38 | }
39 | }
40 | }
41 |
42 | .proxyType {
43 | font-family: var(--font-mono);
44 | font-size: 0.6em;
45 | margin-right: 3px;
46 | @media screen and (min-width: 30em) {
47 | font-size: 0.85em;
48 | }
49 | }
50 |
51 | .row {
52 | display: flex;
53 | align-items: center;
54 | justify-content: space-between;
55 | }
56 |
57 | .proxyName {
58 | width: 100%;
59 | margin-bottom: 5px;
60 | font-size: 0.8em;
61 | white-space: nowrap;
62 | overflow: hidden;
63 | text-overflow: ellipsis;
64 | }
65 |
66 | .proxySmall {
67 | --size: 13px;
68 | width: var(--size);
69 | height: var(--size);
70 | border-radius: 50%;
71 | position: relative;
72 |
73 | &.now {
74 | --size: 15px;
75 | &:before {
76 | --size-dot: 7px;
77 | content: '';
78 | position: absolute;
79 | width: var(--size-dot);
80 | height: var(--size-dot);
81 | background-color: #fff;
82 | // For non-primitive proxy type like "Selector", "LoadBalance", "DIRECT", etc. we are using a transparent
83 | // background, and this selected indicator has a white background. In "light" them mode, the contrast
84 | // between the bg of the indicator and the "background" is too small. In that case we want to add a
85 | // border around this indicator so it's more distinguishable.
86 | border: 1px solid var(--color-proxy-dot-selected-ind-bo);
87 | border-radius: 4px;
88 | top: 50%;
89 | left: 50%;
90 | transform: translate(-50%, -50%);
91 | }
92 | }
93 |
94 | &.selectable {
95 | transition: transform 0.1s ease-in-out;
96 | cursor: pointer;
97 | &:hover {
98 | transform: scale(1.2);
99 | }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/components/proxies/ProxyGroup.module.scss:
--------------------------------------------------------------------------------
1 | .header {
2 | margin-bottom: 12px;
3 | }
4 |
5 | .groupHead {
6 | display: flex;
7 | flex-wrap: wrap;
8 | align-items: center;
9 | }
10 |
11 | .action {
12 | margin: 0 5px;
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/proxies/ProxyGroup.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip } from '@reach/tooltip';
2 | import { useAtom } from 'jotai';
3 | import * as React from 'react';
4 |
5 | import Button from '$src/components/Button';
6 | import CollapsibleSectionHeader from '$src/components/CollapsibleSectionHeader';
7 | import { ZapAnimated } from '$src/components/shared/ZapAnimated';
8 | import { connect, useStoreActions } from '$src/components/StateProvider';
9 | import { useState2 } from '$src/hooks/basic';
10 | import {
11 | autoCloseOldConnsAtom,
12 | collapsibleIsOpenAtom,
13 | hideUnavailableProxiesAtom,
14 | proxySortByAtom,
15 | } from '$src/store/app';
16 | import { getProxies, switchProxy } from '$src/store/proxies';
17 | import { DelayMapping, DispatchFn, ProxiesMapping, State } from '$src/store/types';
18 | import { ClashAPIConfig } from '$src/types';
19 |
20 | import { useFilteredAndSorted } from './hooks';
21 | import s0 from './ProxyGroup.module.scss';
22 | import { ProxyList, ProxyListSummaryView } from './ProxyList';
23 |
24 | const { createElement, useCallback, useMemo } = React;
25 |
26 | type ProxyGroupImplProps = {
27 | name: string;
28 | all: string[];
29 | delay: DelayMapping;
30 | proxies: ProxiesMapping;
31 | type: string;
32 | now: string;
33 | apiConfig: ClashAPIConfig;
34 | dispatch: DispatchFn;
35 | };
36 |
37 | function ProxyGroupImpl({
38 | name,
39 | all: allItems,
40 | delay,
41 | proxies,
42 | type,
43 | now,
44 | apiConfig,
45 | dispatch,
46 | }: ProxyGroupImplProps) {
47 | const [collapsibleIsOpen, setCollapsibleIsOpen] = useAtom(collapsibleIsOpenAtom);
48 | const isOpen = collapsibleIsOpen[`proxyGroup:${name}`];
49 | const [proxySortBy] = useAtom(proxySortByAtom);
50 | const [hideUnavailableProxies] = useAtom(hideUnavailableProxiesAtom);
51 | const all = useFilteredAndSorted(allItems, delay, hideUnavailableProxies, proxySortBy, proxies);
52 | const isSelectable = useMemo(() => type === 'Selector', [type]);
53 | const {
54 | proxies: { requestDelayForProxies },
55 | } = useStoreActions();
56 | const updateCollapsibleIsOpen = useCallback(
57 | (prefix: string, name: string, v: boolean) => {
58 | setCollapsibleIsOpen((s) => ({ ...s, [`${prefix}:${name}`]: v }));
59 | },
60 | [setCollapsibleIsOpen],
61 | );
62 | const toggle = useCallback(() => {
63 | updateCollapsibleIsOpen('proxyGroup', name, !isOpen);
64 | }, [isOpen, updateCollapsibleIsOpen, name]);
65 | const [autoCloseOldConns] = useAtom(autoCloseOldConnsAtom);
66 | const itemOnTapCallback = useCallback(
67 | (proxyName: string) => {
68 | if (!isSelectable) return;
69 | dispatch(switchProxy(apiConfig, name, proxyName, autoCloseOldConns));
70 | },
71 | [apiConfig, dispatch, name, isSelectable, autoCloseOldConns],
72 | );
73 |
74 | const testingLatency = useState2(false);
75 | const testLatency = useCallback(async () => {
76 | if (testingLatency.value) return;
77 | testingLatency.set(true);
78 | try {
79 | await requestDelayForProxies(apiConfig, all);
80 | } catch (err) {}
81 | testingLatency.set(false);
82 | }, [all, apiConfig, requestDelayForProxies, testingLatency]);
83 |
84 | return (
85 |
86 |
87 |
94 |
95 |
96 |
99 |
100 |
101 |
102 | {createElement(isOpen ? ProxyList : ProxyListSummaryView, {
103 | all,
104 | now,
105 | isSelectable,
106 | itemOnTapCallback,
107 | })}
108 |
109 | );
110 | }
111 |
112 | export const ProxyGroup = connect((s: State, { name, delay }) => {
113 | const proxies = getProxies(s);
114 | const group = proxies[name];
115 | const { all, type, now } = group;
116 | return {
117 | all,
118 | delay,
119 | proxies,
120 | type,
121 | now,
122 | };
123 | })(ProxyGroupImpl);
124 |
--------------------------------------------------------------------------------
/src/components/proxies/ProxyLatency.module.scss:
--------------------------------------------------------------------------------
1 | .proxyLatency {
2 | border-radius: 20px;
3 | color: #eee;
4 | font-size: 0.6em;
5 | @media screen and (min-width: 30em) {
6 | font-size: 0.85em;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/proxies/ProxyLatency.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { ProxyDelayItem } from '$src/store/types';
4 |
5 | import s0 from './ProxyLatency.module.scss';
6 |
7 | type ProxyLatencyProps = {
8 | latency: ProxyDelayItem | undefined;
9 | color: string;
10 | };
11 |
12 | export function ProxyLatency({ latency, color }: ProxyLatencyProps) {
13 | let text = ' ';
14 | if (latency) {
15 | switch (latency.kind) {
16 | case 'Error':
17 | case 'Testing':
18 | text = '- ms';
19 | break;
20 | case 'Result':
21 | text = (latency.number !== 0 ? latency.number : '-') + ' ms';
22 | break;
23 | default:
24 | break;
25 | }
26 | }
27 | return (
28 |
29 | {text}
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/proxies/ProxyList.module.scss:
--------------------------------------------------------------------------------
1 | .list {
2 | margin: 8px 0;
3 | display: flex;
4 | flex-wrap: wrap;
5 | margin-left: -3px;
6 | }
7 |
8 | .listSummaryView {
9 | margin: 14px 0;
10 | display: grid;
11 | grid-template-columns: repeat(auto-fill, 13px);
12 | grid-gap: 10px;
13 | place-items: center;
14 | max-width: 900px;
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/proxies/ProxyList.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { Proxy, ProxySmall } from './Proxy';
4 | import s from './ProxyList.module.scss';
5 |
6 | type ProxyListProps = {
7 | all: string[];
8 | now?: string;
9 | isSelectable?: boolean;
10 | itemOnTapCallback?: (x: string) => void;
11 | show?: boolean;
12 | };
13 |
14 | export function ProxyList({ all, now, isSelectable, itemOnTapCallback }: ProxyListProps) {
15 | const proxies = all;
16 |
17 | return (
18 |
19 | {proxies.map((proxyName) => {
20 | return (
21 |
28 | );
29 | })}
30 |
31 | );
32 | }
33 |
34 | export function ProxyListSummaryView({
35 | all,
36 | now,
37 | isSelectable,
38 | itemOnTapCallback,
39 | }: ProxyListProps) {
40 | return (
41 |
42 | {all.map((proxyName) => {
43 | return (
44 |
51 | );
52 | })}
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/proxies/ProxyPageFab.tsx:
--------------------------------------------------------------------------------
1 | import { useAtom } from 'jotai';
2 | import * as React from 'react';
3 | import { Zap } from 'react-feather';
4 | import { useTranslation } from 'react-i18next';
5 | import { useUpdateProviderItems } from 'src/components/proxies/proxies.hooks';
6 | import { Action, Fab, IsFetching, position as fabPosition } from 'src/components/shared/Fab';
7 | import { RotateIcon } from 'src/components/shared/RotateIcon';
8 | import { requestDelayAll } from 'src/store/proxies';
9 | import { DispatchFn, FormattedProxyProvider } from 'src/store/types';
10 | import { ClashAPIConfig } from 'src/types';
11 |
12 | import { latencyTestUrlAtom } from '$src/store/app';
13 |
14 | const { useState, useCallback } = React;
15 |
16 | function StatefulZap({ isLoading }: { isLoading: boolean }) {
17 | return isLoading ? (
18 |
19 |
20 |
21 | ) : (
22 |
23 | );
24 | }
25 |
26 | function useTestLatencyAction({
27 | dispatch,
28 | apiConfig,
29 | }: {
30 | dispatch: DispatchFn;
31 | apiConfig: ClashAPIConfig;
32 | }): [() => unknown, boolean] {
33 | const [isTestingLatency, setIsTestingLatency] = useState(false);
34 | const [latencyTestUrl] = useAtom(latencyTestUrlAtom);
35 | const requestDelayAllFn = useCallback(() => {
36 | if (isTestingLatency) return;
37 |
38 | setIsTestingLatency(true);
39 | dispatch(requestDelayAll(apiConfig, latencyTestUrl)).then(
40 | () => setIsTestingLatency(false),
41 | () => setIsTestingLatency(false),
42 | );
43 | }, [apiConfig, dispatch, isTestingLatency, latencyTestUrl]);
44 | return [requestDelayAllFn, isTestingLatency];
45 | }
46 |
47 | export function ProxyPageFab({
48 | dispatch,
49 | apiConfig,
50 | proxyProviders,
51 | }: {
52 | dispatch: DispatchFn;
53 | apiConfig: ClashAPIConfig;
54 | proxyProviders: FormattedProxyProvider[];
55 | }) {
56 | const { t } = useTranslation();
57 | const [requestDelayAllFn, isTestingLatency] = useTestLatencyAction({
58 | dispatch,
59 | apiConfig,
60 | });
61 |
62 | const [updateProviders, isUpdating] = useUpdateProviderItems({
63 | apiConfig,
64 | dispatch,
65 | names: proxyProviders.map((item) => item.name),
66 | });
67 |
68 | return (
69 | }
71 | onClick={requestDelayAllFn}
72 | text={t('Test Latency')}
73 | style={fabPosition}
74 | >
75 | {proxyProviders.length > 0 ? (
76 |
77 |
78 |
79 | ) : null}
80 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/src/components/proxies/ProxyProvider.module.scss:
--------------------------------------------------------------------------------
1 | .updatedAt {
2 | margin-bottom: 12px;
3 | small {
4 | color: #777;
5 | }
6 | }
7 |
8 | .main {
9 | padding: 10px 15px;
10 | @media screen and (min-width: 30em) {
11 | padding: 10px 40px;
12 | }
13 | }
14 |
15 | .head {
16 | display: flex;
17 | align-items: center;
18 | flex-wrap: wrap;
19 | }
20 |
21 | .action {
22 | margin: 0 5px;
23 | display: grid;
24 | grid-template-columns: auto auto;
25 | gap: 10px;
26 | place-items: center;
27 | }
28 |
29 | .refresh {
30 | display: flex;
31 | justify-content: center;
32 | align-items: center;
33 | cursor: pointer;
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/proxies/ProxyProviderList.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ContentHeader } from 'src/components/ContentHeader';
3 | import { ProxyProvider } from 'src/components/proxies/ProxyProvider';
4 | import { FormattedProxyProvider } from 'src/store/types';
5 |
6 | export function ProxyProviderList({ items }: { items: FormattedProxyProvider[] }) {
7 | if (items.length === 0) return null;
8 |
9 | return (
10 | <>
11 |
12 |
13 | {items.map((item) => (
14 |
22 | ))}
23 |
24 | >
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/proxies/Settings.module.scss:
--------------------------------------------------------------------------------
1 | .labeledInput {
2 | max-width: 85vw;
3 | width: 400px;
4 | display: flex;
5 | justify-content: space-between;
6 | align-items: center;
7 | font-size: 13px;
8 | padding: 13px 0;
9 | }
10 |
11 | hr {
12 | height: 1px;
13 | background-color: var(--color-separator);
14 | border: none;
15 | outline: none;
16 | margin: 1rem 0px;
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/proxies/Settings.tsx:
--------------------------------------------------------------------------------
1 | import { useAtom } from 'jotai';
2 | import * as React from 'react';
3 | import { useTranslation } from 'react-i18next';
4 |
5 | import { ToggleInput } from '$src/components/form/Toggle';
6 | import Select from '$src/components/shared/Select';
7 | import { autoCloseOldConnsAtom, hideUnavailableProxiesAtom, proxySortByAtom } from '$src/store/app';
8 |
9 | import s from './Settings.module.scss';
10 |
11 | const options = [
12 | ['Natural', 'order_natural'],
13 | ['LatencyAsc', 'order_latency_asc'],
14 | ['LatencyDesc', 'order_latency_desc'],
15 | ['NameAsc', 'order_name_asc'],
16 | ['NameDesc', 'order_name_desc'],
17 | ];
18 |
19 | const { useCallback } = React;
20 |
21 | export default function Settings() {
22 | const [autoCloseOldConns, setAutoCloseOldConns] = useAtom(autoCloseOldConnsAtom);
23 | const [proxySortBy, setProxySortBy] = useAtom(proxySortByAtom);
24 | const [hideUnavailableProxies, setHideUnavailableProxies] = useAtom(hideUnavailableProxiesAtom);
25 | const handleProxySortByOnChange = useCallback(
26 | (e: React.ChangeEvent) => setProxySortBy(e.target.value),
27 | [setProxySortBy],
28 | );
29 |
30 | const handleHideUnavailablesSwitchOnChange = useCallback(
31 | (v: boolean) => {
32 | setHideUnavailableProxies(v);
33 | },
34 | [setHideUnavailableProxies],
35 | );
36 | const { t } = useTranslation();
37 | return (
38 | <>
39 |
40 |
{t('sort_in_grp')}
41 |
42 |
50 |
51 |
52 |
53 |
54 |
55 |
60 |
61 |
62 |
63 |
64 |
65 |
70 |
71 |
72 | >
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/src/components/proxies/hooks.tsx:
--------------------------------------------------------------------------------
1 | import { useAtom } from 'jotai';
2 | import * as React from 'react';
3 | import {
4 | // types
5 | NonProxyTypes,
6 | // atom
7 | proxyFilterTextAtom,
8 | } from 'src/store/proxies';
9 | import { DelayMapping, ProxiesMapping, ProxyDelayItem, ProxyItem } from 'src/store/types';
10 |
11 | const { useMemo } = React;
12 |
13 | function filterAvailableProxies(list: string[], delay: DelayMapping) {
14 | return list.filter((name) => {
15 | const d = delay[name];
16 | if (d === undefined) {
17 | return true;
18 | }
19 | if ('number' in d && d.number === 0) {
20 | return false;
21 | } else {
22 | return true;
23 | }
24 | });
25 | }
26 |
27 | const getSortDelay = (d: undefined | ProxyDelayItem, proxyInfo: ProxyItem) => {
28 | if (d && 'number' in d && d.number > 0) {
29 | return d.number;
30 | }
31 |
32 | const type = proxyInfo && proxyInfo.type;
33 | if (type && NonProxyTypes.indexOf(type) > -1) return -1;
34 |
35 | return 999999;
36 | };
37 |
38 | const ProxySortingFns = {
39 | Natural: (proxies: string[]) => proxies,
40 | LatencyAsc: (proxies: string[], delay: DelayMapping, proxyMapping?: ProxiesMapping) => {
41 | return proxies.sort((a, b) => {
42 | const d1 = getSortDelay(delay[a], proxyMapping && proxyMapping[a]);
43 | const d2 = getSortDelay(delay[b], proxyMapping && proxyMapping[b]);
44 | return d1 - d2;
45 | });
46 | },
47 | LatencyDesc: (proxies: string[], delay: DelayMapping, proxyMapping?: ProxiesMapping) => {
48 | return proxies.sort((a, b) => {
49 | const d1 = getSortDelay(delay[a], proxyMapping && proxyMapping[a]);
50 | const d2 = getSortDelay(delay[b], proxyMapping && proxyMapping[b]);
51 | return d2 - d1;
52 | });
53 | },
54 | NameAsc: (proxies: string[]) => {
55 | return proxies.sort();
56 | },
57 | NameDesc: (proxies: string[]) => {
58 | return proxies.sort((a, b) => {
59 | if (a > b) return -1;
60 | if (a < b) return 1;
61 | return 0;
62 | });
63 | },
64 | };
65 |
66 | function filterStrArr(all: string[], searchText: string) {
67 | const segments = searchText
68 | .toLowerCase()
69 | .split(' ')
70 | .map((x) => x.trim())
71 | .filter((x) => !!x);
72 |
73 | if (segments.length === 0) return all;
74 |
75 | return all.filter((name) => {
76 | let i = 0;
77 | for (; i < segments.length; i++) {
78 | const seg = segments[i];
79 | if (name.toLowerCase().indexOf(seg) > -1) return true;
80 | }
81 | return false;
82 | });
83 | }
84 |
85 | function filterAvailableProxiesAndSort(
86 | all: string[],
87 | delay: DelayMapping,
88 | hideUnavailableProxies: boolean,
89 | filterText: string,
90 | proxySortBy: string,
91 | proxies?: ProxiesMapping,
92 | ) {
93 | // all is freezed
94 | let filtered = [...all];
95 | if (hideUnavailableProxies) {
96 | filtered = filterAvailableProxies(all, delay);
97 | }
98 |
99 | if (typeof filterText === 'string' && filterText !== '') {
100 | filtered = filterStrArr(filtered, filterText);
101 | }
102 | return ProxySortingFns[proxySortBy](filtered, delay, proxies);
103 | }
104 |
105 | export function useFilteredAndSorted(
106 | all: string[],
107 | delay: DelayMapping,
108 | hideUnavailableProxies: boolean,
109 | proxySortBy: string,
110 | proxies?: ProxiesMapping,
111 | ) {
112 | const [filterText] = useAtom(proxyFilterTextAtom);
113 | return useMemo(
114 | () =>
115 | filterAvailableProxiesAndSort(
116 | all,
117 | delay,
118 | hideUnavailableProxies,
119 | filterText,
120 | proxySortBy,
121 | proxies,
122 | ),
123 | [all, delay, hideUnavailableProxies, filterText, proxySortBy, proxies],
124 | );
125 | }
126 |
--------------------------------------------------------------------------------
/src/components/proxies/index.tsx:
--------------------------------------------------------------------------------
1 | export { ProxyList } from './ProxyList';
2 |
--------------------------------------------------------------------------------
/src/components/proxies/proxies.hooks.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { updateProviderByName, updateProviders } from 'src/store/proxies';
3 | import { DispatchFn } from 'src/store/types';
4 | import { ClashAPIConfig } from 'src/types';
5 |
6 | const { useCallback, useState } = React;
7 |
8 | export function useUpdateProviderItem({
9 | dispatch,
10 | apiConfig,
11 | name,
12 | }: {
13 | dispatch: DispatchFn;
14 | apiConfig: ClashAPIConfig;
15 | name: string;
16 | }) {
17 | return useCallback(
18 | () => dispatch(updateProviderByName(apiConfig, name)),
19 | [apiConfig, dispatch, name],
20 | );
21 | }
22 |
23 | export function useUpdateProviderItems({
24 | dispatch,
25 | apiConfig,
26 | names,
27 | }: {
28 | dispatch: DispatchFn;
29 | apiConfig: ClashAPIConfig;
30 | names: string[];
31 | }): [() => unknown, boolean] {
32 | const [isLoading, setIsLoading] = useState(false);
33 |
34 | const action = useCallback(async () => {
35 | if (isLoading) {
36 | return;
37 | }
38 |
39 | setIsLoading(true);
40 | try {
41 | await dispatch(updateProviders(apiConfig, names));
42 | } catch (e) {
43 | // ignore
44 | }
45 | setIsLoading(false);
46 | }, [apiConfig, dispatch, names, isLoading]);
47 |
48 | return [action, isLoading];
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/rules/RuleProviderItem.module.scss:
--------------------------------------------------------------------------------
1 | .RuleProviderItem {
2 | display: grid;
3 | grid-template-columns: 40px 1fr 46px;
4 | height: 100%;
5 | }
6 |
7 | .left {
8 | display: inline-flex;
9 | align-items: center;
10 | color: var(--color-text-secondary);
11 | opacity: 0.4;
12 | }
13 |
14 | .middle {
15 | display: grid;
16 | gap: 6px;
17 | grid-template-rows: 1fr auto auto;
18 | align-items: center;
19 | }
20 |
21 | .gray {
22 | color: #777;
23 | }
24 |
25 | .action {
26 | display: grid;
27 | gap: 4px;
28 | grid-template-columns: auto 1fr;
29 | align-items: center;
30 | }
31 |
32 | .refreshBtn {
33 | padding: 5px;
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/rules/RuleProviderItem.tsx:
--------------------------------------------------------------------------------
1 | import { formatDistance } from 'date-fns';
2 | import * as React from 'react';
3 | import Button from 'src/components/Button';
4 | import { useUpdateRuleProviderItem } from 'src/components/rules/rules.hooks';
5 | import { SectionNameType } from 'src/components/shared/Basic';
6 | import { RotateIcon } from 'src/components/shared/RotateIcon';
7 |
8 | import { ClashAPIConfig } from '$src/types';
9 |
10 | import s from './RuleProviderItem.module.scss';
11 |
12 | export function RuleProviderItem({
13 | idx,
14 | name,
15 | vehicleType,
16 | behavior,
17 | updatedAt,
18 | ruleCount,
19 | apiConfig,
20 | }: {
21 | idx: number;
22 | name: string;
23 | vehicleType: string;
24 | behavior: string;
25 | updatedAt: string;
26 | ruleCount: number;
27 | apiConfig: ClashAPIConfig;
28 | }) {
29 | const [onClickRefreshButton, isRefreshing] = useUpdateRuleProviderItem(name, apiConfig);
30 | const timeAgo = formatDistance(new Date(updatedAt), new Date());
31 | return (
32 |
33 |
{idx}
34 |
35 |
36 |
{ruleCount < 2 ? `${ruleCount} rule` : `${ruleCount} rules`}
37 |
38 |
42 | Updated {timeAgo} ago
43 |
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/rules/RulesPageFab.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useTranslation } from 'react-i18next';
3 | import { useUpdateAllRuleProviderItems } from 'src/components/rules/rules.hooks';
4 | import { Fab, position as fabPosition } from 'src/components/shared/Fab';
5 | import { RotateIcon } from 'src/components/shared/RotateIcon';
6 | import { ClashAPIConfig } from 'src/types';
7 |
8 | type RulesPageFabProps = {
9 | apiConfig: ClashAPIConfig;
10 | };
11 |
12 | export function RulesPageFab({ apiConfig }: RulesPageFabProps) {
13 | const [update, isLoading] = useUpdateAllRuleProviderItems(apiConfig);
14 | const { t } = useTranslation();
15 | return (
16 | }
18 | text={t('update_all_rule_provider')}
19 | style={fabPosition}
20 | onClick={update}
21 | />
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/rules/rules.hooks.tsx:
--------------------------------------------------------------------------------
1 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2 | import { useAtom } from 'jotai';
3 | import * as React from 'react';
4 | import {
5 | fetchRuleProviders,
6 | refreshRuleProviderByName,
7 | updateRuleProviders,
8 | } from 'src/api/rule-provider';
9 | import { fetchRules } from 'src/api/rules';
10 | import { ruleFilterTextAtom } from 'src/store/rules';
11 | import type { ClashAPIConfig } from 'src/types';
12 |
13 | const { useCallback } = React;
14 |
15 | export function useUpdateRuleProviderItem(
16 | name: string,
17 | apiConfig: ClashAPIConfig,
18 | ): [(ev: React.MouseEvent) => unknown, boolean] {
19 | const queryClient = useQueryClient();
20 | const { mutate, isLoading } = useMutation(refreshRuleProviderByName, {
21 | onSuccess: () => {
22 | queryClient.invalidateQueries(['/providers/rules']);
23 | },
24 | });
25 | const onClickRefreshButton = (ev: React.MouseEvent) => {
26 | ev.preventDefault();
27 | mutate({ name, apiConfig });
28 | };
29 | return [onClickRefreshButton, isLoading];
30 | }
31 |
32 | export function useUpdateAllRuleProviderItems(
33 | apiConfig: ClashAPIConfig,
34 | ): [(ev: React.MouseEvent) => unknown, boolean] {
35 | const queryClient = useQueryClient();
36 | const { data: provider } = useRuleProviderQuery(apiConfig);
37 | const { mutate, isLoading } = useMutation(updateRuleProviders, {
38 | onSuccess: () => {
39 | queryClient.invalidateQueries(['/providers/rules']);
40 | },
41 | });
42 | const onClickRefreshButton = (ev: React.MouseEvent) => {
43 | ev.preventDefault();
44 | mutate({ names: provider.names, apiConfig });
45 | };
46 | return [onClickRefreshButton, isLoading];
47 | }
48 |
49 | export function useInvalidateQueries() {
50 | const queryClient = useQueryClient();
51 | return useCallback(() => {
52 | queryClient.invalidateQueries(['/rules']);
53 | queryClient.invalidateQueries(['/providers/rules']);
54 | }, [queryClient]);
55 | }
56 |
57 | export function useRuleProviderQuery(apiConfig: ClashAPIConfig) {
58 | return useQuery(['/providers/rules', apiConfig], fetchRuleProviders);
59 | }
60 |
61 | export function useRuleAndProvider(apiConfig: ClashAPIConfig) {
62 | const { data: rules, isFetching } = useQuery(['/rules', apiConfig], fetchRules);
63 | const { data: provider } = useRuleProviderQuery(apiConfig);
64 | const [filterText] = useAtom(ruleFilterTextAtom);
65 | if (filterText === '') {
66 | return { rules, provider, isFetching };
67 | } else {
68 | const f = filterText.toLowerCase();
69 | return {
70 | rules: rules.filter((r) => r.payload.toLowerCase().indexOf(f) >= 0),
71 | isFetching,
72 | provider: {
73 | byName: provider.byName,
74 | names: provider.names.filter((t) => t.toLowerCase().indexOf(f) >= 0),
75 | },
76 | };
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/components/shared/BaseModal.module.scss:
--------------------------------------------------------------------------------
1 | .overlay {
2 | background-color: rgba(0, 0, 0, 0.6);
3 | }
4 | .cnt {
5 | position: absolute;
6 | background-color: var(--bg-modal);
7 | color: var(--color-text);
8 | line-height: 1.4;
9 | opacity: 0.6;
10 | transition: all 0.3s ease;
11 | transform: translate(-50%, -50%) scale(1.2);
12 | box-shadow:
13 | rgba(0, 0, 0, 0.12) 0px 4px 4px,
14 | rgba(0, 0, 0, 0.24) 0px 16px 32px;
15 | }
16 | .afterOpen {
17 | opacity: 1;
18 | transform: translate(-50%, -50%) scale(1);
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/shared/BaseModal.tsx:
--------------------------------------------------------------------------------
1 | import cx from 'clsx';
2 | import * as React from 'react';
3 | import Modal from 'react-modal';
4 |
5 | import modalStyle from '../Modal.module.scss';
6 | import s from './BaseModal.module.scss';
7 |
8 | const { useMemo } = React;
9 |
10 | export default function BaseModal({
11 | isOpen,
12 | onRequestClose,
13 | children,
14 | }: {
15 | isOpen: boolean;
16 | onRequestClose: (ev: any) => void;
17 | children: React.ReactNode;
18 | }) {
19 | const className = useMemo(
20 | () => ({
21 | base: cx(modalStyle.content, s.cnt),
22 | afterOpen: s.afterOpen,
23 | beforeClose: '',
24 | }),
25 | [],
26 | );
27 | return (
28 |
34 | {children}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/shared/Basic.module.scss:
--------------------------------------------------------------------------------
1 | h2.sectionNameType {
2 | margin: 0;
3 | font-size: 1.3em;
4 | font-weight: bold;
5 | @media screen and (min-width: 30em) {
6 | font-size: 1.5em;
7 | }
8 |
9 | span:nth-child(2) {
10 | font-size: 12px;
11 | color: #777;
12 | font-weight: normal;
13 | margin: 0 0.3em;
14 | }
15 | }
16 |
17 | /**
18 | * loading dots
19 | *
20 | * light
21 | * dot color is #000 (black)
22 | *
23 | * transparency change:
24 | * dot1 dot2 do3
25 | * phase1 0.1 0.3 0.5
26 | * phase2 0.5 0.1 0.3
27 | * phase3 0.3 0.5 0.1
28 | *
29 | * dark
30 | * dot color is #fff (white)
31 | *
32 | * transparency change:
33 | * dot1 dot2 do3
34 | * phase1 0.5 0.3 0.1
35 | * phase2 0.1 0.5 0.3
36 | * phase3 0.3 0.1 0.5
37 | *
38 | */
39 |
40 | :root[data-theme='light'] {
41 | /*
42 | * --loading-dot-{dot-index}-{dot-keyframe-phase}
43 | */
44 | --loading-dot-1-1: rgba(0, 0, 0, 0.1);
45 | --loading-dot-1-2: rgba(0, 0, 0, 0.5);
46 | --loading-dot-1-3: rgba(0, 0, 0, 0.3);
47 | --loading-dot-2-1: rgba(0, 0, 0, 0.3);
48 | --loading-dot-2-2: rgba(0, 0, 0, 0.1);
49 | --loading-dot-2-3: rgba(0, 0, 0, 0.5);
50 | --loading-dot-3-1: rgba(0, 0, 0, 0.5);
51 | --loading-dot-3-2: rgba(0, 0, 0, 0.3);
52 | --loading-dot-3-3: rgba(0, 0, 0, 0.1);
53 | }
54 | :root[data-theme='dark'] {
55 | --loading-dot-1-1: rgba(255, 255, 255, 0.5);
56 | --loading-dot-1-2: rgba(255, 255, 255, 0.1);
57 | --loading-dot-1-3: rgba(255, 255, 255, 0.3);
58 | --loading-dot-2-1: rgba(255, 255, 255, 0.3);
59 | --loading-dot-2-2: rgba(255, 255, 255, 0.5);
60 | --loading-dot-2-3: rgba(255, 255, 255, 0.1);
61 | --loading-dot-3-1: rgba(255, 255, 255, 0.1);
62 | --loading-dot-3-2: rgba(255, 255, 255, 0.3);
63 | --loading-dot-3-3: rgba(255, 255, 255, 0.5);
64 | }
65 |
66 | .loadingDot,
67 | .loadingDot:before,
68 | .loadingDot:after {
69 | display: inline-block;
70 | vertical-align: middle;
71 | width: 6px;
72 | height: 6px;
73 | border-radius: 50%;
74 | font-size: 0;
75 | }
76 |
77 | .loadingDot {
78 | $d: 1s;
79 |
80 | position: relative;
81 | background-color: var(--loading-dot-2-1);
82 | animation: dot2 $d step-start infinite;
83 | &:before {
84 | content: '';
85 | position: absolute;
86 | left: -12px;
87 | background-color: var(--loading-dot-1-1);
88 | animation: dot1 $d step-start infinite;
89 | }
90 | &:after {
91 | content: '';
92 | position: absolute;
93 | right: -12px;
94 | background-color: var(--loading-dot-3-1);
95 | animation: dot3 $d step-start infinite;
96 | }
97 | }
98 | /* prettier-ignore */
99 | @keyframes dot1 {
100 | 0%, 100% { background-color: var(--loading-dot-1-1) }
101 | 33% { background-color: var(--loading-dot-1-2) }
102 | 66% { background-color: var(--loading-dot-1-3) }
103 | }
104 | /* prettier-ignore */
105 | @keyframes dot2 {
106 | 0%, 100% { background-color: var(--loading-dot-2-1) }
107 | 33% { background-color: var(--loading-dot-2-2) }
108 | 66% { background-color: var(--loading-dot-2-3) }
109 | }
110 | /* prettier-ignore */
111 | @keyframes dot3 {
112 | 0%, 100% { background-color: var(--loading-dot-3-1) }
113 | 33% { background-color: var(--loading-dot-3-2) }
114 | 66% { background-color: var(--loading-dot-3-3) }
115 | }
116 |
--------------------------------------------------------------------------------
/src/components/shared/Basic.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import s from './Basic.module.scss';
4 |
5 | export function SectionNameType({ name, type }: { name: string; type: string }) {
6 | return (
7 |
8 | {name}
9 | {type}
10 |
11 | );
12 | }
13 |
14 | export function LoadingDot() {
15 | return ;
16 | }
17 |
18 | export function Sep(props: { height?: number }) {
19 | return ;
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/shared/Fab.module.scss:
--------------------------------------------------------------------------------
1 | .spining {
2 | position: relative;
3 | border-radius: 50%;
4 | background: linear-gradient(60deg, #e66465, #9198e5);
5 |
6 | width: 48px;
7 | height: 48px;
8 | display: flex;
9 | justify-content: center;
10 | align-items: center;
11 | }
12 |
13 | .spining:before {
14 | content: '';
15 | position: absolute;
16 | top: 0;
17 | bottom: 0;
18 | left: 0;
19 | right: 0;
20 | border: 2px solid transparent;
21 | border-top-color: currentColor;
22 | border-radius: 50%;
23 | animation: spining_keyframes 1s linear infinite;
24 | }
25 |
26 | @keyframes spining_keyframes {
27 | 0% {
28 | transform: rotate(0);
29 | }
30 | 100% {
31 | transform: rotate(360deg);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/shared/Fab.tsx:
--------------------------------------------------------------------------------
1 | // adapted from https://github.com/dericgw/react-tiny-fab/blob/master/src/index.tsx
2 | import './rtf.css';
3 |
4 | import * as React from 'react';
5 |
6 | import s from './Fab.module.scss';
7 |
8 | const { useState } = React;
9 |
10 | export function IsFetching({ children }: { children: React.ReactNode }) {
11 | return {children};
12 | }
13 |
14 | export const position = {
15 | right: 10,
16 | bottom: 10,
17 | };
18 |
19 | interface ABProps extends React.HTMLAttributes {
20 | text?: string;
21 | onClick?: (e: React.FormEvent) => void;
22 | 'data-testid'?: string;
23 | }
24 |
25 | const AB: React.FC = ({ children, ...p }) => (
26 |
29 | );
30 |
31 | interface MBProps extends Omit, 'tabIndex'> {
32 | tabIndex?: number;
33 | }
34 |
35 | export const MB: React.FC = ({ children, ...p }) => (
36 |
39 | );
40 |
41 | const defaultStyles: React.CSSProperties = { bottom: 24, right: 24 };
42 |
43 | interface FabProps {
44 | event?: 'hover' | 'click';
45 | style?: React.CSSProperties;
46 | alwaysShowTitle?: boolean;
47 | icon?: React.ReactNode;
48 | mainButtonStyles?: React.CSSProperties;
49 | onClick?: (e: React.FormEvent) => void;
50 | text?: string;
51 | children?: React.ReactNode;
52 | }
53 |
54 | const Fab: React.FC = ({
55 | event = 'hover',
56 | style = defaultStyles,
57 | alwaysShowTitle = false,
58 | children,
59 | icon,
60 | mainButtonStyles,
61 | onClick,
62 | text,
63 | ...p
64 | }) => {
65 | const [isOpen, setIsOpen] = useState(false);
66 | const ariaHidden = alwaysShowTitle || !isOpen;
67 | const open = () => setIsOpen(true);
68 | const close = () => setIsOpen(false);
69 | const enter = () => event === 'hover' && open();
70 | const leave = () => event === 'hover' && close();
71 | const toggle = (e: React.FormEvent) => {
72 | if (onClick) {
73 | return onClick(e);
74 | }
75 | e.persist();
76 | return event === 'click' ? (isOpen ? close() : open()) : null;
77 | };
78 |
79 | const actionOnClick = (e: React.FormEvent, userFunc: (e: React.FormEvent) => void) => {
80 | e.persist();
81 | setIsOpen(false);
82 | setTimeout(() => {
83 | userFunc(e);
84 | }, 1);
85 | };
86 |
87 | const rc = () =>
88 | React.Children.map(children, (ch, i) => {
89 | if (React.isValidElement(ch)) {
90 | return (
91 |
92 | {React.cloneElement(ch, {
93 | 'data-testid': `action-button-${i}`,
94 | 'aria-label': ch.props.text || `Menu button ${i + 1}`,
95 | 'aria-hidden': ariaHidden,
96 | tabIndex: isOpen ? 0 : -1,
97 | ...ch.props,
98 | onClick: (e: React.FormEvent) => {
99 | if (ch.props.onClick) actionOnClick(e, ch.props.onClick);
100 | },
101 | })}
102 | {ch.props.text && (
103 |
109 | {ch.props.text}
110 |
111 | )}
112 |
113 | );
114 | }
115 | return null;
116 | });
117 |
118 | return (
119 |
127 | -
128 |
136 | {icon}
137 |
138 | {text && (
139 |
143 | {text}
144 |
145 | )}
146 |
147 |
148 |
149 | );
150 | };
151 |
152 | export { Fab, AB as Action };
153 |
--------------------------------------------------------------------------------
/src/components/shared/Head.tsx:
--------------------------------------------------------------------------------
1 | import { useAtom } from 'jotai';
2 | import * as React from 'react';
3 |
4 | import { clashAPIConfigsAtom, useApiConfig } from '$src/store/app';
5 |
6 | export function Head() {
7 | const apiConfig = useApiConfig();
8 | const [apiConfigs] = useAtom(clashAPIConfigsAtom);
9 | React.useEffect(() => {
10 | let title = 'yacd';
11 | if (apiConfigs.length > 1) {
12 | try {
13 | title = `${apiConfig.metaLabel || new URL(apiConfig.baseURL).host} - yacd`;
14 | } catch (e) {
15 | // ignore
16 | }
17 | }
18 | document.title = title;
19 | });
20 |
21 | return <>>;
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/shared/RotateIcon.module.scss:
--------------------------------------------------------------------------------
1 | .rotate {
2 | display: inline-flex;
3 | }
4 | .isRotating {
5 | animation: rotating 3s infinite linear;
6 | animation-fill-mode: forwards;
7 | }
8 |
9 | @keyframes rotating {
10 | from {
11 | transform: rotate(0deg);
12 | }
13 | to {
14 | transform: rotate(360deg);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/shared/RotateIcon.tsx:
--------------------------------------------------------------------------------
1 | import cx from 'clsx';
2 | import * as React from 'react';
3 | import { RotateCw } from 'react-feather';
4 |
5 | import s from './RotateIcon.module.scss';
6 |
7 | export function RotateIcon(props: { isRotating: boolean; size?: number }) {
8 | const size = props.size || 16;
9 | const cls = cx(s.rotate, { [s.isRotating]: props.isRotating });
10 | return (
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/shared/Select.module.scss:
--------------------------------------------------------------------------------
1 | .select {
2 | height: 40px;
3 | line-height: 1.5;
4 | width: 100%;
5 | padding-left: 8px;
6 | appearance: none;
7 | background-color: var(--color-input-bg);
8 | color: var(--color-text);
9 | padding-right: 20px;
10 | border-radius: 4px;
11 | border: 1px solid var(--color-input-border);
12 | background-image: url(data:image/svg+xml,%0A%20%20%20%20%3Csvg%20width%3D%228%22%20height%3D%2224%22%20viewBox%3D%220%200%208%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%207L7%2011H1L4%207Z%22%20fill%3D%22%23999999%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%2017L1%2013L7%2013L4%2017Z%22%20fill%3D%22%23999999%22%20%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E%0A%20%20);
13 | background-position: right 8px center;
14 | background-repeat: no-repeat;
15 | }
16 |
17 | .select:hover,
18 | .select:focus {
19 | border-color: rgb(52, 52, 52);
20 | outline: none !important;
21 | color: var(--color-text-highlight);
22 | background-image: var(--select-bg-hover);
23 | }
24 | .select:focus {
25 | box-shadow: rgba(66, 153, 225, 0.6) 0px 0px 0px 3px;
26 | }
27 |
28 | .select option {
29 | background-color: var(--color-background);
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/shared/Select.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import s from './Select.module.scss';
4 |
5 | type Props = {
6 | options: Array;
7 | selected: string;
8 | onChange: (event: React.ChangeEvent) => any;
9 | };
10 |
11 | export default function Select({ options, selected, onChange }: Props) {
12 | return (
13 | // eslint-disable-next-line jsx-a11y/no-onchange
14 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/shared/Styled.module.scss:
--------------------------------------------------------------------------------
1 | .FlexCenter {
2 | display: flex;
3 | justify-content: center;
4 | align-items: center;
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/shared/Styled.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import s from './Styled.module.scss';
4 |
5 | export function FlexCenter({ children }: { children: React.ReactNode }) {
6 | return {children}
;
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/shared/TextFilter.module.scss:
--------------------------------------------------------------------------------
1 | .input {
2 | -webkit-appearance: none;
3 | background-color: var(--color-input-bg);
4 | background-image: none;
5 | border-radius: 20px;
6 | border: 1px solid var(--color-input-border);
7 | box-sizing: border-box;
8 | color: #c1c1c1;
9 | display: inline-block;
10 | font-size: inherit;
11 | outline: none;
12 | padding: 8px 15px;
13 | transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
14 | width: 100%;
15 |
16 | &:focus {
17 | border: 1px solid var(--color-focus-blue);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/shared/TextFilter.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useTextInput } from 'src/hooks/useTextInput';
3 |
4 | import { TextAtom } from '$src/store/rules';
5 |
6 | import s from './TextFilter.module.scss';
7 |
8 | export function TextFilter(props: { textAtom: TextAtom; placeholder?: string }) {
9 | const [onChange, text] = useTextInput(props.textAtom);
10 | return (
11 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/shared/ThemeSwitcher.module.scss:
--------------------------------------------------------------------------------
1 | .MenubarTrigger {
2 | --sz: 40px;
3 |
4 | width: var(--sz);
5 | height: var(--sz);
6 |
7 | display: inline-flex;
8 | align-items: center;
9 | justify-content: center;
10 |
11 | -webkit-appearance: none;
12 | appearance: none;
13 | outline: none;
14 | user-select: none;
15 | cursor: pointer;
16 | color: var(--color-btn-fg);
17 | background: none;
18 | border: 1px solid transparent;
19 | border-radius: 20px;
20 |
21 | &:hover {
22 | opacity: 0.6;
23 | }
24 |
25 | &:focus {
26 | border-color: var(--color-focus-blue);
27 | }
28 | }
29 |
30 | .MenubarContent {
31 | background: var(--bg-tooltip);
32 | color: var(--color-text);
33 | border: 1px solid #555;
34 | padding: 4px;
35 | border-radius: 8px;
36 | }
37 |
38 | .MenubarItem {
39 | padding: 5px 16px 5px 6px;
40 | border-radius: 7px;
41 | cursor: pointer;
42 | display: flex;
43 | align-items: center;
44 | outline: none;
45 | }
46 |
47 | .MenubarItem[data-highlighted] {
48 | background: var(--color-focus-blue);
49 | color: #f7f7f7;
50 | }
51 |
52 | .checkWrapper {
53 | display: inline-flex;
54 | align-items: center;
55 | margin-right: 2px;
56 | visibility: hidden;
57 | &.active {
58 | visibility: visible;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/shared/ZapAnimated.module.scss:
--------------------------------------------------------------------------------
1 | .animate {
2 | --saturation: 70%;
3 | stroke: hsl(46deg var(--saturation) 45%);
4 | transform: scale(1);
5 | animation: zap-pulse 0.7s 0s ease-in-out none normal infinite;
6 | }
7 |
8 | // prettier-ignore
9 | @keyframes zap-pulse {
10 | 0% { stroke: hsl(46deg var(--saturation) 45%); transform: scale(1); }
11 | 50% { stroke: hsl(46deg var(--saturation) 95%); transform: scale(1.1); }
12 | 100% { stroke: hsl(46deg var(--saturation) 45%); transform: scale(1); }
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/shared/ZapAnimated.tsx:
--------------------------------------------------------------------------------
1 | import cx from 'clsx';
2 | import * as React from 'react';
3 |
4 | import s from './ZapAnimated.module.scss';
5 |
6 | export function ZapAnimated(props: { size?: number; animate?: boolean }) {
7 | const size = props.size || 24;
8 | const cls = cx({ [s.animate]: props.animate });
9 | return (
10 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/style/StyleGuide.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react';
2 | import { Zap } from 'react-feather';
3 | import Loading from 'src/components/Loading';
4 |
5 | import Button from '$src/components/Button';
6 | import { ToggleInput } from '$src/components/form/Toggle';
7 | import Input from '$src/components/Input';
8 | import { ZapAnimated } from '$src/components/shared/ZapAnimated';
9 | import ToggleSwitch from '$src/components/ToggleSwitch';
10 |
11 | import { ThemeSwitcher } from '../shared/ThemeSwitcher';
12 |
13 | const noop = () => {
14 | /* empty */
15 | };
16 |
17 | const paneStyle = {
18 | padding: '20px 0',
19 | };
20 |
21 | const optionsRule = [
22 | { label: 'Global', value: 'Global' },
23 | { label: 'Rule', value: 'Rule' },
24 | { label: 'Direct', value: 'Direct' },
25 | ];
26 |
27 | const Pane = ({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) => (
28 | {children}
29 | );
30 |
31 | export default class StyleGuide extends PureComponent {
32 | render() {
33 | return (
34 |
35 |
36 |
37 |
38 |
39 |
40 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | } />
58 | } isLoading />
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | );
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/svg/Equalizer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | type Props = {
4 | size?: number;
5 | color?: string;
6 | };
7 |
8 | export default function Equalizer({ color = 'currentColor', size = 24 }: Props) {
9 | return (
10 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/custom.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
5 | // for css modules
6 | declare module '*.module.css' {
7 | const classes: { [key: string]: string };
8 | export default classes;
9 | }
10 | declare module '*.module.scss' {
11 | const classes: { [key: string]: string };
12 | export default classes;
13 | }
14 | declare module '*.woff2';
15 |
16 | interface Window {
17 | i18n: any;
18 | }
19 |
20 | declare const __VERSION__: string;
21 | declare const __COMMIT_HASH__: string;
22 | declare const process = {
23 | env: {
24 | NODE_ENV: string,
25 | PUBLIC_URL: string,
26 | COMMIT_HASH: string,
27 | },
28 | };
29 |
--------------------------------------------------------------------------------
/src/hooks/basic.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | const { useState, useCallback } = React;
4 |
5 | export function useToggle(initialValue = false): [boolean, () => void] {
6 | const [isOn, setState] = useState(initialValue);
7 | const toggle = useCallback(() => setState((x) => !x), []);
8 | return [isOn, toggle];
9 | }
10 |
11 | export function useState2(v: T) {
12 | const [value, set] = useState(v);
13 | return { value, set };
14 | }
15 |
--------------------------------------------------------------------------------
/src/hooks/useLineChart.ts:
--------------------------------------------------------------------------------
1 | import type { ChartConfiguration } from 'chart.js';
2 | import React from 'react';
3 | import { commonChartOptions } from 'src/misc/chart';
4 |
5 | const { useEffect } = React;
6 |
7 | export default function useLineChart(
8 | chart: typeof import('chart.js').Chart,
9 | elementId: string,
10 | data: ChartConfiguration['data'],
11 | subscription: any,
12 | extraChartOptions = {},
13 | ) {
14 | useEffect(() => {
15 | const ctx = (document.getElementById(elementId) as HTMLCanvasElement).getContext('2d');
16 | const options = { ...commonChartOptions, ...extraChartOptions };
17 | const c = new chart(ctx, { type: 'line', data, options });
18 | const unsubscribe = subscription && subscription.subscribe(() => c.update());
19 | return () => {
20 | unsubscribe && unsubscribe();
21 | c.destroy();
22 | };
23 | }, [chart, elementId, data, subscription, extraChartOptions]);
24 | }
25 |
--------------------------------------------------------------------------------
/src/hooks/useRemainingViewPortHeight.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | const { useState, useRef, useCallback, useLayoutEffect } = React;
4 |
5 | /**
6 | * cosnt [ref, remainingHeight] = useRemainingViewPortHeight();
7 | *
8 | * return a reference, and the remaining height of the referenced dom node
9 | * to the bottom of the view port
10 | *
11 | */
12 | export default function useRemainingViewPortHeight(): [
13 | React.MutableRefObject,
14 | number,
15 | ] {
16 | const ref = useRef(null);
17 | const [containerHeight, setContainerHeight] = useState(200);
18 | const updateContainerHeight = useCallback(() => {
19 | const { top } = ref.current.getBoundingClientRect();
20 | setContainerHeight(window.innerHeight - top);
21 | }, []);
22 |
23 | useLayoutEffect(() => {
24 | updateContainerHeight();
25 | window.addEventListener('resize', updateContainerHeight);
26 | return () => {
27 | window.removeEventListener('resize', updateContainerHeight);
28 | };
29 | }, [updateContainerHeight]);
30 |
31 | return [ref, containerHeight];
32 | }
33 |
--------------------------------------------------------------------------------
/src/hooks/useTextInput.ts:
--------------------------------------------------------------------------------
1 | import { PrimitiveAtom, useAtom } from 'jotai';
2 | import debounce from 'lodash-es/debounce';
3 | import * as React from 'react';
4 |
5 | const { useCallback, useState, useMemo } = React;
6 |
7 | export function useTextInput(
8 | x: PrimitiveAtom,
9 | ): [(e: React.ChangeEvent) => void, string] {
10 | const [, setTextGlobal] = useAtom(x);
11 | const [text, setText] = useState('');
12 | const setTextDebounced = useMemo(() => debounce(setTextGlobal, 300), [setTextGlobal]);
13 | const onChange = useCallback(
14 | (e: React.ChangeEvent) => {
15 | setText(e.target.value);
16 | setTextDebounced(e.target.value);
17 | },
18 | [setTextDebounced],
19 | );
20 | return [onChange, text];
21 | }
22 |
--------------------------------------------------------------------------------
/src/i18n/en.ts:
--------------------------------------------------------------------------------
1 | export const data = {
2 | Overview: 'Overview',
3 | Proxies: 'Proxies',
4 | Rules: 'Rules',
5 | Conns: 'Conns',
6 | Config: 'Config',
7 | Logs: 'Logs',
8 | Upload: 'Upload',
9 | Download: 'Download',
10 | 'Upload Total': 'Upload Total',
11 | 'Download Total': 'Download Total',
12 | 'Active Connections': 'Active Connections',
13 | 'Pause Refresh': 'Pause Refresh',
14 | 'Resume Refresh': 'Resume Refresh',
15 | Up: 'Up',
16 | Down: 'Down',
17 | 'Test Latency': 'Test Latency',
18 | settings: 'settings',
19 | sort_in_grp: 'Sorting in group',
20 | hide_unavail_proxies: 'Hide unavailable proxies',
21 | auto_close_conns: 'Automatically close old connections',
22 | order_natural: 'Original order in config file',
23 | order_latency_asc: 'By latency from small to big',
24 | order_latency_desc: 'By latency from big to small',
25 | order_name_asc: 'By name alphabetically (A-Z)',
26 | order_name_desc: 'By name alphabetically (Z-A)',
27 | Connections: 'Connections',
28 | current_backend: 'Current Backend',
29 | Active: 'Active',
30 | switch_backend: 'Switch backend',
31 | Closed: 'Closed',
32 | switch_theme: 'Switch theme',
33 | theme: 'theme',
34 | about: 'about',
35 | no_logs: 'No logs yet, hang tight...',
36 | chart_style: 'Chart Style',
37 | latency_test_url: 'Latency Test URL',
38 | lang: 'Language',
39 | update_all_rule_provider: 'Update all rule providers',
40 | update_all_proxy_provider: 'Update all proxy providers',
41 | dark_mode_pure_black_toggle_label: 'Use pure black in dark mode',
42 | };
43 |
--------------------------------------------------------------------------------
/src/i18n/zh.ts:
--------------------------------------------------------------------------------
1 | export const data = {
2 | Overview: '概览',
3 | Proxies: '代理',
4 | Rules: '规则',
5 | Conns: '连接',
6 | Config: '配置',
7 | Logs: '日志',
8 | Upload: '上传',
9 | Download: '下载',
10 | 'Upload Total': '上传总量',
11 | 'Download Total': '下载总量',
12 | 'Active Connections': '活动连接',
13 | 'Pause Refresh': '暂停刷新',
14 | 'Resume Refresh': '继续刷新',
15 | Up: '上传',
16 | Down: '下载',
17 | 'Test Latency': '延迟测速',
18 | settings: '设置',
19 | sort_in_grp: '代理组条目排序',
20 | hide_unavail_proxies: '隐藏不可用代理',
21 | auto_close_conns: '切换代理时自动断开旧连接',
22 | order_natural: '原 config 文件中的排序',
23 | order_latency_asc: '按延迟从小到大',
24 | order_latency_desc: '按延迟从大到小',
25 | order_name_asc: '按名称字母排序 (A-Z)',
26 | order_name_desc: '按名称字母排序 (Z-A)',
27 | Connections: '连接',
28 | current_backend: '当前后端',
29 | Active: '活动',
30 | switch_backend: '切换后端',
31 | Closed: '已断开',
32 | switch_theme: '切换主题',
33 | theme: '主题',
34 | about: '关于',
35 | no_logs: '暂无日志...',
36 | chart_style: '流量图样式',
37 | latency_test_url: '延迟测速 URL',
38 | lang: '语言',
39 | update_all_rule_provider: '更新所有 rule provider',
40 | update_all_proxy_provider: '更新所有 proxy providers',
41 | dark_mode_pure_black_toggle_label: '在黑色主题下使用纯黑背景',
42 | };
43 |
--------------------------------------------------------------------------------
/src/misc/chart-lib.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CategoryScale,
3 | Chart,
4 | Filler,
5 | Legend,
6 | LinearScale,
7 | LineController,
8 | LineElement,
9 | PointElement,
10 | } from 'chart.js';
11 |
12 | // see https://www.chartjs.org/docs/latest/getting-started/integration.html#bundlers-webpack-rollup-etc
13 | Chart.register(
14 | LineElement,
15 | PointElement,
16 | LineController,
17 | CategoryScale,
18 | LinearScale,
19 | Filler,
20 | Legend,
21 | );
22 |
23 | export { Chart };
24 |
--------------------------------------------------------------------------------
/src/misc/chart.ts:
--------------------------------------------------------------------------------
1 | import { createAsset } from 'use-asset';
2 |
3 | import prettyBytes from './pretty-bytes';
4 | export const chartJSResource = createAsset(() => {
5 | return import('$src/misc/chart-lib');
6 | });
7 |
8 | export const commonDataSetProps = { borderWidth: 1, pointRadius: 0, tension: 0.2, fill: true };
9 |
10 | export const commonChartOptions: import('chart.js').ChartOptions<'line'> = {
11 | responsive: true,
12 | maintainAspectRatio: true,
13 | plugins: {
14 | legend: { labels: { boxWidth: 20 } },
15 | },
16 | scales: {
17 | x: { display: false, type: 'category' },
18 | y: {
19 | type: 'linear',
20 | display: true,
21 | grid: {
22 | display: true,
23 | color: '#555',
24 | drawTicks: false,
25 | },
26 | border: {
27 | display: false,
28 | dash: [3, 6],
29 | },
30 | ticks: {
31 | callback(value: number) {
32 | return prettyBytes(value) + '/s ';
33 | },
34 | },
35 | },
36 | },
37 | };
38 |
39 | export const chartStyles = [
40 | {
41 | down: {
42 | backgroundColor: 'rgba(176, 209, 132, 0.8)',
43 | borderColor: 'rgb(176, 209, 132)',
44 | },
45 | up: {
46 | backgroundColor: 'rgba(181, 220, 231, 0.8)',
47 | borderColor: 'rgb(181, 220, 231)',
48 | },
49 | },
50 | {
51 | up: {
52 | backgroundColor: 'rgb(98, 190, 100)',
53 | borderColor: 'rgb(78,146,79)',
54 | },
55 | down: {
56 | backgroundColor: 'rgb(160, 230, 66)',
57 | borderColor: 'rgb(110, 156, 44)',
58 | },
59 | },
60 | {
61 | up: {
62 | backgroundColor: 'rgba(94, 175, 223, 0.3)',
63 | borderColor: 'rgb(94, 175, 223)',
64 | },
65 | down: {
66 | backgroundColor: 'rgba(139, 227, 195, 0.3)',
67 | borderColor: 'rgb(139, 227, 195)',
68 | },
69 | },
70 | {
71 | up: {
72 | backgroundColor: 'rgba(242, 174, 62, 0.3)',
73 | borderColor: 'rgb(242, 174, 62)',
74 | },
75 | down: {
76 | backgroundColor: 'rgba(69, 154, 248, 0.3)',
77 | borderColor: 'rgb(69, 154, 248)',
78 | },
79 | },
80 | ];
81 |
--------------------------------------------------------------------------------
/src/misc/constants.ts:
--------------------------------------------------------------------------------
1 | export const ENDPOINT = {
2 | config: '/configs',
3 | };
4 |
--------------------------------------------------------------------------------
/src/misc/createResource.ts:
--------------------------------------------------------------------------------
1 | // from https://gist.github.com/ryanflorence/e10cc9dbc0e259759ec942ba82e5b57c
2 | export function createResource(getPromise: (key: string) => Promise) {
3 | let cache = {};
4 | const inflight = {};
5 | const errors = {};
6 |
7 | function load(key = 'default') {
8 | inflight[key] = getPromise(key)
9 | .then((val) => {
10 | delete inflight[key];
11 | cache[key] = val;
12 | })
13 | .catch((error) => {
14 | errors[key] = error;
15 | });
16 | return inflight[key];
17 | }
18 |
19 | function preload(key = 'default') {
20 | if (cache[key] !== undefined || inflight[key]) return;
21 | load(key);
22 | }
23 |
24 | function read(key = 'default') {
25 | if (cache[key] !== undefined) {
26 | return cache[key];
27 | } else if (errors[key]) {
28 | throw errors[key];
29 | } else if (inflight[key]) {
30 | throw inflight[key];
31 | } else {
32 | throw load(key);
33 | }
34 | }
35 |
36 | function clear(key: 'default') {
37 | if (key) {
38 | delete cache[key];
39 | } else {
40 | cache = {};
41 | }
42 | }
43 |
44 | return { preload, read, clear };
45 | }
46 |
--------------------------------------------------------------------------------
/src/misc/errors.ts:
--------------------------------------------------------------------------------
1 | import { SimplifiedResponse } from '$src/api/fetch';
2 | import { ClashAPIConfig } from '$src/types';
3 |
4 | export const DOES_NOT_SUPPORT_FETCH = 0;
5 |
6 | export class YacdError extends Error {
7 | constructor(
8 | public message: string,
9 | public code?: string | number,
10 | ) {
11 | super(message);
12 | }
13 | }
14 |
15 | export class YacdFetchNetworkError extends Error {
16 | constructor(
17 | public message: string,
18 | public ctx: { endpoint: string; apiConfig: ClashAPIConfig },
19 | ) {
20 | super(message);
21 | }
22 | }
23 |
24 | export class YacdBackendUnauthorizedError extends Error {
25 | constructor(
26 | public message: string,
27 | public ctx: { endpoint: string; apiConfig: ClashAPIConfig },
28 | ) {
29 | super(message);
30 | }
31 | }
32 |
33 | export class YacdBackendGeneralError extends Error {
34 | constructor(
35 | public message: string,
36 | public ctx: { endpoint: string; apiConfig: ClashAPIConfig; response: SimplifiedResponse },
37 | ) {
38 | super(message);
39 | }
40 | }
41 |
42 | export const errors = {
43 | [DOES_NOT_SUPPORT_FETCH]: {
44 | message: 'Browser not supported!',
45 | detail: 'This browser does not support "fetch", please choose another one.',
46 | },
47 | default: {
48 | message: 'Oops, something went wrong!',
49 | },
50 | };
51 |
52 | export type Err = { code: number };
53 |
54 | export function deriveMessageFromError(err: Err) {
55 | const { code } = err;
56 | if (typeof code === 'number') {
57 | return errors[code];
58 | }
59 | return errors.default;
60 | }
61 |
--------------------------------------------------------------------------------
/src/misc/i18n.ts:
--------------------------------------------------------------------------------
1 | import type { ReadCallback } from 'i18next';
2 | import i18next from 'i18next';
3 | import LanguageDetector from 'i18next-browser-languagedetector';
4 | import { initReactI18next } from 'react-i18next';
5 |
6 | const LngBackend = {
7 | type: 'backend' as const,
8 | read: (lng: string, _namespace: string, callback: ReadCallback) => {
9 | let p: PromiseLike<{ data: any }>;
10 | switch (lng) {
11 | case 'zh':
12 | case 'zh-CN':
13 | p = import('src/i18n/zh');
14 | break;
15 | case 'en':
16 | default:
17 | p = import('src/i18n/en');
18 | break;
19 | }
20 | if (p) {
21 | p.then(
22 | (d) => callback(null, d.data),
23 | (err) => callback(err, null),
24 | );
25 | } else {
26 | callback(new Error(`unable to load translation file for language ${lng}`), null);
27 | }
28 | },
29 | };
30 |
31 | i18next
32 | .use(initReactI18next)
33 | .use(LanguageDetector)
34 | .use(LngBackend)
35 | .init({
36 | debug: process.env.NODE_ENV === 'development',
37 | fallbackLng: 'en',
38 | interpolation: {
39 | escapeValue: false,
40 | },
41 | });
42 |
43 | if (process.env.NODE_ENV === 'development') {
44 | window.i18n = i18next;
45 | }
46 |
47 | export default i18next;
48 |
--------------------------------------------------------------------------------
/src/misc/keycode.ts:
--------------------------------------------------------------------------------
1 | export const keyCodes = {
2 | Right: 39,
3 | Left: 37,
4 | Enter: 13,
5 | Space: 32,
6 | };
7 |
--------------------------------------------------------------------------------
/src/misc/motion.ts:
--------------------------------------------------------------------------------
1 | import { createResource } from './createResource';
2 |
3 | export const framerMotionResource = createResource(() => import('framer-motion'));
4 |
--------------------------------------------------------------------------------
/src/misc/pretty-bytes.ts:
--------------------------------------------------------------------------------
1 | // steal from https://github.com/sindresorhus/pretty-bytes/blob/master/index.js
2 |
3 | const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
4 |
5 | export default function prettyBytes(n: number) {
6 | if (n < 1000) {
7 | return n + ' B';
8 | }
9 | const exponent = Math.min(Math.floor(Math.log10(n) / 3), UNITS.length - 1);
10 | n = Number((n / Math.pow(1000, exponent)).toPrecision(3));
11 | const unit = UNITS[exponent];
12 | return n + ' ' + unit;
13 | }
14 |
--------------------------------------------------------------------------------
/src/misc/query.ts:
--------------------------------------------------------------------------------
1 | import { QueryCache, QueryClient } from '@tanstack/react-query';
2 |
3 | const queryCache = new QueryCache();
4 | export const queryClient = new QueryClient({
5 | queryCache,
6 | defaultOptions: {
7 | queries: {
8 | suspense: true,
9 | },
10 | },
11 | });
12 |
--------------------------------------------------------------------------------
/src/misc/request-helper.ts:
--------------------------------------------------------------------------------
1 | import { trimTrailingSlash } from 'src/misc/utils';
2 | import { ClashAPIConfig } from 'src/types';
3 | import { LogsAPIConfig } from 'src/types';
4 |
5 | const headersCommon = { 'Content-Type': 'application/json' };
6 |
7 | function genCommonHeaders({ secret }: { secret?: string }) {
8 | const h = { ...headersCommon };
9 | if (secret) {
10 | h['Authorization'] = `Bearer ${secret}`;
11 | }
12 | return h;
13 | }
14 | function buildWebSocketURLBase(baseURL: string, params: URLSearchParams, endpoint: string) {
15 | const qs = '?' + params.toString();
16 | const url = new URL(baseURL);
17 | url.protocol === 'https:' ? (url.protocol = 'wss:') : (url.protocol = 'ws:');
18 | return `${trimTrailingSlash(url.href)}${endpoint}${qs}`;
19 | }
20 |
21 | export function getURLAndInit({ baseURL, secret }: ClashAPIConfig) {
22 | const headers = genCommonHeaders({ secret });
23 | return {
24 | url: baseURL,
25 | init: { headers },
26 | };
27 | }
28 |
29 | export function buildWebSocketURL(apiConfig: ClashAPIConfig, endpoint: string) {
30 | const { baseURL, secret } = apiConfig;
31 | const params = new URLSearchParams({
32 | token: secret,
33 | });
34 |
35 | return buildWebSocketURLBase(baseURL, params, endpoint);
36 | }
37 |
38 | export function buildLogsWebSocketURL(apiConfig: LogsAPIConfig, endpoint: string) {
39 | const { baseURL, secret, logLevel } = apiConfig;
40 | const params = new URLSearchParams({
41 | token: secret,
42 | level: logLevel,
43 | });
44 |
45 | return buildWebSocketURLBase(baseURL, params, endpoint);
46 | }
47 |
--------------------------------------------------------------------------------
/src/misc/shallowEqual.ts:
--------------------------------------------------------------------------------
1 | const hasOwn = Object.prototype.hasOwnProperty;
2 |
3 | function is(x: any, y: any) {
4 | if (x === y) {
5 | return x !== 0 || y !== 0 || 1 / x === 1 / y;
6 | } else {
7 | // eslint-disable-next-line no-self-compare
8 | return x !== x && y !== y;
9 | }
10 | }
11 |
12 | export default function shallowEqual(objA: unknown, objB: unknown) {
13 | if (is(objA, objB)) return true;
14 |
15 | if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
16 | return false;
17 | }
18 |
19 | const keysA = Object.keys(objA);
20 | const keysB = Object.keys(objB);
21 |
22 | if (keysA.length !== keysB.length) return false;
23 |
24 | for (let i = 0; i < keysA.length; i++) {
25 | if (!hasOwn.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) {
26 | return false;
27 | }
28 | }
29 |
30 | return true;
31 | }
32 |
--------------------------------------------------------------------------------
/src/misc/storage.ts:
--------------------------------------------------------------------------------
1 | // manage localStorage
2 |
3 | import { StateApp } from '$src/store/types';
4 |
5 | const StorageKey = 'yacd.haishan.me';
6 |
7 | export function loadState() {
8 | try {
9 | const serialized = localStorage.getItem(StorageKey);
10 | if (!serialized) return undefined;
11 | return JSON.parse(serialized);
12 | } catch (err) {
13 | return undefined;
14 | }
15 | }
16 |
17 | export function saveState(state: StateApp) {
18 | try {
19 | const serialized = JSON.stringify(state);
20 | localStorage.setItem(StorageKey, serialized);
21 | } catch (err) {
22 | // ignore
23 | }
24 | }
25 |
26 | export function clearState() {
27 | try {
28 | localStorage.removeItem(StorageKey);
29 | } catch (err) {
30 | // ignore
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/misc/utils.ts:
--------------------------------------------------------------------------------
1 | export function throttle(fn: (...args: T) => unknown, timeout: number) {
2 | let pending = false;
3 |
4 | return (...args: T) => {
5 | if (!pending) {
6 | pending = true;
7 | fn(...args);
8 | setTimeout(() => {
9 | pending = false;
10 | }, timeout);
11 | }
12 | };
13 | }
14 |
15 | export function debounce(fn: (...args: T) => unknown, timeout: number) {
16 | let timeoutId: ReturnType;
17 | return (...args: T) => {
18 | if (timeoutId) clearTimeout(timeoutId);
19 | timeoutId = setTimeout(() => {
20 | fn(...args);
21 | }, timeout);
22 | };
23 | }
24 |
25 | export function trimTrailingSlash(s: string) {
26 | return s.replace(/\/$/, '');
27 | }
28 |
29 | export function pad0(number: number | string, len: number): string {
30 | let output = String(number);
31 | while (output.length < len) {
32 | output = '0' + output;
33 | }
34 | return output;
35 | }
36 |
37 | // eslint-disable-next-line @typescript-eslint/no-empty-function
38 | export const noop = () => {};
39 |
40 | export function sleep(ms: number) {
41 | return new Promise((resolve) => setTimeout(resolve, ms));
42 | }
43 |
--------------------------------------------------------------------------------
/src/store/configs.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 |
3 | import { fetchConfigs2 } from '$src/api/configs';
4 | import { ENDPOINT } from '$src/misc/constants';
5 | import { useApiConfig } from '$src/store/app';
6 |
7 | export function useClashConfig() {
8 | const apiConfig = useApiConfig();
9 | return useQuery([ENDPOINT.config, apiConfig], fetchConfigs2);
10 | }
11 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { initialState as logs } from './logs';
2 | import { actions as proxiesActions, initialState as proxies } from './proxies';
3 |
4 | export const initialState = {
5 | proxies,
6 | logs,
7 | };
8 |
9 | export const actions = {
10 | proxies: proxiesActions,
11 | };
12 |
--------------------------------------------------------------------------------
/src/store/logs.ts:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect';
2 | import { DispatchFn, GetStateFn, Log, State } from 'src/store/types';
3 |
4 | const LogSize = 300;
5 |
6 | const getLogs = (s: State) => s.logs.logs;
7 | const getTail = (s: State) => s.logs.tail;
8 | export const getSearchText = (s: State) => s.logs.searchText;
9 | export const getLogsForDisplay = createSelector(
10 | getLogs,
11 | getTail,
12 | getSearchText,
13 | (logs, tail, searchText) => {
14 | const x = [];
15 | for (let i = tail; i >= 0; i--) {
16 | x.push(logs[i]);
17 | }
18 | if (logs.length === LogSize) {
19 | for (let i = LogSize - 1; i > tail; i--) {
20 | x.push(logs[i]);
21 | }
22 | }
23 |
24 | if (searchText === '') return x;
25 | return x.filter((r) => r.payload.toLowerCase().indexOf(searchText) >= 0);
26 | },
27 | );
28 |
29 | export function updateSearchText(text: string) {
30 | return (dispatch: DispatchFn) => {
31 | dispatch('logsUpdateSearchText', (s) => {
32 | s.logs.searchText = text.toLowerCase();
33 | });
34 | };
35 | }
36 |
37 | export function appendLog(log: Log) {
38 | return (dispatch: DispatchFn, getState: GetStateFn) => {
39 | const s = getState();
40 | const logs = getLogs(s);
41 | const tailCurr = getTail(s);
42 | const tail = tailCurr >= LogSize - 1 ? 0 : tailCurr + 1;
43 | // mutate intentionally for performance
44 | logs[tail] = log;
45 |
46 | dispatch('logsAppendLog', (s: State) => {
47 | s.logs.tail = tail;
48 | });
49 | };
50 | }
51 |
52 | export const initialState = {
53 | searchText: '',
54 | logs: [],
55 | // tail's initial value must be -1
56 | tail: -1,
57 | };
58 |
--------------------------------------------------------------------------------
/src/store/rules.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'jotai';
2 |
3 | export const ruleFilterTextAtom = atom('');
4 |
5 | export type TextAtom = typeof ruleFilterTextAtom;
6 |
--------------------------------------------------------------------------------
/src/store/types.ts:
--------------------------------------------------------------------------------
1 | import type { ClashAPIConfig } from 'src/types';
2 |
3 | export type ThemeType = 'dark' | 'light' | 'auto';
4 |
5 | export type StateApp = {
6 | selectedClashAPIConfigIndex: number;
7 | clashAPIConfigs: ClashAPIConfig[];
8 |
9 | latencyTestUrl: string;
10 | selectedChartStyleIndex: number;
11 | theme: ThemeType;
12 |
13 | collapsibleIsOpen: Record;
14 | proxySortBy: string;
15 | hideUnavailableProxies: boolean;
16 | autoCloseOldConns: boolean;
17 | logStreamingPaused: boolean;
18 | };
19 |
20 | export type ClashGeneralConfig = {
21 | port: number;
22 | 'socks-port': number;
23 | 'redir-port': number;
24 | 'allow-lan': boolean;
25 | mode: string;
26 | 'log-level': string;
27 | // new
28 | authentication?: unknown[];
29 | 'bind-address'?: string;
30 | ipv6?: boolean;
31 | 'mixed-port'?: number;
32 | 'tproxy-port'?: number;
33 | };
34 |
35 | ///// store.proxies
36 |
37 | type LatencyHistoryItem = { time: string; delay: number };
38 | export type LatencyHistory = LatencyHistoryItem[];
39 |
40 | export type ProxyItem = {
41 | name: string;
42 | type: string;
43 | history: LatencyHistory;
44 | all?: string[];
45 | now?: string;
46 |
47 | __provider?: string;
48 | };
49 |
50 | export type ProxyDelayItem =
51 | | { kind: 'Result'; number: number }
52 | | { kind: 'Testing' }
53 | | { kind: 'Error'; message: string }
54 | | { kind: 'None' };
55 |
56 | export type ProxiesMapping = Record;
57 | export type DelayMapping = Record;
58 |
59 | export type ProxyProvider = {
60 | name: string;
61 | type: 'Proxy';
62 | updatedAt: string;
63 | vehicleType: 'HTTP' | 'File' | 'Compatible';
64 | proxies: ProxyItem[];
65 | };
66 |
67 | export type FormattedProxyProvider = Omit & { proxies: string[] };
68 |
69 | export type SwitchProxyCtxItem = { groupName: string; itemName: string };
70 | type SwitchProxyCtx = { to: SwitchProxyCtxItem };
71 |
72 | export type StateProxies = {
73 | groupNames: string[];
74 | proxyProviders?: FormattedProxyProvider[];
75 |
76 | proxies: ProxiesMapping;
77 | delay: DelayMapping;
78 | dangleProxyNames?: string[];
79 |
80 | showModalClosePrevConns: boolean;
81 | switchProxyCtx?: SwitchProxyCtx;
82 | };
83 |
84 | ///// store.logs
85 |
86 | export type Log = {
87 | time: string;
88 | even: boolean;
89 | payload: string;
90 | type: string;
91 | id: string;
92 | };
93 |
94 | export type StateLogs = {
95 | searchText: string;
96 | logs: Log[];
97 | tail: number;
98 | };
99 |
100 | //////
101 |
102 | export type State = {
103 | proxies: StateProxies;
104 | logs: StateLogs;
105 | };
106 |
107 | export type GetStateFn = () => State;
108 | export interface DispatchFn {
109 | (msg: string, change: (s: State) => void): void;
110 | (
111 | action: (dispatch: DispatchFn, getState: GetStateFn) => Promise,
112 | ): ReturnType;
113 | (action: (dispatch: DispatchFn, getState: GetStateFn) => void): ReturnType;
114 | }
115 |
--------------------------------------------------------------------------------
/src/sw.ts:
--------------------------------------------------------------------------------
1 | ///
2 | /* eslint-disable no-restricted-globals */
3 |
4 | // This service worker can be customized!
5 | // See https://developers.google.com/web/tools/workbox/modules
6 | // for the list of available Workbox modules, or add any other
7 | // code you'd like.
8 | // You can also remove this file if you'd prefer not to use a
9 | // service worker, and the Workbox build step will be skipped.
10 |
11 | import { clientsClaim } from 'workbox-core';
12 | import { ExpirationPlugin } from 'workbox-expiration';
13 | import { createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching';
14 | import { registerRoute } from 'workbox-routing';
15 | import { StaleWhileRevalidate } from 'workbox-strategies';
16 |
17 | declare const self: ServiceWorkerGlobalScope;
18 |
19 | clientsClaim();
20 |
21 | precacheAndRoute(self.__WB_MANIFEST);
22 |
23 | // Set up App Shell-style routing, so that all navigation requests
24 | // are fulfilled with your index.html shell. Learn more at
25 | // https://developers.google.com/web/fundamentals/architecture/app-shell
26 | const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
27 | registerRoute(
28 | // Return false to exempt requests from being fulfilled by index.html.
29 | ({ request, url }: { request: Request; url: URL }) => {
30 | // If this isn't a navigation, skip.
31 | if (request.mode !== 'navigate') {
32 | return false;
33 | }
34 |
35 | // If this is a URL that starts with /_, skip.
36 | if (url.pathname.startsWith('/_')) {
37 | return false;
38 | }
39 |
40 | // If this looks like a URL for a resource, because it contains
41 | // a file extension, skip.
42 | if (url.pathname.match(fileExtensionRegexp)) {
43 | return false;
44 | }
45 |
46 | // Return true to signal that we want to use the handler.
47 | return true;
48 | },
49 | createHandlerBoundToURL('index.html'),
50 | );
51 |
52 | // An example runtime caching route for requests that aren't handled by the
53 | // precache, in this case same-origin .png requests like those from in public/
54 | registerRoute(
55 | // Add in any other file extensions or routing criteria as needed.
56 | ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
57 | // Customize this strategy as needed, e.g., by changing to CacheFirst.
58 | new StaleWhileRevalidate({
59 | cacheName: 'images',
60 | plugins: [
61 | // Ensure that once this runtime cache reaches a maximum size the
62 | // least-recently used images are removed.
63 | new ExpirationPlugin({ maxEntries: 50 }),
64 | ],
65 | }),
66 | );
67 |
68 | // This allows the web app to trigger skipWaiting via
69 | // registration.waiting.postMessage({type: 'SKIP_WAITING'})
70 | self.addEventListener('message', (event) => {
71 | if (event.data && event.data.type === 'SKIP_WAITING') {
72 | self.skipWaiting();
73 | }
74 | });
75 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export type ClashAPIConfig = {
2 | baseURL: string;
3 | secret?: string;
4 |
5 | // metadata
6 | metaLabel?: string;
7 | addedAt?: number;
8 | };
9 |
10 | export type LogsAPIConfig = ClashAPIConfig & { logLevel: string };
11 |
12 | export type RuleType = { id?: number; type?: string; payload?: string; proxy?: string };
13 |
14 | export type FetchCtx = {
15 | endpoint: string;
16 | apiConfig: ClashAPIConfig;
17 | };
18 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: [
4 | "./index.html",
5 | "./src/**/*.{js,ts,jsx,tsx}",
6 | ],
7 | theme: {
8 | extend: {},
9 | },
10 | plugins: [],
11 | }
12 |
13 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "paths": {
6 | "$src": ["src"],
7 | "$src/*": ["src/*"]
8 | },
9 | "target": "ESNext",
10 | "lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
11 | "skipLibCheck": true,
12 | "esModuleInterop": false,
13 | "allowSyntheticDefaultImports": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "allowJs": false,
16 | "module": "ESNext",
17 | "moduleResolution": "Node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react"
22 | },
23 | "include": ["./src", "vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react'
2 | import * as path from 'path';
3 | import { defineConfig } from 'vite';
4 | import { VitePWA } from 'vite-plugin-pwa';
5 |
6 | import * as pkg from './package.json';
7 |
8 | // https://vitejs.dev/config/
9 | export default defineConfig(async ({ mode }) => {
10 | let hash = process.env.COMMIT_HASH;
11 | if (!hash) {
12 | try {
13 | hash = await gitHash();
14 | hash = hash.trim();
15 | } catch (e) { }
16 | }
17 | if (!hash) hash = '';
18 | console.log('commit hash', hash);
19 |
20 | return {
21 | define: {
22 | __VERSION__: JSON.stringify(pkg.version),
23 | __COMMIT_HASH__: JSON.stringify(hash),
24 | 'process.env.NODE_ENV': JSON.stringify(mode),
25 | 'process.env.PUBLIC_URL': JSON.stringify(''),
26 | },
27 | base: './',
28 | resolve: {
29 | alias: {
30 | $src: path.resolve(__dirname, './src'),
31 | src: path.resolve(__dirname, './src'),
32 | },
33 | },
34 | publicDir: 'assets',
35 | build: {
36 | // sourcemap: true,
37 | // the default value is 'dist'
38 | // which make more sense
39 | // but change this may break other people's tools
40 | outDir: 'public',
41 | },
42 | plugins: [
43 | react(),
44 | VitePWA({
45 | srcDir: 'src',
46 | outDir: 'public',
47 | filename: 'sw.ts',
48 | strategies: 'injectManifest',
49 | base: './',
50 | }),
51 | ],
52 | }
53 | });
54 |
55 | // non vite stuff
56 |
57 | async function gitHash() {
58 | try {
59 | const mod = await import('node:child_process');
60 | return await run(mod.spawn, 'git', ['rev-parse', '--short', 'HEAD']);
61 | } catch (e) {
62 | return;
63 | }
64 | }
65 |
66 | function run(spawn: typeof import('node:child_process').spawn, cmd0: string, args0: string[]): Promise {
67 | const cmd = cmd0;
68 | const args = args0;
69 |
70 | return new Promise((resolve, reject) => {
71 | const proc = spawn(cmd, args);
72 | let out = Buffer.from('');
73 | proc.stdout.on('data', (data) => {
74 | out += data;
75 | });
76 | proc.on('error', (err) => {
77 | reject(err);
78 | });
79 | proc.on('exit', (code) => {
80 | if (code !== 0) reject(code);
81 | resolve(out.toString());
82 | });
83 | });
84 | }
85 |
--------------------------------------------------------------------------------