├── .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 | yacd 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 | 100 | ); 101 | })} 102 | 103 | ); 104 | })} 105 | 106 | 107 | {table.getRowModel().rows.map((row) => { 108 | return ( 109 | 110 | {row.getVisibleCells().map((cell) => { 111 | return ( 112 | 113 | ); 114 | })} 115 | 116 | ); 117 | })} 118 | 119 |
85 | 86 | {flexRender(header.column.columnDef.header, header.getContext())} 87 | {header.column.getIsSorted() ? ( 88 | 95 | 96 | 97 | ) : null} 98 | 99 |
{flexRender(cell.column.columnDef.cell, cell.getContext())}
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 | 16 | 17 | 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 |
13 |
14 |
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 |
25 | {optionPropsList.map((props, idx) => { 26 | return ( 27 | 41 | ); 42 | })} 43 |
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 | 31 | 32 | {/* face */} 33 | 41 | 42 | 43 | {/* mouth */} 44 | 45 | 46 | 47 | 48 | 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 |
13 |

{name}

14 |

15 | Version 16 | {version} 17 |

18 |

19 | 20 | 21 | Source 22 | 23 |

24 |
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 | 8 | 12 | 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 | 15 | {options.map(([value, name]) => ( 16 | 19 | ))} 20 | 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 | 22 | 23 | 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 |
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 | 21 | 22 | 23 | 24 | 25 | 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 | --------------------------------------------------------------------------------