├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml ├── build-for-linux │ ├── Dockerfile │ ├── action.yml │ ├── build.sh │ └── entrypoint.sh └── workflows │ ├── release.yml │ └── updater.yml ├── .gitignore ├── .husky └── pre-commit ├── LICENSE ├── README.md ├── UPDATELOG.md ├── docs └── preview.gif ├── package.json ├── patches └── support-windows-aarch64.patch ├── pnpm-lock.yaml ├── scripts ├── check.mjs ├── portable.mjs ├── updatelog.mjs └── updater.mjs ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Info.plist ├── build.rs ├── clash-verge.desktop ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon-new.icns │ ├── icon-shrink.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── mac-tray-icon-sys.png │ ├── mac-tray-icon-tun.png │ ├── mac-tray-icon.png │ ├── tray-icon-sys.png │ ├── tray-icon-tun.png │ ├── tray-icon.ico │ └── tray-icon.png ├── rustfmt.toml ├── src │ ├── cmds.rs │ ├── config │ │ ├── clash.rs │ │ ├── config.rs │ │ ├── draft.rs │ │ ├── mod.rs │ │ ├── prfitem.rs │ │ ├── profiles.rs │ │ ├── runtime.rs │ │ └── verge.rs │ ├── core │ │ ├── clash_api.rs │ │ ├── core.rs │ │ ├── handle.rs │ │ ├── hotkey.rs │ │ ├── logger.rs │ │ ├── manager.rs │ │ ├── mod.rs │ │ ├── sysopt.rs │ │ ├── timer.rs │ │ ├── tray.rs │ │ ├── win_service.rs │ │ └── win_uwp.rs │ ├── enhance │ │ ├── builtin │ │ │ ├── meta_guard.js │ │ │ └── meta_hy_alpn.js │ │ ├── chain.rs │ │ ├── field.rs │ │ ├── merge.rs │ │ ├── mod.rs │ │ ├── script.rs │ │ └── tun.rs │ ├── feat.rs │ ├── main.rs │ └── utils │ │ ├── dirs.rs │ │ ├── help.rs │ │ ├── init.rs │ │ ├── mod.rs │ │ ├── resolve.rs │ │ ├── server.rs │ │ └── tmpl.rs ├── tauri.conf.json ├── tauri.linux.conf.json ├── tauri.macos.conf.json └── tauri.windows.conf.json ├── src ├── assets │ ├── fonts │ │ └── Twemoji.Mozilla.ttf │ ├── image │ │ ├── logo-box.png │ │ ├── logo.ico │ │ ├── logo.png │ │ └── logo.svg │ └── styles │ │ ├── font.scss │ │ ├── index.scss │ │ ├── layout.scss │ │ └── page.scss ├── components │ ├── base │ │ ├── base-dialog.tsx │ │ ├── base-empty.tsx │ │ ├── base-error-boundary.tsx │ │ ├── base-loading.tsx │ │ ├── base-notice.tsx │ │ ├── base-page.tsx │ │ └── index.ts │ ├── connection │ │ ├── connection-detail.tsx │ │ ├── connection-item.tsx │ │ └── connection-table.tsx │ ├── layout │ │ ├── layout-control.tsx │ │ ├── layout-item.tsx │ │ ├── layout-traffic.tsx │ │ ├── traffic-graph.tsx │ │ ├── update-button.tsx │ │ ├── use-custom-theme.ts │ │ └── use-log-setup.ts │ ├── log │ │ └── log-item.tsx │ ├── profile │ │ ├── editor-viewer.tsx │ │ ├── file-input.tsx │ │ ├── log-viewer.tsx │ │ ├── profile-box.tsx │ │ ├── profile-item.tsx │ │ ├── profile-more.tsx │ │ └── profile-viewer.tsx │ ├── proxy │ │ ├── provider-button.tsx │ │ ├── proxy-groups.tsx │ │ ├── proxy-head.tsx │ │ ├── proxy-item-mini.tsx │ │ ├── proxy-item.tsx │ │ ├── proxy-render.tsx │ │ ├── use-filter-sort.ts │ │ ├── use-head-state.ts │ │ ├── use-render-list.ts │ │ └── use-window-width.ts │ ├── rule │ │ └── rule-item.tsx │ └── setting │ │ ├── mods │ │ ├── clash-core-viewer.tsx │ │ ├── clash-field-viewer.tsx │ │ ├── clash-port-viewer.tsx │ │ ├── config-viewer.tsx │ │ ├── controller-viewer.tsx │ │ ├── guard-state.tsx │ │ ├── hotkey-input.tsx │ │ ├── hotkey-viewer.tsx │ │ ├── layout-viewer.tsx │ │ ├── misc-viewer.tsx │ │ ├── service-viewer.tsx │ │ ├── setting-comp.tsx │ │ ├── sysproxy-viewer.tsx │ │ ├── theme-mode-switch.tsx │ │ ├── theme-viewer.tsx │ │ ├── update-viewer.tsx │ │ ├── web-ui-item.tsx │ │ └── web-ui-viewer.tsx │ │ ├── setting-clash.tsx │ │ ├── setting-system.tsx │ │ └── setting-verge.tsx ├── hooks │ ├── use-clash.ts │ ├── use-profiles.ts │ ├── use-verge.ts │ ├── use-visibility.ts │ └── use-websocket.ts ├── index.html ├── locales │ ├── en.json │ ├── ru.json │ └── zh.json ├── main.tsx ├── pages │ ├── _layout.tsx │ ├── _routers.tsx │ ├── _theme.tsx │ ├── connections.tsx │ ├── logs.tsx │ ├── profiles.tsx │ ├── proxies.tsx │ ├── rules.tsx │ └── settings.tsx ├── services │ ├── api.ts │ ├── cmds.ts │ ├── delay.ts │ ├── i18n.ts │ ├── states.ts │ └── types.d.ts └── utils │ ├── clash-fields.ts │ ├── custom-comparator.ts │ ├── get-system.ts │ ├── ignore-case.ts │ ├── noop.ts │ ├── parse-hotkey.ts │ ├── parse-traffic.ts │ └── truncate-str.ts ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | insert_final_newline = true 8 | 9 | [*.rs] 10 | charset = utf-8 11 | end_of_line = lf 12 | indent_size = 4 13 | insert_final_newline = true 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | title: "[BUG]" 4 | labels: ["bug"] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Describe the bug 9 | description: A clear and concise description of what the bug is. 10 | validations: 11 | required: true 12 | - type: textarea 13 | attributes: 14 | label: To Reproduce 15 | description: Steps to reproduce the behavior. 16 | validations: 17 | required: true 18 | - type: dropdown 19 | attributes: 20 | label: Platform 21 | options: 22 | - Windows 23 | - Linux 24 | - MacOS 25 | validations: 26 | required: true 27 | - type: input 28 | attributes: 29 | label: System Version 30 | placeholder: "e.g. macOS 10.15.7" 31 | validations: 32 | required: true 33 | - type: input 34 | attributes: 35 | label: Software Version 36 | placeholder: "e.g. 1.4.3" 37 | validations: 38 | required: true 39 | - type: textarea 40 | attributes: 41 | label: Log 42 | description: "Log file content or screenshot" 43 | - type: textarea 44 | attributes: 45 | label: Additional Information 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | title: "[Feature]" 4 | labels: ["enhancement"] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Is your feature request related to a problem? Please describe. 9 | description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 10 | validations: 11 | required: true 12 | - type: textarea 13 | attributes: 14 | label: Describe the solution you'd like 15 | description: A clear and concise description of what you want to happen. 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: Describe alternatives you've considered 21 | description: A clear and concise description of any alternative solutions or features you've considered. 22 | validations: 23 | required: true 24 | - type: textarea 25 | attributes: 26 | label: Additional context 27 | description: Add any other context or screenshots about the feature request here. 28 | -------------------------------------------------------------------------------- /.github/build-for-linux/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:buster 2 | COPY entrypoint.sh /entrypoint.sh 3 | RUN chmod a+x /entrypoint.sh 4 | ENTRYPOINT ["/entrypoint.sh"] -------------------------------------------------------------------------------- /.github/build-for-linux/action.yml: -------------------------------------------------------------------------------- 1 | name: "Build for Linux" 2 | branding: 3 | icon: user-check 4 | color: gray-dark 5 | inputs: 6 | target: 7 | required: true 8 | description: "Rust Target" 9 | 10 | runs: 11 | using: "docker" 12 | image: "Dockerfile" 13 | args: 14 | - ${{ inputs.target }} 15 | -------------------------------------------------------------------------------- /.github/build-for-linux/build.sh: -------------------------------------------------------------------------------- 1 | # pnpm install --resolution-only 2 | pnpm install 3 | pnpm check $INPUT_TARGET 4 | sed -i "s/#openssl/openssl={version=\"0.10\",features=[\"vendored\"]}/g" src-tauri/Cargo.toml 5 | if [ "$INPUT_TARGET" = "x86_64-unknown-linux-gnu" ] || [ "$INPUT_TARGET" = "i686-unknown-linux-gnu" ]; then 6 | pnpm build --target $INPUT_TARGET 7 | else 8 | pnpm build --target $INPUT_TARGET -b deb 9 | fi 10 | -------------------------------------------------------------------------------- /.github/build-for-linux/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | wget https://nodejs.org/dist/v20.10.0/node-v20.10.0-linux-x64.tar.xz 4 | tar -Jxvf ./node-v20.10.0-linux-x64.tar.xz 5 | export PATH=$(pwd)/node-v20.10.0-linux-x64/bin:$PATH 6 | npm install pnpm -g 7 | 8 | rustup target add "$INPUT_TARGET" 9 | 10 | if [ "$INPUT_TARGET" = "x86_64-unknown-linux-gnu" ]; then 11 | apt-get update 12 | apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev patchelf 13 | elif [ "$INPUT_TARGET" = "i686-unknown-linux-gnu" ]; then 14 | dpkg --add-architecture i386 15 | apt-get update 16 | apt-get install -y libstdc++6:i386 libgdk-pixbuf2.0-dev:i386 libatomic1:i386 gcc-multilib g++-multilib libwebkit2gtk-4.0-dev:i386 libssl-dev:i386 libgtk-3-dev:i386 librsvg2-dev:i386 patchelf:i386 libayatana-appindicator3-dev:i386 17 | export PKG_CONFIG_PATH=/usr/lib/i386-linux-gnu/pkgconfig/:$PKG_CONFIG_PATH 18 | export PKG_CONFIG_SYSROOT_DIR=/ 19 | elif [ "$INPUT_TARGET" = "aarch64-unknown-linux-gnu" ]; then 20 | sed 's/http:\/\/\(.*\).ubuntu.com\/ubuntu\//[arch-=amd64,i386] http:\/\/ports.ubuntu.com\/ubuntu-ports\//g' /etc/apt/sources.list | tee /etc/apt/sources.list.d/ports.list 21 | sed -i 's/http:\/\/\(.*\).ubuntu.com\/ubuntu\//[arch=amd64,i386] http:\/\/\1.archive.ubuntu.com\/ubuntu\//g' /etc/apt/sources.list 22 | dpkg --add-architecture arm64 23 | apt-get update 24 | apt-get install -y libncurses6:arm64 libtinfo6:arm64 linux-libc-dev:arm64 libncursesw6:arm64 libssl3:arm64 libcups2:arm64 25 | apt-get install -y --no-install-recommends g++-aarch64-linux-gnu libc6-dev-arm64-cross libssl-dev:arm64 libwebkit2gtk-4.0-dev:arm64 libgtk-3-dev:arm64 patchelf:arm64 librsvg2-dev:arm64 libayatana-appindicator3-dev:arm64 26 | export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc 27 | export CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc 28 | export CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ 29 | export PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig 30 | export PKG_CONFIG_ALLOW_CROSS=1 31 | elif [ "$INPUT_TARGET" = "armv7-unknown-linux-gnueabihf" ]; then 32 | sed 's/http:\/\/\(.*\).ubuntu.com\/ubuntu\//[arch-=amd64,i386] http:\/\/ports.ubuntu.com\/ubuntu-ports\//g' /etc/apt/sources.list | tee /etc/apt/sources.list.d/ports.list 33 | sed -i 's/http:\/\/\(.*\).ubuntu.com\/ubuntu\//[arch=amd64,i386] http:\/\/\1.archive.ubuntu.com\/ubuntu\//g' /etc/apt/sources.list 34 | dpkg --add-architecture armhf 35 | apt-get update 36 | apt-get install -y libncurses6:armhf libtinfo6:armhf linux-libc-dev:armhf libncursesw6:armhf libssl3:armhf libcups2:armhf 37 | apt-get install -y --no-install-recommends g++-arm-linux-gnueabihf libc6-dev-armhf-cross libssl-dev:armhf libwebkit2gtk-4.0-dev:armhf libgtk-3-dev:armhf patchelf:armhf librsvg2-dev:armhf libayatana-appindicator3-dev:armhf 38 | export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc 39 | export CC_armv7_unknown_linux_gnueabihf=arm-linux-gnueabihf-gcc 40 | export CXX_armv7_unknown_linux_gnueabihf=arm-linux-gnueabihf-g++ 41 | export PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig 42 | export PKG_CONFIG_ALLOW_CROSS=1 43 | else 44 | echo "Unknown target: $INPUT_TARGET" && exit 1 45 | fi 46 | 47 | bash .github/build-for-linux/build.sh -------------------------------------------------------------------------------- /.github/workflows/updater.yml: -------------------------------------------------------------------------------- 1 | name: Updater CI 2 | 3 | on: workflow_dispatch 4 | permissions: write-all 5 | jobs: 6 | release-update: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout repository 10 | uses: actions/checkout@v3 11 | 12 | - name: Install Node 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: "20" 16 | 17 | - uses: pnpm/action-setup@v2 18 | name: Install pnpm 19 | with: 20 | version: 8 21 | run_install: false 22 | 23 | - name: Pnpm install 24 | run: pnpm i 25 | 26 | - name: Release updater file 27 | run: pnpm updater 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | update.json 7 | scripts/_env.sh 8 | .vscode 9 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm pretty-quick --staged 5 | -------------------------------------------------------------------------------- /docs/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/docs/preview.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clash-verge", 3 | "version": "1.4.8", 4 | "license": "GPL-3.0", 5 | "scripts": { 6 | "dev": "tauri dev", 7 | "dev:diff": "tauri dev -f verge-dev", 8 | "build": "tauri build", 9 | "tauri": "tauri", 10 | "web:dev": "vite", 11 | "web:build": "tsc && vite build", 12 | "web:serve": "vite preview", 13 | "check": "node scripts/check.mjs", 14 | "updater": "node scripts/updater.mjs", 15 | "portable": "node scripts/portable.mjs", 16 | "prepare": "husky install" 17 | }, 18 | "dependencies": { 19 | "@dnd-kit/core": "^6.1.0", 20 | "@dnd-kit/sortable": "^8.0.0", 21 | "@dnd-kit/utilities": "^3.2.2", 22 | "@emotion/react": "^11.11.1", 23 | "@emotion/styled": "^11.11.0", 24 | "@juggle/resize-observer": "^3.4.0", 25 | "@mui/icons-material": "^5.14.19", 26 | "@mui/lab": "5.0.0-alpha.149", 27 | "@mui/material": "^5.14.19", 28 | "@mui/x-data-grid": "^6.18.2", 29 | "@tauri-apps/api": "^1.5.1", 30 | "ahooks": "^3.7.8", 31 | "axios": "^1.6.2", 32 | "dayjs": "1.11.5", 33 | "i18next": "^23.7.7", 34 | "lodash-es": "^4.17.21", 35 | "monaco-editor": "^0.34.1", 36 | "react": "^18.2.0", 37 | "react-dom": "^18.2.0", 38 | "react-error-boundary": "^3.1.4", 39 | "react-hook-form": "^7.48.2", 40 | "react-i18next": "^13.5.0", 41 | "react-router-dom": "^6.20.0", 42 | "react-transition-group": "^4.4.5", 43 | "react-virtuoso": "^4.6.2", 44 | "recoil": "^0.7.7", 45 | "snarkdown": "^2.0.0", 46 | "swr": "^1.3.0", 47 | "tar": "^6.2.0" 48 | }, 49 | "devDependencies": { 50 | "@actions/github": "^5.1.1", 51 | "@tauri-apps/cli": "^1.5.6", 52 | "@types/fs-extra": "^9.0.13", 53 | "@types/js-cookie": "^3.0.6", 54 | "@types/lodash": "^4.14.202", 55 | "@types/lodash-es": "^4.17.12", 56 | "@types/react": "^18.2.39", 57 | "@types/react-dom": "^18.2.17", 58 | "@types/react-transition-group": "^4.4.9", 59 | "@vitejs/plugin-react": "^4.2.0", 60 | "adm-zip": "^0.5.10", 61 | "cross-env": "^7.0.3", 62 | "fs-extra": "^11.2.0", 63 | "https-proxy-agent": "^5.0.1", 64 | "husky": "^7.0.4", 65 | "node-fetch": "^3.3.2", 66 | "prettier": "^2.8.8", 67 | "pretty-quick": "^3.1.3", 68 | "sass": "^1.69.5", 69 | "typescript": "^5.3.2", 70 | "vite": "^4.5.0", 71 | "vite-plugin-monaco-editor": "^1.1.0", 72 | "vite-plugin-svgr": "^4.2.0" 73 | }, 74 | "prettier": { 75 | "tabWidth": 2, 76 | "semi": true, 77 | "singleQuote": false, 78 | "endOfLine": "lf" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /scripts/portable.mjs: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path from "path"; 3 | import AdmZip from "adm-zip"; 4 | import { createRequire } from "module"; 5 | import { getOctokit, context } from "@actions/github"; 6 | 7 | const target = process.argv.slice(2)[0]; 8 | 9 | const ARCH_MAP = { 10 | "i686-pc-windows-msvc": "x86", 11 | "x86_64-pc-windows-msvc": "x64", 12 | "aarch64-pc-windows-msvc": "arm64", 13 | }; 14 | 15 | /// Script for ci 16 | /// 打包绿色版/便携版 (only Windows) 17 | async function resolvePortable() { 18 | if (process.platform !== "win32") return; 19 | 20 | const releaseDir = target 21 | ? `./src-tauri/target/${target}/release` 22 | : `./src-tauri/target/release`; 23 | const configDir = path.join(releaseDir, ".config"); 24 | 25 | if (!(await fs.pathExists(releaseDir))) { 26 | throw new Error("could not found the release dir"); 27 | } 28 | 29 | await fs.mkdir(configDir); 30 | await fs.createFile(path.join(configDir, "PORTABLE")); 31 | 32 | const zip = new AdmZip(); 33 | 34 | zip.addLocalFile(path.join(releaseDir, "Clash Verge.exe")); 35 | zip.addLocalFile(path.join(releaseDir, "clash-meta.exe")); 36 | zip.addLocalFile(path.join(releaseDir, "clash-meta-alpha.exe")); 37 | zip.addLocalFolder(path.join(releaseDir, "resources"), "resources"); 38 | zip.addLocalFolder(configDir, ".config"); 39 | 40 | const require = createRequire(import.meta.url); 41 | const packageJson = require("../package.json"); 42 | const { version } = packageJson; 43 | 44 | const zipFile = `Clash.Verge_${version}_${ARCH_MAP[target]}_portable.zip`; 45 | zip.writeZip(zipFile); 46 | 47 | console.log("[INFO]: create portable zip successfully"); 48 | 49 | // push release assets 50 | if (process.env.GITHUB_TOKEN === undefined) { 51 | throw new Error("GITHUB_TOKEN is required"); 52 | } 53 | 54 | const options = { owner: context.repo.owner, repo: context.repo.repo }; 55 | const github = getOctokit(process.env.GITHUB_TOKEN); 56 | 57 | console.log("[INFO]: upload to ", process.env.TAG_NAME || `v${version}`); 58 | 59 | const { data: release } = await github.rest.repos.getReleaseByTag({ 60 | ...options, 61 | tag: process.env.TAG_NAME || `v${version}`, 62 | }); 63 | 64 | console.log(release.name); 65 | 66 | await github.rest.repos.uploadReleaseAsset({ 67 | ...options, 68 | release_id: release.id, 69 | name: zipFile, 70 | data: zip.toBuffer(), 71 | }); 72 | } 73 | 74 | resolvePortable().catch(console.error); 75 | -------------------------------------------------------------------------------- /scripts/updatelog.mjs: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path from "path"; 3 | 4 | const UPDATE_LOG = "UPDATELOG.md"; 5 | 6 | // parse the UPDATELOG.md 7 | export async function resolveUpdateLog(tag) { 8 | const cwd = process.cwd(); 9 | 10 | const reTitle = /^## v[\d\.]+/; 11 | const reEnd = /^---/; 12 | 13 | const file = path.join(cwd, UPDATE_LOG); 14 | 15 | if (!(await fs.pathExists(file))) { 16 | throw new Error("could not found UPDATELOG.md"); 17 | } 18 | 19 | const data = await fs.readFile(file).then((d) => d.toString("utf8")); 20 | 21 | const map = {}; 22 | let p = ""; 23 | 24 | data.split("\n").forEach((line) => { 25 | if (reTitle.test(line)) { 26 | p = line.slice(3).trim(); 27 | if (!map[p]) { 28 | map[p] = []; 29 | } else { 30 | throw new Error(`Tag ${p} dup`); 31 | } 32 | } else if (reEnd.test(line)) { 33 | p = ""; 34 | } else if (p) { 35 | map[p].push(line); 36 | } 37 | }); 38 | 39 | if (!map[tag]) { 40 | throw new Error(`could not found "${tag}" in UPDATELOG.md`); 41 | } 42 | 43 | return map[tag].join("\n").trim(); 44 | } 45 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | WixTools 5 | resources 6 | sidecar 7 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "clash-verge" 3 | version = "1.4.8" 4 | description = "clash verge" 5 | authors = ["zzzgydi", "wonfen", "MystiPanda"] 6 | license = "GPL-3.0" 7 | repository = "https://github.com/MetaCubeX/clash-verge.git" 8 | default-run = "clash-verge" 9 | edition = "2021" 10 | build = "build.rs" 11 | 12 | [build-dependencies] 13 | tauri-build = { version = "1", features = [] } 14 | 15 | [dependencies] 16 | warp = "0.3" 17 | which = "5.0.0" 18 | anyhow = "1.0" 19 | dirs = "5.0" 20 | open = "5.0" 21 | log = "0.4" 22 | ctrlc = "3.4" 23 | dunce = "1.0" 24 | log4rs = "1" 25 | nanoid = "0.4" 26 | chrono = "0.4" 27 | sysinfo = "0.30" 28 | rquickjs = "0.3" # 高版本不支持 Linux aarch64 29 | serde_json = "1.0" 30 | serde_yaml = "0.9" 31 | auto-launch = "0.5" 32 | once_cell = "1.18" 33 | port_scanner = "0.1.5" 34 | delay_timer = "0.11.5" 35 | parking_lot = "0.12" 36 | percent-encoding = "2.3.1" 37 | window-shadows = { version = "0.2" } 38 | tokio = { version = "1", features = ["full"] } 39 | serde = { version = "1.0", features = ["derive"] } 40 | reqwest = { version = "0.11", features = ["json", "rustls-tls"] } 41 | sysproxy = { git="https://github.com/clash-verge-rev/sysproxy-rs", branch = "main" } 42 | tauri = { version = "1.5", features = [ "notification-all", "icon-png", "clipboard-all", "global-shortcut-all", "process-all", "shell-all", "system-tray", "updater", "window-all"] } 43 | 44 | [target.'cfg(windows)'.dependencies] 45 | runas = "=1.0.0" # 高版本会返回错误 Status 46 | deelevate = "0.2.0" 47 | winreg = "0.52.0" 48 | 49 | [target.'cfg(target_os = "linux")'.dependencies] 50 | #openssl 51 | 52 | [features] 53 | default = ["custom-protocol"] 54 | custom-protocol = ["tauri/custom-protocol"] 55 | verge-dev = [] 56 | 57 | [profile.release] 58 | panic = "abort" 59 | codegen-units = 1 60 | lto = true 61 | opt-level = "s" 62 | -------------------------------------------------------------------------------- /src-tauri/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleURLTypes 6 | 7 | 8 | CFBundleURLName 9 | Clash Verge 10 | CFBundleURLSchemes 11 | 12 | clash 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/clash-verge.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Categories={{{categories}}} 3 | Comment={{{comment}}} 4 | Exec={{{exec}}} %u 5 | Icon={{{icon}}} 6 | Name={{{name}}} 7 | Terminal=false 8 | Type=Application 9 | MimeType=x-scheme-handler/clash; -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/icon-new.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/icon-new.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon-shrink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/icon-shrink.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/mac-tray-icon-sys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/mac-tray-icon-sys.png -------------------------------------------------------------------------------- /src-tauri/icons/mac-tray-icon-tun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/mac-tray-icon-tun.png -------------------------------------------------------------------------------- /src-tauri/icons/mac-tray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/mac-tray-icon.png -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon-sys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/tray-icon-sys.png -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon-tun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/tray-icon-tun.png -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/tray-icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src-tauri/icons/tray-icon.png -------------------------------------------------------------------------------- /src-tauri/rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 100 2 | hard_tabs = false 3 | tab_spaces = 4 4 | newline_style = "Auto" 5 | use_small_heuristics = "Default" 6 | reorder_imports = true 7 | reorder_modules = true 8 | remove_nested_parens = true 9 | edition = "2021" 10 | merge_derives = true 11 | use_try_shorthand = false 12 | use_field_init_shorthand = false 13 | force_explicit_abi = true 14 | imports_granularity = "Crate" 15 | -------------------------------------------------------------------------------- /src-tauri/src/config/config.rs: -------------------------------------------------------------------------------- 1 | use super::{Draft, IClashTemp, IProfiles, IRuntime, IVerge}; 2 | use crate::{ 3 | enhance, 4 | utils::{dirs, help}, 5 | }; 6 | use anyhow::{anyhow, Result}; 7 | use once_cell::sync::OnceCell; 8 | use std::{env::temp_dir, path::PathBuf}; 9 | 10 | pub const RUNTIME_CONFIG: &str = "clash-verge.yaml"; 11 | pub const CHECK_CONFIG: &str = "clash-verge-check.yaml"; 12 | 13 | pub struct Config { 14 | clash_config: Draft, 15 | verge_config: Draft, 16 | profiles_config: Draft, 17 | runtime_config: Draft, 18 | } 19 | 20 | impl Config { 21 | pub fn global() -> &'static Config { 22 | static CONFIG: OnceCell = OnceCell::new(); 23 | 24 | CONFIG.get_or_init(|| Config { 25 | clash_config: Draft::from(IClashTemp::new()), 26 | verge_config: Draft::from(IVerge::new()), 27 | profiles_config: Draft::from(IProfiles::new()), 28 | runtime_config: Draft::from(IRuntime::new()), 29 | }) 30 | } 31 | 32 | pub fn clash() -> Draft { 33 | Self::global().clash_config.clone() 34 | } 35 | 36 | pub fn verge() -> Draft { 37 | Self::global().verge_config.clone() 38 | } 39 | 40 | pub fn profiles() -> Draft { 41 | Self::global().profiles_config.clone() 42 | } 43 | 44 | pub fn runtime() -> Draft { 45 | Self::global().runtime_config.clone() 46 | } 47 | 48 | /// 初始化订阅 49 | pub fn init_config() -> Result<()> { 50 | crate::log_err!(Self::generate()); 51 | if let Err(err) = Self::generate_file(ConfigType::Run) { 52 | log::error!(target: "app", "{err}"); 53 | 54 | let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG); 55 | // 如果不存在就将默认的clash文件拿过来 56 | if !runtime_path.exists() { 57 | help::save_yaml( 58 | &runtime_path, 59 | &Config::clash().latest().0, 60 | Some("# Clash Verge Runtime"), 61 | )?; 62 | } 63 | } 64 | Ok(()) 65 | } 66 | 67 | /// 将订阅丢到对应的文件中 68 | pub fn generate_file(typ: ConfigType) -> Result { 69 | let path = match typ { 70 | ConfigType::Run => dirs::app_home_dir()?.join(RUNTIME_CONFIG), 71 | ConfigType::Check => temp_dir().join(CHECK_CONFIG), 72 | }; 73 | 74 | let runtime = Config::runtime(); 75 | let runtime = runtime.latest(); 76 | let config = runtime 77 | .config 78 | .as_ref() 79 | .ok_or(anyhow!("failed to get runtime config"))?; 80 | 81 | help::save_yaml(&path, &config, Some("# Generated by Clash Verge"))?; 82 | Ok(path) 83 | } 84 | 85 | /// 生成订阅存好 86 | pub fn generate() -> Result<()> { 87 | let (config, exists_keys, logs) = enhance::enhance(); 88 | 89 | *Config::runtime().draft() = IRuntime { 90 | config: Some(config), 91 | exists_keys, 92 | chain_logs: logs, 93 | }; 94 | 95 | Ok(()) 96 | } 97 | } 98 | 99 | #[derive(Debug)] 100 | pub enum ConfigType { 101 | Run, 102 | Check, 103 | } 104 | -------------------------------------------------------------------------------- /src-tauri/src/config/draft.rs: -------------------------------------------------------------------------------- 1 | use super::{IClashTemp, IProfiles, IRuntime, IVerge}; 2 | use parking_lot::{MappedMutexGuard, Mutex, MutexGuard}; 3 | use std::sync::Arc; 4 | 5 | #[derive(Debug, Clone)] 6 | pub struct Draft { 7 | inner: Arc)>>, 8 | } 9 | 10 | macro_rules! draft_define { 11 | ($id: ident) => { 12 | impl Draft<$id> { 13 | #[allow(unused)] 14 | pub fn data(&self) -> MappedMutexGuard<$id> { 15 | MutexGuard::map(self.inner.lock(), |guard| &mut guard.0) 16 | } 17 | 18 | pub fn latest(&self) -> MappedMutexGuard<$id> { 19 | MutexGuard::map(self.inner.lock(), |inner| { 20 | if inner.1.is_none() { 21 | &mut inner.0 22 | } else { 23 | inner.1.as_mut().unwrap() 24 | } 25 | }) 26 | } 27 | 28 | pub fn draft(&self) -> MappedMutexGuard<$id> { 29 | MutexGuard::map(self.inner.lock(), |inner| { 30 | if inner.1.is_none() { 31 | inner.1 = Some(inner.0.clone()); 32 | } 33 | 34 | inner.1.as_mut().unwrap() 35 | }) 36 | } 37 | 38 | pub fn apply(&self) -> Option<$id> { 39 | let mut inner = self.inner.lock(); 40 | 41 | match inner.1.take() { 42 | Some(draft) => { 43 | let old_value = inner.0.to_owned(); 44 | inner.0 = draft.to_owned(); 45 | Some(old_value) 46 | } 47 | None => None, 48 | } 49 | } 50 | 51 | pub fn discard(&self) -> Option<$id> { 52 | let mut inner = self.inner.lock(); 53 | inner.1.take() 54 | } 55 | } 56 | 57 | impl From<$id> for Draft<$id> { 58 | fn from(data: $id) -> Self { 59 | Draft { 60 | inner: Arc::new(Mutex::new((data, None))), 61 | } 62 | } 63 | } 64 | }; 65 | } 66 | 67 | // draft_define!(IClash); 68 | draft_define!(IClashTemp); 69 | draft_define!(IProfiles); 70 | draft_define!(IRuntime); 71 | draft_define!(IVerge); 72 | 73 | #[test] 74 | fn test_draft() { 75 | let verge = IVerge { 76 | enable_auto_launch: Some(true), 77 | enable_tun_mode: Some(false), 78 | ..IVerge::default() 79 | }; 80 | 81 | let draft = Draft::from(verge); 82 | 83 | assert_eq!(draft.data().enable_auto_launch, Some(true)); 84 | assert_eq!(draft.data().enable_tun_mode, Some(false)); 85 | 86 | assert_eq!(draft.draft().enable_auto_launch, Some(true)); 87 | assert_eq!(draft.draft().enable_tun_mode, Some(false)); 88 | 89 | let mut d = draft.draft(); 90 | d.enable_auto_launch = Some(false); 91 | d.enable_tun_mode = Some(true); 92 | drop(d); 93 | 94 | assert_eq!(draft.data().enable_auto_launch, Some(true)); 95 | assert_eq!(draft.data().enable_tun_mode, Some(false)); 96 | 97 | assert_eq!(draft.draft().enable_auto_launch, Some(false)); 98 | assert_eq!(draft.draft().enable_tun_mode, Some(true)); 99 | 100 | assert_eq!(draft.latest().enable_auto_launch, Some(false)); 101 | assert_eq!(draft.latest().enable_tun_mode, Some(true)); 102 | 103 | assert!(draft.apply().is_some()); 104 | assert!(draft.apply().is_none()); 105 | 106 | assert_eq!(draft.data().enable_auto_launch, Some(false)); 107 | assert_eq!(draft.data().enable_tun_mode, Some(true)); 108 | 109 | assert_eq!(draft.draft().enable_auto_launch, Some(false)); 110 | assert_eq!(draft.draft().enable_tun_mode, Some(true)); 111 | 112 | let mut d = draft.draft(); 113 | d.enable_auto_launch = Some(true); 114 | drop(d); 115 | 116 | assert_eq!(draft.data().enable_auto_launch, Some(false)); 117 | 118 | assert_eq!(draft.draft().enable_auto_launch, Some(true)); 119 | 120 | assert!(draft.discard().is_some()); 121 | 122 | assert_eq!(draft.data().enable_auto_launch, Some(false)); 123 | 124 | assert!(draft.discard().is_none()); 125 | 126 | assert_eq!(draft.draft().enable_auto_launch, Some(false)); 127 | } 128 | -------------------------------------------------------------------------------- /src-tauri/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | mod clash; 2 | mod config; 3 | mod draft; 4 | mod prfitem; 5 | mod profiles; 6 | mod runtime; 7 | mod verge; 8 | 9 | pub use self::clash::*; 10 | pub use self::config::*; 11 | pub use self::draft::*; 12 | pub use self::prfitem::*; 13 | pub use self::profiles::*; 14 | pub use self::runtime::*; 15 | pub use self::verge::*; 16 | -------------------------------------------------------------------------------- /src-tauri/src/config/runtime.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use serde_yaml::Mapping; 3 | use std::collections::HashMap; 4 | 5 | #[derive(Default, Debug, Clone, Deserialize, Serialize)] 6 | pub struct IRuntime { 7 | pub config: Option, 8 | // 记录在订阅中(包括merge和script生成的)出现过的keys 9 | // 这些keys不一定都生效 10 | pub exists_keys: Vec, 11 | pub chain_logs: HashMap>, 12 | } 13 | 14 | impl IRuntime { 15 | pub fn new() -> Self { 16 | Self::default() 17 | } 18 | 19 | // 这里只更改 allow-lan | ipv6 | log-level 20 | pub fn patch_config(&mut self, patch: Mapping) { 21 | if let Some(config) = self.config.as_mut() { 22 | ["allow-lan", "ipv6", "log-level"] 23 | .into_iter() 24 | .for_each(|key| { 25 | if let Some(value) = patch.get(key).to_owned() { 26 | config.insert(key.into(), value.clone()); 27 | } 28 | }); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src-tauri/src/core/handle.rs: -------------------------------------------------------------------------------- 1 | use super::tray::Tray; 2 | use crate::log_err; 3 | use anyhow::{bail, Result}; 4 | use once_cell::sync::OnceCell; 5 | use parking_lot::Mutex; 6 | use std::sync::Arc; 7 | use tauri::{AppHandle, Manager, Window}; 8 | 9 | #[derive(Debug, Default, Clone)] 10 | pub struct Handle { 11 | pub app_handle: Arc>>, 12 | } 13 | 14 | impl Handle { 15 | pub fn global() -> &'static Handle { 16 | static HANDLE: OnceCell = OnceCell::new(); 17 | 18 | HANDLE.get_or_init(|| Handle { 19 | app_handle: Arc::new(Mutex::new(None)), 20 | }) 21 | } 22 | 23 | pub fn init(&self, app_handle: AppHandle) { 24 | *self.app_handle.lock() = Some(app_handle); 25 | } 26 | 27 | pub fn get_window(&self) -> Option { 28 | self.app_handle 29 | .lock() 30 | .as_ref() 31 | .and_then(|a| a.get_window("main")) 32 | } 33 | 34 | pub fn refresh_clash() { 35 | if let Some(window) = Self::global().get_window() { 36 | log_err!(window.emit("verge://refresh-clash-config", "yes")); 37 | } 38 | } 39 | 40 | pub fn refresh_verge() { 41 | if let Some(window) = Self::global().get_window() { 42 | log_err!(window.emit("verge://refresh-verge-config", "yes")); 43 | } 44 | } 45 | 46 | #[allow(unused)] 47 | pub fn refresh_profiles() { 48 | if let Some(window) = Self::global().get_window() { 49 | log_err!(window.emit("verge://refresh-profiles-config", "yes")); 50 | } 51 | } 52 | 53 | pub fn notice_message, M: Into>(status: S, msg: M) { 54 | if let Some(window) = Self::global().get_window() { 55 | log_err!(window.emit("verge://notice-message", (status.into(), msg.into()))); 56 | } 57 | } 58 | 59 | pub fn update_systray() -> Result<()> { 60 | let app_handle = Self::global().app_handle.lock(); 61 | if app_handle.is_none() { 62 | bail!("update_systray unhandled error"); 63 | } 64 | Tray::update_systray(app_handle.as_ref().unwrap())?; 65 | Ok(()) 66 | } 67 | 68 | /// update the system tray state 69 | pub fn update_systray_part() -> Result<()> { 70 | let app_handle = Self::global().app_handle.lock(); 71 | if app_handle.is_none() { 72 | bail!("update_systray unhandled error"); 73 | } 74 | Tray::update_part(app_handle.as_ref().unwrap())?; 75 | Ok(()) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src-tauri/src/core/logger.rs: -------------------------------------------------------------------------------- 1 | use once_cell::sync::OnceCell; 2 | use parking_lot::Mutex; 3 | use std::{collections::VecDeque, sync::Arc}; 4 | 5 | const LOGS_QUEUE_LEN: usize = 100; 6 | 7 | pub struct Logger { 8 | log_data: Arc>>, 9 | } 10 | 11 | impl Logger { 12 | pub fn global() -> &'static Logger { 13 | static LOGGER: OnceCell = OnceCell::new(); 14 | 15 | LOGGER.get_or_init(|| Logger { 16 | log_data: Arc::new(Mutex::new(VecDeque::with_capacity(LOGS_QUEUE_LEN + 10))), 17 | }) 18 | } 19 | 20 | pub fn get_log(&self) -> VecDeque { 21 | self.log_data.lock().clone() 22 | } 23 | 24 | pub fn set_log(&self, text: String) { 25 | let mut logs = self.log_data.lock(); 26 | if logs.len() > LOGS_QUEUE_LEN { 27 | logs.pop_front(); 28 | } 29 | logs.push_back(text); 30 | } 31 | 32 | pub fn clear_log(&self) { 33 | let mut logs = self.log_data.lock(); 34 | logs.clear(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src-tauri/src/core/manager.rs: -------------------------------------------------------------------------------- 1 | /// 给clash内核的tun模式授权 2 | #[cfg(any(target_os = "macos", target_os = "linux"))] 3 | pub fn grant_permission(core: String) -> anyhow::Result<()> { 4 | use std::process::Command; 5 | use tauri::utils::platform::current_exe; 6 | 7 | let path = current_exe()?.with_file_name(core).canonicalize()?; 8 | let path = path.display().to_string(); 9 | 10 | log::debug!("grant_permission path: {path}"); 11 | 12 | #[cfg(target_os = "macos")] 13 | let output = { 14 | let path = path.replace(' ', "\\\\ "); 15 | let shell = format!("chown root:admin {path}\nchmod +sx {path}"); 16 | let command = format!(r#"do shell script "{shell}" with administrator privileges"#); 17 | Command::new("osascript") 18 | .args(vec!["-e", &command]) 19 | .output()? 20 | }; 21 | 22 | #[cfg(target_os = "linux")] 23 | let output = { 24 | let path = path.replace(' ', "\\ "); // 避免路径中有空格 25 | let shell = format!("setcap cap_net_bind_service,cap_net_admin=+ep {path}"); 26 | 27 | let sudo = match Command::new("which").arg("pkexec").output() { 28 | Ok(output) => { 29 | if output.stdout.is_empty() { 30 | "sudo" 31 | } else { 32 | "pkexec" 33 | } 34 | } 35 | Err(_) => "sudo", 36 | }; 37 | 38 | Command::new(sudo).arg("sh").arg("-c").arg(shell).output()? 39 | }; 40 | 41 | if output.status.success() { 42 | Ok(()) 43 | } else { 44 | let stderr = std::str::from_utf8(&output.stderr).unwrap_or(""); 45 | anyhow::bail!("{stderr}"); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src-tauri/src/core/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod clash_api; 2 | mod core; 3 | pub mod handle; 4 | pub mod hotkey; 5 | pub mod logger; 6 | pub mod manager; 7 | pub mod sysopt; 8 | pub mod timer; 9 | pub mod tray; 10 | pub mod win_service; 11 | pub mod win_uwp; 12 | 13 | pub use self::core::*; 14 | -------------------------------------------------------------------------------- /src-tauri/src/core/win_uwp.rs: -------------------------------------------------------------------------------- 1 | #![cfg(target_os = "windows")] 2 | 3 | use crate::utils::dirs; 4 | use anyhow::{bail, Result}; 5 | use deelevate::{PrivilegeLevel, Token}; 6 | use runas::Command as RunasCommand; 7 | use std::process::Command as StdCommand; 8 | 9 | pub async fn invoke_uwptools() -> Result<()> { 10 | let resource_dir = dirs::app_resources_dir()?; 11 | let tool_path = resource_dir.join("enableLoopback.exe"); 12 | 13 | if !tool_path.exists() { 14 | bail!("enableLoopback exe not found"); 15 | } 16 | 17 | let token = Token::with_current_process()?; 18 | let level = token.privilege_level()?; 19 | 20 | match level { 21 | PrivilegeLevel::NotPrivileged => RunasCommand::new(tool_path).status()?, 22 | _ => StdCommand::new(tool_path).status()?, 23 | }; 24 | 25 | Ok(()) 26 | } 27 | -------------------------------------------------------------------------------- /src-tauri/src/enhance/builtin/meta_guard.js: -------------------------------------------------------------------------------- 1 | function main(params) { 2 | if (params.mode === "script") { 3 | params.mode = "rule"; 4 | } 5 | return params; 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/src/enhance/builtin/meta_hy_alpn.js: -------------------------------------------------------------------------------- 1 | function main(params) { 2 | if (Array.isArray(params.proxies)) { 3 | params.proxies.forEach((p, i) => { 4 | if (p.type === "hysteria" && typeof p.alpn === "string") { 5 | params.proxies[i].alpn = [p.alpn]; 6 | } 7 | }); 8 | } 9 | return params; 10 | } 11 | -------------------------------------------------------------------------------- /src-tauri/src/enhance/chain.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config::PrfItem, 3 | utils::{dirs, help}, 4 | }; 5 | use serde_yaml::Mapping; 6 | use std::fs; 7 | 8 | #[derive(Debug, Clone)] 9 | pub struct ChainItem { 10 | pub uid: String, 11 | pub data: ChainType, 12 | } 13 | 14 | #[derive(Debug, Clone)] 15 | pub enum ChainType { 16 | Merge(Mapping), 17 | Script(String), 18 | } 19 | 20 | #[derive(Debug, Clone)] 21 | pub enum ChainSupport { 22 | Clash, 23 | ClashMeta, 24 | ClashMetaAlpha, 25 | All, 26 | } 27 | 28 | impl From<&PrfItem> for Option { 29 | fn from(item: &PrfItem) -> Self { 30 | let itype = item.itype.as_ref()?.as_str(); 31 | let file = item.file.clone()?; 32 | let uid = item.uid.clone().unwrap_or("".into()); 33 | let path = dirs::app_profiles_dir().ok()?.join(file); 34 | 35 | if !path.exists() { 36 | return None; 37 | } 38 | 39 | match itype { 40 | "script" => Some(ChainItem { 41 | uid, 42 | data: ChainType::Script(fs::read_to_string(path).ok()?), 43 | }), 44 | "merge" => Some(ChainItem { 45 | uid, 46 | data: ChainType::Merge(help::read_merge_mapping(&path).ok()?), 47 | }), 48 | _ => None, 49 | } 50 | } 51 | } 52 | 53 | impl ChainItem { 54 | /// 内建支持一些脚本 55 | pub fn builtin() -> Vec<(ChainSupport, ChainItem)> { 56 | // meta 的一些处理 57 | let meta_guard = 58 | ChainItem::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js")); 59 | 60 | // meta 1.13.2 alpn string 转 数组 61 | let hy_alpn = 62 | ChainItem::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js")); 63 | 64 | // meta 的一些处理 65 | let meta_guard_alpha = 66 | ChainItem::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js")); 67 | 68 | // meta 1.13.2 alpn string 转 数组 69 | let hy_alpn_alpha = 70 | ChainItem::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js")); 71 | 72 | vec![ 73 | (ChainSupport::ClashMeta, hy_alpn), 74 | (ChainSupport::ClashMeta, meta_guard), 75 | (ChainSupport::ClashMetaAlpha, hy_alpn_alpha), 76 | (ChainSupport::ClashMetaAlpha, meta_guard_alpha), 77 | ] 78 | } 79 | 80 | pub fn to_script, D: Into>(uid: U, data: D) -> Self { 81 | Self { 82 | uid: uid.into(), 83 | data: ChainType::Script(data.into()), 84 | } 85 | } 86 | } 87 | 88 | impl ChainSupport { 89 | pub fn is_support(&self, core: Option<&String>) -> bool { 90 | match core { 91 | Some(core) => match (self, core.as_str()) { 92 | (ChainSupport::All, _) => true, 93 | (ChainSupport::Clash, "clash") => true, 94 | (ChainSupport::ClashMeta, "clash-meta") => true, 95 | (ChainSupport::ClashMetaAlpha, "clash-meta-alpha") => true, 96 | _ => false, 97 | }, 98 | None => true, 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src-tauri/src/enhance/field.rs: -------------------------------------------------------------------------------- 1 | use serde_yaml::{Mapping, Value}; 2 | use std::collections::HashSet; 3 | 4 | pub const HANDLE_FIELDS: [&str; 9] = [ 5 | "mode", 6 | "port", 7 | "socks-port", 8 | "mixed-port", 9 | "allow-lan", 10 | "log-level", 11 | "ipv6", 12 | "secret", 13 | "external-controller", 14 | ]; 15 | 16 | pub const DEFAULT_FIELDS: [&str; 5] = [ 17 | "proxies", 18 | "proxy-groups", 19 | "proxy-providers", 20 | "rules", 21 | "rule-providers", 22 | ]; 23 | 24 | pub const OTHERS_FIELDS: [&str; 31] = [ 25 | "dns", 26 | "tun", 27 | "ebpf", 28 | "hosts", 29 | "script", 30 | "profile", 31 | "payload", 32 | "tunnels", 33 | "auto-redir", 34 | "experimental", 35 | "interface-name", 36 | "routing-mark", 37 | "redir-port", 38 | "tproxy-port", 39 | "iptables", 40 | "external-ui", 41 | "bind-address", 42 | "authentication", 43 | "tls", // meta 44 | "sniffer", // meta 45 | "geox-url", // meta 46 | "listeners", // meta 47 | "sub-rules", // meta 48 | "geodata-mode", // meta 49 | "unified-delay", // meta 50 | "tcp-concurrent", // meta 51 | "enable-process", // meta 52 | "find-process-mode", // meta 53 | "skip-auth-prefixes", // meta 54 | "external-controller-tls", // meta 55 | "global-client-fingerprint", // meta 56 | ]; 57 | 58 | pub fn use_clash_fields() -> Vec { 59 | DEFAULT_FIELDS 60 | .into_iter() 61 | .chain(HANDLE_FIELDS) 62 | .chain(OTHERS_FIELDS) 63 | .map(|s| s.to_string()) 64 | .collect() 65 | } 66 | 67 | pub fn use_valid_fields(mut valid: Vec) -> Vec { 68 | let others = Vec::from(OTHERS_FIELDS); 69 | 70 | valid.iter_mut().for_each(|s| s.make_ascii_lowercase()); 71 | valid 72 | .into_iter() 73 | .filter(|s| others.contains(&s.as_str())) 74 | .chain(DEFAULT_FIELDS.iter().map(|s| s.to_string())) 75 | .collect() 76 | } 77 | 78 | pub fn use_filter(config: Mapping, filter: &Vec, enable: bool) -> Mapping { 79 | if !enable { 80 | return config; 81 | } 82 | 83 | let mut ret = Mapping::new(); 84 | 85 | for (key, value) in config.into_iter() { 86 | if let Some(key) = key.as_str() { 87 | if filter.contains(&key.to_string()) { 88 | ret.insert(Value::from(key), value); 89 | } 90 | } 91 | } 92 | ret 93 | } 94 | 95 | pub fn use_lowercase(config: Mapping) -> Mapping { 96 | let mut ret = Mapping::new(); 97 | 98 | for (key, value) in config.into_iter() { 99 | if let Some(key_str) = key.as_str() { 100 | let mut key_str = String::from(key_str); 101 | key_str.make_ascii_lowercase(); 102 | ret.insert(Value::from(key_str), value); 103 | } 104 | } 105 | ret 106 | } 107 | 108 | pub fn use_sort(config: Mapping, enable_filter: bool) -> Mapping { 109 | let mut ret = Mapping::new(); 110 | 111 | HANDLE_FIELDS 112 | .into_iter() 113 | .chain(OTHERS_FIELDS) 114 | .chain(DEFAULT_FIELDS) 115 | .for_each(|key| { 116 | let key = Value::from(key); 117 | if let Some(value) = config.get(&key) { 118 | ret.insert(key, value.clone()); 119 | } 120 | }); 121 | 122 | if !enable_filter { 123 | let supported_keys: HashSet<&str> = HANDLE_FIELDS 124 | .into_iter() 125 | .chain(OTHERS_FIELDS) 126 | .chain(DEFAULT_FIELDS) 127 | .collect(); 128 | 129 | let config_keys: HashSet<&str> = config 130 | .keys() 131 | .filter_map(|e| e.as_str()) 132 | .into_iter() 133 | .collect(); 134 | 135 | config_keys.difference(&supported_keys).for_each(|&key| { 136 | let key = Value::from(key); 137 | if let Some(value) = config.get(&key) { 138 | ret.insert(key, value.clone()); 139 | } 140 | }); 141 | } 142 | 143 | ret 144 | } 145 | 146 | pub fn use_keys(config: &Mapping) -> Vec { 147 | config 148 | .iter() 149 | .filter_map(|(key, _)| key.as_str()) 150 | .map(|s| { 151 | let mut s = s.to_string(); 152 | s.make_ascii_lowercase(); 153 | s 154 | }) 155 | .collect() 156 | } 157 | -------------------------------------------------------------------------------- /src-tauri/src/enhance/merge.rs: -------------------------------------------------------------------------------- 1 | use super::{use_filter, use_lowercase}; 2 | use serde_yaml::{self, Mapping, Sequence, Value}; 3 | 4 | const MERGE_FIELDS: [&str; 6] = [ 5 | "prepend-rules", 6 | "append-rules", 7 | "prepend-proxies", 8 | "append-proxies", 9 | "prepend-proxy-groups", 10 | "append-proxy-groups", 11 | ]; 12 | 13 | pub fn use_merge(merge: Mapping, mut config: Mapping) -> Mapping { 14 | // 直接覆盖原字段 15 | use_lowercase(merge.clone()) 16 | .into_iter() 17 | .for_each(|(key, value)| { 18 | config.insert(key, value); 19 | }); 20 | 21 | let merge_list = MERGE_FIELDS.iter().map(|s| s.to_string()); 22 | let merge = use_filter(merge, &merge_list.collect(), true); 23 | 24 | ["rules", "proxies", "proxy-groups"] 25 | .iter() 26 | .for_each(|key_str| { 27 | let key_val = Value::from(key_str.to_string()); 28 | 29 | let mut list = Sequence::default(); 30 | list = config.get(&key_val).map_or(list.clone(), |val| { 31 | val.as_sequence().map_or(list, |v| v.clone()) 32 | }); 33 | 34 | let pre_key = Value::from(format!("prepend-{key_str}")); 35 | let post_key = Value::from(format!("append-{key_str}")); 36 | 37 | if let Some(pre_val) = merge.get(&pre_key) { 38 | if pre_val.is_sequence() { 39 | let mut pre_val = pre_val.as_sequence().unwrap().clone(); 40 | pre_val.extend(list); 41 | list = pre_val; 42 | } 43 | } 44 | 45 | if let Some(post_val) = merge.get(&post_key) { 46 | if post_val.is_sequence() { 47 | list.extend(post_val.as_sequence().unwrap().clone()); 48 | } 49 | } 50 | 51 | config.insert(key_val, Value::from(list)); 52 | }); 53 | config 54 | } 55 | 56 | #[test] 57 | fn test_merge() -> anyhow::Result<()> { 58 | let merge = r" 59 | prepend-rules: 60 | - prepend 61 | - 1123123 62 | append-rules: 63 | - append 64 | prepend-proxies: 65 | - 9999 66 | append-proxies: 67 | - 1111 68 | rules: 69 | - replace 70 | proxy-groups: 71 | - 123781923810 72 | tun: 73 | enable: true 74 | dns: 75 | enable: true 76 | "; 77 | 78 | let config = r" 79 | rules: 80 | - aaaaa 81 | script1: test 82 | "; 83 | 84 | let merge = serde_yaml::from_str::(merge)?; 85 | let config = serde_yaml::from_str::(config)?; 86 | 87 | let result = serde_yaml::to_string(&use_merge(merge, config))?; 88 | 89 | println!("{result}"); 90 | 91 | Ok(()) 92 | } 93 | -------------------------------------------------------------------------------- /src-tauri/src/enhance/mod.rs: -------------------------------------------------------------------------------- 1 | mod chain; 2 | mod field; 3 | mod merge; 4 | mod script; 5 | mod tun; 6 | 7 | use self::chain::*; 8 | use self::field::*; 9 | use self::merge::*; 10 | use self::script::*; 11 | use self::tun::*; 12 | use crate::config::Config; 13 | use serde_yaml::Mapping; 14 | use std::collections::HashMap; 15 | use std::collections::HashSet; 16 | 17 | type ResultLog = Vec<(String, String)>; 18 | 19 | /// Enhance mode 20 | /// 返回最终订阅、该订阅包含的键、和script执行的结果 21 | pub fn enhance() -> (Mapping, Vec, HashMap) { 22 | // config.yaml 的订阅 23 | let clash_config = { Config::clash().latest().0.clone() }; 24 | 25 | let (clash_core, enable_tun, enable_builtin, enable_filter) = { 26 | let verge = Config::verge(); 27 | let verge = verge.latest(); 28 | ( 29 | verge.clash_core.clone(), 30 | verge.enable_tun_mode.unwrap_or(false), 31 | verge.enable_builtin_enhanced.unwrap_or(true), 32 | verge.enable_clash_fields.unwrap_or(true), 33 | ) 34 | }; 35 | 36 | // 从profiles里拿东西 37 | let (mut config, chain, valid) = { 38 | let profiles = Config::profiles(); 39 | let profiles = profiles.latest(); 40 | 41 | let current = profiles.current_mapping().unwrap_or_default(); 42 | 43 | let chain = match profiles.chain.as_ref() { 44 | Some(chain) => chain 45 | .iter() 46 | .filter_map(|uid| profiles.get_item(uid).ok()) 47 | .filter_map(>::from) 48 | .collect::>(), 49 | None => vec![], 50 | }; 51 | 52 | let valid = profiles.valid.clone().unwrap_or_default(); 53 | 54 | (current, chain, valid) 55 | }; 56 | 57 | let mut result_map = HashMap::new(); // 保存脚本日志 58 | let mut exists_keys = use_keys(&config); // 保存出现过的keys 59 | 60 | let valid = use_valid_fields(valid); 61 | config = use_filter(config, &valid, enable_filter); 62 | 63 | // 处理用户的profile 64 | chain.into_iter().for_each(|item| match item.data { 65 | ChainType::Merge(merge) => { 66 | exists_keys.extend(use_keys(&merge)); 67 | config = use_merge(merge, config.to_owned()); 68 | config = use_filter(config.to_owned(), &valid, enable_filter); 69 | } 70 | ChainType::Script(script) => { 71 | let mut logs = vec![]; 72 | 73 | match use_script(script, config.to_owned()) { 74 | Ok((res_config, res_logs)) => { 75 | exists_keys.extend(use_keys(&res_config)); 76 | config = use_filter(res_config, &valid, enable_filter); 77 | logs.extend(res_logs); 78 | } 79 | Err(err) => logs.push(("exception".into(), err.to_string())), 80 | } 81 | 82 | result_map.insert(item.uid, logs); 83 | } 84 | }); 85 | 86 | // 合并默认的config 87 | for (key, value) in clash_config.into_iter() { 88 | config.insert(key, value); 89 | } 90 | 91 | let clash_fields = use_clash_fields(); 92 | 93 | // 内建脚本最后跑 94 | if enable_builtin { 95 | ChainItem::builtin() 96 | .into_iter() 97 | .filter(|(s, _)| s.is_support(clash_core.as_ref())) 98 | .map(|(_, c)| c) 99 | .for_each(|item| { 100 | log::debug!(target: "app", "run builtin script {}", item.uid); 101 | 102 | match item.data { 103 | ChainType::Script(script) => match use_script(script, config.to_owned()) { 104 | Ok((res_config, _)) => { 105 | config = use_filter(res_config, &clash_fields, enable_filter); 106 | } 107 | Err(err) => { 108 | log::error!(target: "app", "builtin script error `{err}`"); 109 | } 110 | }, 111 | _ => {} 112 | } 113 | }); 114 | } 115 | 116 | config = use_filter(config, &clash_fields, enable_filter); 117 | config = use_tun(config, enable_tun); 118 | config = use_sort(config, enable_filter); 119 | 120 | let mut exists_set = HashSet::new(); 121 | exists_set.extend(exists_keys.into_iter().filter(|s| clash_fields.contains(s))); 122 | exists_keys = exists_set.into_iter().collect(); 123 | 124 | (config, exists_keys, result_map) 125 | } 126 | -------------------------------------------------------------------------------- /src-tauri/src/enhance/script.rs: -------------------------------------------------------------------------------- 1 | use super::use_lowercase; 2 | use anyhow::Result; 3 | use serde_yaml::Mapping; 4 | 5 | pub fn use_script(script: String, config: Mapping) -> Result<(Mapping, Vec<(String, String)>)> { 6 | use rquickjs::{function::Func, Context, Runtime}; 7 | use std::sync::{Arc, Mutex}; 8 | 9 | let runtime = Runtime::new().unwrap(); 10 | let context = Context::full(&runtime).unwrap(); 11 | let outputs = Arc::new(Mutex::new(vec![])); 12 | 13 | let copy_outputs = outputs.clone(); 14 | let result = context.with(|ctx| -> Result { 15 | ctx.globals().set( 16 | "__verge_log__", 17 | Func::from(move |level: String, data: String| { 18 | let mut out = copy_outputs.lock().unwrap(); 19 | out.push((level, data)); 20 | }), 21 | )?; 22 | 23 | ctx.eval( 24 | r#"var console = Object.freeze({ 25 | log(data){__verge_log__("log",JSON.stringify(data))}, 26 | info(data){__verge_log__("info",JSON.stringify(data))}, 27 | error(data){__verge_log__("error",JSON.stringify(data))}, 28 | debug(data){__verge_log__("debug",JSON.stringify(data))}, 29 | });"#, 30 | )?; 31 | 32 | let config = use_lowercase(config.clone()); 33 | let config_str = serde_json::to_string(&config)?; 34 | 35 | let code = format!( 36 | r#"try{{ 37 | {script}; 38 | JSON.stringify(main({config_str})||'') 39 | }} catch(err) {{ 40 | `__error_flag__ ${{err.toString()}}` 41 | }}"# 42 | ); 43 | let result: String = ctx.eval(code.as_str())?; 44 | if result.starts_with("__error_flag__") { 45 | anyhow::bail!(result[15..].to_owned()); 46 | } 47 | if result == "\"\"" { 48 | anyhow::bail!("main function should return object"); 49 | } 50 | Ok(serde_json::from_str::(result.as_str())?) 51 | }); 52 | 53 | let mut out = outputs.lock().unwrap(); 54 | match result { 55 | Ok(config) => Ok((use_lowercase(config), out.to_vec())), 56 | Err(err) => { 57 | out.push(("exception".into(), err.to_string())); 58 | Ok((config, out.to_vec())) 59 | } 60 | } 61 | } 62 | 63 | #[test] 64 | fn test_script() { 65 | let script = r#" 66 | function main(config) { 67 | if (Array.isArray(config.rules)) { 68 | config.rules = [...config.rules, "add"]; 69 | } 70 | console.log(config); 71 | config.proxies = ["111"]; 72 | return config; 73 | } 74 | "#; 75 | 76 | let config = r#" 77 | rules: 78 | - 111 79 | - 222 80 | tun: 81 | enable: false 82 | dns: 83 | enable: false 84 | "#; 85 | 86 | let config = serde_yaml::from_str(config).unwrap(); 87 | let (config, results) = use_script(script.into(), config).unwrap(); 88 | 89 | let config_str = serde_yaml::to_string(&config).unwrap(); 90 | 91 | println!("{config_str}"); 92 | 93 | dbg!(results); 94 | } 95 | -------------------------------------------------------------------------------- /src-tauri/src/enhance/tun.rs: -------------------------------------------------------------------------------- 1 | use serde_yaml::{Mapping, Value}; 2 | 3 | macro_rules! revise { 4 | ($map: expr, $key: expr, $val: expr) => { 5 | let ret_key = Value::String($key.into()); 6 | $map.insert(ret_key, Value::from($val)); 7 | }; 8 | } 9 | 10 | // if key not exists then append value 11 | macro_rules! append { 12 | ($map: expr, $key: expr, $val: expr) => { 13 | let ret_key = Value::String($key.into()); 14 | if !$map.contains_key(&ret_key) { 15 | $map.insert(ret_key, Value::from($val)); 16 | } 17 | }; 18 | } 19 | 20 | pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping { 21 | let tun_key = Value::from("tun"); 22 | let tun_val = config.get(&tun_key); 23 | 24 | if !enable && tun_val.is_none() { 25 | return config; 26 | } 27 | 28 | let mut tun_val = tun_val.map_or(Mapping::new(), |val| { 29 | val.as_mapping().cloned().unwrap_or(Mapping::new()) 30 | }); 31 | 32 | revise!(tun_val, "enable", enable); 33 | if enable { 34 | append!(tun_val, "stack", "gvisor"); 35 | append!(tun_val, "dns-hijack", vec!["any:53"]); 36 | append!(tun_val, "auto-route", true); 37 | append!(tun_val, "auto-detect-interface", true); 38 | } 39 | 40 | revise!(config, "tun", tun_val); 41 | 42 | if enable { 43 | use_dns_for_tun(config) 44 | } else { 45 | config 46 | } 47 | } 48 | 49 | fn use_dns_for_tun(mut config: Mapping) -> Mapping { 50 | let dns_key = Value::from("dns"); 51 | let dns_val = config.get(&dns_key); 52 | 53 | let mut dns_val = dns_val.map_or(Mapping::new(), |val| { 54 | val.as_mapping().cloned().unwrap_or(Mapping::new()) 55 | }); 56 | 57 | // 开启tun将同时开启dns 58 | revise!(dns_val, "enable", true); 59 | 60 | append!(dns_val, "enhanced-mode", "fake-ip"); 61 | append!(dns_val, "fake-ip-range", "198.18.0.1/16"); 62 | append!( 63 | dns_val, 64 | "nameserver", 65 | vec!["114.114.114.114", "223.5.5.5", "8.8.8.8"] 66 | ); 67 | append!(dns_val, "fallback", vec![] as Vec<&str>); 68 | 69 | #[cfg(target_os = "windows")] 70 | append!( 71 | dns_val, 72 | "fake-ip-filter", 73 | vec![ 74 | "dns.msftncsi.com", 75 | "www.msftncsi.com", 76 | "www.msftconnecttest.com" 77 | ] 78 | ); 79 | revise!(config, "dns", dns_val); 80 | config 81 | } 82 | -------------------------------------------------------------------------------- /src-tauri/src/utils/dirs.rs: -------------------------------------------------------------------------------- 1 | use crate::core::handle; 2 | use anyhow::Result; 3 | use once_cell::sync::OnceCell; 4 | use std::path::PathBuf; 5 | use tauri::{ 6 | api::path::{data_dir, resource_dir}, 7 | Env, 8 | }; 9 | 10 | #[cfg(not(feature = "verge-dev"))] 11 | pub static APP_ID: &str = "io.github.clash-verge-rev.clash-verge-rev"; 12 | #[cfg(feature = "verge-dev")] 13 | pub static APP_ID: &str = "io.github.clash-verge-rev.clash-verge-rev.dev"; 14 | 15 | pub static PORTABLE_FLAG: OnceCell = OnceCell::new(); 16 | 17 | static CLASH_CONFIG: &str = "config.yaml"; 18 | static VERGE_CONFIG: &str = "verge.yaml"; 19 | static PROFILE_YAML: &str = "profiles.yaml"; 20 | 21 | /// init portable flag 22 | pub fn init_portable_flag() -> Result<()> { 23 | use tauri::utils::platform::current_exe; 24 | 25 | let app_exe = current_exe()?; 26 | if let Some(dir) = app_exe.parent() { 27 | let dir = PathBuf::from(dir).join(".config/PORTABLE"); 28 | 29 | if dir.exists() { 30 | PORTABLE_FLAG.get_or_init(|| true); 31 | } 32 | } 33 | PORTABLE_FLAG.get_or_init(|| false); 34 | Ok(()) 35 | } 36 | 37 | /// get the verge app home dir 38 | pub fn app_home_dir() -> Result { 39 | use tauri::utils::platform::current_exe; 40 | 41 | let flag = PORTABLE_FLAG.get().unwrap_or(&false); 42 | if *flag { 43 | let app_exe = current_exe()?; 44 | let app_exe = dunce::canonicalize(app_exe)?; 45 | let app_dir = app_exe 46 | .parent() 47 | .ok_or(anyhow::anyhow!("failed to get the portable app dir"))?; 48 | return Ok(PathBuf::from(app_dir).join(".config").join(APP_ID)); 49 | } 50 | 51 | Ok(data_dir() 52 | .ok_or(anyhow::anyhow!("failed to get app home dir"))? 53 | .join(APP_ID)) 54 | } 55 | 56 | /// get the resources dir 57 | pub fn app_resources_dir() -> Result { 58 | let handle = handle::Handle::global(); 59 | let app_handle = handle.app_handle.lock(); 60 | if let Some(app_handle) = app_handle.as_ref() { 61 | let res_dir = resource_dir(app_handle.package_info(), &Env::default()) 62 | .ok_or(anyhow::anyhow!("failed to get the resource dir"))? 63 | .join("resources"); 64 | return Ok(res_dir); 65 | }; 66 | Err(anyhow::anyhow!("failed to get the resource dir")) 67 | } 68 | 69 | /// profiles dir 70 | pub fn app_profiles_dir() -> Result { 71 | Ok(app_home_dir()?.join("profiles")) 72 | } 73 | 74 | /// logs dir 75 | pub fn app_logs_dir() -> Result { 76 | Ok(app_home_dir()?.join("logs")) 77 | } 78 | 79 | pub fn clash_path() -> Result { 80 | Ok(app_home_dir()?.join(CLASH_CONFIG)) 81 | } 82 | 83 | pub fn verge_path() -> Result { 84 | Ok(app_home_dir()?.join(VERGE_CONFIG)) 85 | } 86 | 87 | pub fn profiles_path() -> Result { 88 | Ok(app_home_dir()?.join(PROFILE_YAML)) 89 | } 90 | 91 | pub fn clash_pid_path() -> Result { 92 | Ok(app_home_dir()?.join("clash.pid")) 93 | } 94 | 95 | #[cfg(windows)] 96 | pub fn service_dir() -> Result { 97 | Ok(app_home_dir()?.join("service")) 98 | } 99 | 100 | #[cfg(windows)] 101 | pub fn service_path() -> Result { 102 | Ok(service_dir()?.join("clash-verge-service.exe")) 103 | } 104 | 105 | #[cfg(windows)] 106 | pub fn service_log_file() -> Result { 107 | use chrono::Local; 108 | 109 | let log_dir = app_logs_dir()?.join("service"); 110 | 111 | let local_time = Local::now().format("%Y-%m-%d-%H%M").to_string(); 112 | let log_file = format!("{}.log", local_time); 113 | let log_file = log_dir.join(log_file); 114 | 115 | let _ = std::fs::create_dir_all(&log_dir); 116 | 117 | Ok(log_file) 118 | } 119 | 120 | pub fn path_to_str(path: &PathBuf) -> Result<&str> { 121 | let path_str = path 122 | .as_os_str() 123 | .to_str() 124 | .ok_or(anyhow::anyhow!("failed to get path from {:?}", path))?; 125 | Ok(path_str) 126 | } 127 | -------------------------------------------------------------------------------- /src-tauri/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod dirs; 2 | pub mod help; 3 | pub mod init; 4 | pub mod resolve; 5 | pub mod server; 6 | pub mod tmpl; 7 | -------------------------------------------------------------------------------- /src-tauri/src/utils/server.rs: -------------------------------------------------------------------------------- 1 | extern crate warp; 2 | 3 | use super::resolve; 4 | use crate::config::IVerge; 5 | use anyhow::{bail, Result}; 6 | use port_scanner::local_port_available; 7 | use std::convert::Infallible; 8 | use tauri::AppHandle; 9 | use warp::Filter; 10 | 11 | #[derive(serde::Deserialize, Debug)] 12 | struct QueryParam { 13 | param: String, 14 | } 15 | 16 | /// check whether there is already exists 17 | pub fn check_singleton() -> Result<()> { 18 | let port = IVerge::get_singleton_port(); 19 | 20 | if !local_port_available(port) { 21 | tauri::async_runtime::block_on(async { 22 | let resp = reqwest::get(format!("http://127.0.0.1:{port}/commands/ping")) 23 | .await? 24 | .text() 25 | .await?; 26 | 27 | if &resp == "ok" { 28 | let argvs: Vec = std::env::args().collect(); 29 | if argvs.len() > 1 { 30 | let param = argvs[1].as_str(); 31 | reqwest::get(format!( 32 | "http://127.0.0.1:{port}/commands/scheme?param={param}" 33 | )) 34 | .await? 35 | .text() 36 | .await?; 37 | } else { 38 | reqwest::get(format!("http://127.0.0.1:{port}/commands/visible")) 39 | .await? 40 | .text() 41 | .await?; 42 | } 43 | bail!("app exists"); 44 | } 45 | 46 | log::error!("failed to setup singleton listen server"); 47 | Ok(()) 48 | }) 49 | } else { 50 | Ok(()) 51 | } 52 | } 53 | 54 | /// The embed server only be used to implement singleton process 55 | /// maybe it can be used as pac server later 56 | pub fn embed_server(app_handle: AppHandle) { 57 | let port = IVerge::get_singleton_port(); 58 | 59 | tauri::async_runtime::spawn(async move { 60 | let ping = warp::path!("commands" / "ping").map(move || "ok"); 61 | 62 | let visible = warp::path!("commands" / "visible").map(move || { 63 | resolve::create_window(&app_handle); 64 | "ok" 65 | }); 66 | 67 | let scheme = warp::path!("commands" / "scheme") 68 | .and(warp::query::()) 69 | .and_then(scheme_handler); 70 | 71 | async fn scheme_handler(query: QueryParam) -> Result { 72 | resolve::resolve_scheme(query.param).await; 73 | Ok("ok") 74 | } 75 | let commands = ping.or(visible).or(scheme); 76 | warp::serve(commands).run(([127, 0, 0, 1], port)).await; 77 | }); 78 | } 79 | -------------------------------------------------------------------------------- /src-tauri/src/utils/tmpl.rs: -------------------------------------------------------------------------------- 1 | //! Some config file template 2 | 3 | /// template for new a profile item 4 | pub const ITEM_LOCAL: &str = "# Profile Template for clash verge 5 | 6 | proxies: 7 | 8 | proxy-groups: 9 | 10 | rules: 11 | "; 12 | 13 | /// enhanced profile 14 | pub const ITEM_MERGE: &str = "# Merge Template for clash verge 15 | # The `Merge` format used to enhance profile 16 | 17 | prepend-rules: 18 | 19 | prepend-proxies: 20 | 21 | prepend-proxy-groups: 22 | 23 | append-rules: 24 | 25 | append-proxies: 26 | 27 | append-proxy-groups: 28 | "; 29 | 30 | /// enhanced profile 31 | pub const ITEM_SCRIPT: &str = "// Define the `main` function 32 | 33 | function main(params) { 34 | return params; 35 | } 36 | "; 37 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "package": { 3 | "productName": "Clash Verge", 4 | "version": "1.4.8" 5 | }, 6 | "build": { 7 | "distDir": "../dist", 8 | "devPath": "http://localhost:3000/", 9 | "beforeDevCommand": "pnpm run web:dev", 10 | "beforeBuildCommand": "pnpm run web:build" 11 | }, 12 | "tauri": { 13 | "bundle": { 14 | "active": true, 15 | "identifier": "io.github.clash-verge-rev.clash-verge-rev", 16 | "icon": [ 17 | "icons/32x32.png", 18 | "icons/128x128.png", 19 | "icons/128x128@2x.png", 20 | "icons/icon-new.icns", 21 | "icons/icon.ico" 22 | ], 23 | "resources": ["resources"], 24 | "externalBin": ["sidecar/clash-meta", "sidecar/clash-meta-alpha"], 25 | "copyright": "© 2022 zzzgydi All Rights Reserved", 26 | "category": "DeveloperTool", 27 | "shortDescription": "A Clash Meta GUI based on tauri.", 28 | "longDescription": "A Clash Meta GUI based on tauri." 29 | }, 30 | "updater": { 31 | "active": true, 32 | "endpoints": [ 33 | "https://mirror.ghproxy.com/https://github.com/MetaCubeX/clash-verge/releases/download/updater/update-proxy.json", 34 | "https://github.com/MetaCubeX/clash-verge/releases/download/updater/update.json" 35 | ], 36 | "dialog": false, 37 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDY2ODY2RjA5OTZBOEUzNDgKUldSSTQ2aVdDVytHWm43T1FPcXNCbTgySnN4a2liZWpWSFpNcEgwSlZkUU5KQTNjbStRTzhBTm4K" 38 | }, 39 | "allowlist": { 40 | "shell": { 41 | "all": true 42 | }, 43 | "window": { 44 | "all": true 45 | }, 46 | "process": { 47 | "all": true 48 | }, 49 | "globalShortcut": { 50 | "all": true 51 | }, 52 | "clipboard": { 53 | "all": true 54 | }, 55 | "notification": { 56 | "all": true 57 | } 58 | }, 59 | "windows": [], 60 | "security": { 61 | "csp": "script-src 'unsafe-eval' 'self'; default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self'; img-src data: 'self';" 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src-tauri/tauri.linux.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "tauri": { 3 | "systemTray": { 4 | "iconPath": "icons/tray-icon.png" 5 | }, 6 | "bundle": { 7 | "targets": ["deb", "appimage", "updater"], 8 | "deb": { 9 | "depends": ["openssl"], 10 | "desktopTemplate": "./clash-verge.desktop" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src-tauri/tauri.macos.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "tauri": { 3 | "systemTray": { 4 | "iconPath": "icons/mac-tray-icon.png", 5 | "iconAsTemplate": true 6 | }, 7 | "bundle": { 8 | "targets": ["app", "dmg", "updater"], 9 | "macOS": { 10 | "frameworks": [], 11 | "minimumSystemVersion": "", 12 | "exceptionDomain": "", 13 | "signingIdentity": null, 14 | "entitlements": null 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src-tauri/tauri.windows.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "tauri": { 3 | "systemTray": { 4 | "iconPath": "icons/tray-icon.png" 5 | }, 6 | "bundle": { 7 | "targets": ["nsis", "updater"], 8 | "windows": { 9 | "certificateThumbprint": null, 10 | "digestAlgorithm": "sha256", 11 | "timestampUrl": "", 12 | "webviewInstallMode": { 13 | "type": "embedBootstrapper", 14 | "silent": true 15 | }, 16 | "nsis": { 17 | "displayLanguageSelector": true, 18 | "installerIcon": "icons/icon.ico", 19 | "languages": ["SimpChinese", "English"], 20 | "license": "../LICENSE" 21 | } 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/assets/fonts/Twemoji.Mozilla.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src/assets/fonts/Twemoji.Mozilla.ttf -------------------------------------------------------------------------------- /src/assets/image/logo-box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src/assets/image/logo-box.png -------------------------------------------------------------------------------- /src/assets/image/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src/assets/image/logo.ico -------------------------------------------------------------------------------- /src/assets/image/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MetaCubeX/clash-verge/88b9d85e9c0d9e255d035d9bd35db23b481194be/src/assets/image/logo.png -------------------------------------------------------------------------------- /src/assets/styles/font.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "twemoji mozilla"; 3 | src: url("../fonts/Twemoji.Mozilla.ttf"); 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/styles/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | 8 | user-select: none; 9 | -webkit-user-select: none; 10 | -moz-user-select: none; 11 | -ms-user-select: none; 12 | } 13 | 14 | :root { 15 | --primary-main: #5b5c9d; 16 | --text-primary: #637381; 17 | --selection-color: #f5f5f5; 18 | --scroller-color: #90939980; 19 | --background-color: #ffffff; 20 | --background-color-alpha: rgba(24, 103, 192, 0.1); 21 | --border-radius: 8px; 22 | } 23 | 24 | ::selection { 25 | color: var(--selection-color); 26 | background-color: var(--primary-main); 27 | } 28 | 29 | *::-webkit-scrollbar { 30 | width: 6px; 31 | height: 6px; 32 | background: transparent; 33 | } 34 | *::-webkit-scrollbar-thumb { 35 | border-radius: 6px; 36 | background-color: var(--scroller-color); 37 | } 38 | 39 | body { 40 | overflow: hidden; 41 | } 42 | 43 | @import "./layout.scss"; 44 | @import "./page.scss"; 45 | @import "./font.scss"; 46 | 47 | @media (prefers-color-scheme: dark) { 48 | :root { 49 | background-color: rgba(18, 18, 18, 1); 50 | } 51 | } 52 | 53 | .user-none { 54 | user-select: none; 55 | -webkit-user-select: none; 56 | -moz-user-select: none; 57 | -ms-user-select: none; 58 | } 59 | -------------------------------------------------------------------------------- /src/assets/styles/layout.scss: -------------------------------------------------------------------------------- 1 | .layout { 2 | width: 100%; 3 | height: 100vh; 4 | display: flex; 5 | overflow: hidden; 6 | 7 | &__left { 8 | flex: 1 0 15%; 9 | display: flex; 10 | height: 100%; 11 | max-width: 225px; 12 | min-width: 125px; 13 | padding: 16px 0 8px; 14 | position: relative; 15 | flex-direction: column; 16 | box-sizing: border-box; 17 | user-select: none; 18 | -webkit-user-select: none; 19 | -moz-user-select: none; 20 | -ms-user-select: none; 21 | overflow: hidden; 22 | background-color: var(--background-color-alpha); 23 | 24 | $maxLogo: 100px; 25 | 26 | .the-logo { 27 | position: relative; 28 | flex: 0 1 $maxLogo; 29 | width: 100%; 30 | max-width: $maxLogo + 32px; 31 | max-height: $maxLogo; 32 | margin: 0 auto; 33 | padding: 0 16px; 34 | text-align: center; 35 | box-sizing: border-box; 36 | 37 | img, 38 | svg { 39 | width: 100%; 40 | height: 100%; 41 | pointer-events: none; 42 | fill: var(--primary-main); 43 | 44 | #bg { 45 | fill: var(--background-color); 46 | } 47 | } 48 | 49 | .the-newbtn { 50 | position: absolute; 51 | right: 10px; 52 | bottom: 0px; 53 | transform: scale(0.8); 54 | } 55 | } 56 | 57 | .the-menu { 58 | flex: 1 1 80%; 59 | overflow-y: auto; 60 | margin-bottom: 0px; 61 | } 62 | 63 | .the-traffic { 64 | flex: 0 0 60px; 65 | 66 | > div { 67 | margin: 0 auto; 68 | } 69 | } 70 | } 71 | 72 | &__right { 73 | position: relative; 74 | flex: 1 1 75%; 75 | height: 100%; 76 | background-color: var(--background-color-alpha); 77 | 78 | .the-bar { 79 | position: absolute; 80 | top: 0px; 81 | right: 0px; 82 | height: 36px; 83 | display: flex; 84 | align-items: center; 85 | box-sizing: border-box; 86 | z-index: 2; 87 | } 88 | 89 | .the-content { 90 | position: absolute; 91 | top: 0; 92 | left: 0; 93 | right: 2px; 94 | bottom: 0px; 95 | } 96 | } 97 | } 98 | 99 | .linux, 100 | .windows, 101 | .unknown { 102 | &.layout { 103 | $maxLogo: 115px; 104 | .layout__left .the-logo { 105 | flex: 0 1 $maxLogo; 106 | max-width: $maxLogo + 32px; 107 | max-height: $maxLogo; 108 | } 109 | 110 | .layout__right .the-content { 111 | top: 30px; 112 | } 113 | } 114 | } 115 | 116 | .macos { 117 | &.layout { 118 | .layout__left { 119 | padding-top: 24px; 120 | } 121 | .layout__right .the-content { 122 | top: 20px; 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/assets/styles/page.scss: -------------------------------------------------------------------------------- 1 | .base-page { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | 7 | > header { 8 | flex: 0 0 58px; 9 | width: 100%; 10 | // max-width: 850px; 11 | margin: 0 auto; 12 | padding-right: 8px; 13 | box-sizing: border-box; 14 | display: flex; 15 | align-items: center; 16 | justify-content: space-between; 17 | } 18 | 19 | .base-container { 20 | height: 100%; 21 | overflow: hidden; 22 | border-radius: var(--border-radius); 23 | 24 | > section { 25 | position: relative; 26 | flex: 1 1 100%; 27 | width: 100%; 28 | height: 100%; 29 | overflow: auto; 30 | padding: 10px 0; 31 | box-sizing: border-box; 32 | scrollbar-gutter: stable; 33 | 34 | .base-content { 35 | width: calc(100% - 10px * 2); 36 | margin: 0 auto; 37 | } 38 | } 39 | 40 | &.no-padding { 41 | > section { 42 | padding: 0; 43 | overflow: visible; 44 | 45 | .base-content { 46 | width: 100%; 47 | } 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/components/base/base-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { 3 | Button, 4 | Dialog, 5 | DialogActions, 6 | DialogContent, 7 | DialogTitle, 8 | type SxProps, 9 | type Theme, 10 | } from "@mui/material"; 11 | import { LoadingButton } from "@mui/lab"; 12 | 13 | interface Props { 14 | title: ReactNode; 15 | open: boolean; 16 | okBtn?: ReactNode; 17 | cancelBtn?: ReactNode; 18 | disableOk?: boolean; 19 | disableCancel?: boolean; 20 | disableFooter?: boolean; 21 | contentSx?: SxProps; 22 | children?: ReactNode; 23 | loading?: boolean; 24 | onOk?: () => void; 25 | onCancel?: () => void; 26 | onClose?: () => void; 27 | } 28 | 29 | export interface DialogRef { 30 | open: () => void; 31 | close: () => void; 32 | } 33 | 34 | export const BaseDialog: React.FC = (props) => { 35 | const { 36 | open, 37 | title, 38 | children, 39 | okBtn, 40 | cancelBtn, 41 | contentSx, 42 | disableCancel, 43 | disableOk, 44 | disableFooter, 45 | loading, 46 | } = props; 47 | 48 | return ( 49 | 50 | {title} 51 | 52 | {children} 53 | 54 | {!disableFooter && ( 55 | 56 | {!disableCancel && ( 57 | 60 | )} 61 | {!disableOk && ( 62 | 67 | {okBtn} 68 | 69 | )} 70 | 71 | )} 72 | 73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /src/components/base/base-empty.tsx: -------------------------------------------------------------------------------- 1 | import { alpha, Box, Typography } from "@mui/material"; 2 | import { InboxRounded } from "@mui/icons-material"; 3 | 4 | interface Props { 5 | text?: React.ReactNode; 6 | extra?: React.ReactNode; 7 | } 8 | 9 | export const BaseEmpty = (props: Props) => { 10 | const { text = "Empty", extra } = props; 11 | 12 | return ( 13 | ({ 15 | width: "100%", 16 | height: "100%", 17 | display: "flex", 18 | flexDirection: "column", 19 | alignItems: "center", 20 | justifyContent: "center", 21 | color: alpha(palette.text.secondary, 0.75), 22 | })} 23 | > 24 | 25 | {text} 26 | {extra} 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/base/base-error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { ErrorBoundary, FallbackProps } from "react-error-boundary"; 3 | 4 | function ErrorFallback({ error }: FallbackProps) { 5 | return ( 6 |
7 |

Something went wrong:(

8 | 9 |
{error.message}
10 | 11 |
12 | Error Stack 13 |
{error.stack}
14 |
15 |
16 | ); 17 | } 18 | 19 | interface Props { 20 | children?: ReactNode; 21 | } 22 | 23 | export const BaseErrorBoundary = (props: Props) => { 24 | return ( 25 | 26 | {props.children} 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/base/base-loading.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material"; 2 | 3 | const Loading = styled("div")` 4 | position: relative; 5 | display: flex; 6 | height: 100%; 7 | min-height: 18px; 8 | box-sizing: border-box; 9 | align-items: center; 10 | 11 | & > div { 12 | box-sizing: border-box; 13 | width: 6px; 14 | height: 6px; 15 | margin: 2px; 16 | border-radius: 100%; 17 | animation: loading 0.7s -0.15s infinite linear; 18 | } 19 | 20 | & > div:nth-child(2n-1) { 21 | animation-delay: -0.5s; 22 | } 23 | 24 | @keyframes loading { 25 | 50% { 26 | opacity: 0.2; 27 | transform: scale(0.75); 28 | } 29 | 100% { 30 | opacity: 1; 31 | transform: scale(1); 32 | } 33 | } 34 | `; 35 | 36 | const LoadingItem = styled("div")(({ theme }) => ({ 37 | background: theme.palette.text.secondary, 38 | })); 39 | 40 | export const BaseLoading = () => { 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/components/base/base-notice.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import { ReactNode, useState } from "react"; 3 | import { Box, IconButton, Slide, Snackbar, Typography } from "@mui/material"; 4 | import { Close, CheckCircleRounded, ErrorRounded } from "@mui/icons-material"; 5 | 6 | interface InnerProps { 7 | type: string; 8 | duration?: number; 9 | message: ReactNode; 10 | onClose: () => void; 11 | } 12 | 13 | const NoticeInner = (props: InnerProps) => { 14 | const { type, message, duration = 1500, onClose } = props; 15 | const [visible, setVisible] = useState(true); 16 | 17 | const onBtnClose = () => { 18 | setVisible(false); 19 | onClose(); 20 | }; 21 | const onAutoClose = (_e: any, reason: string) => { 22 | if (reason !== "clickaway") onBtnClose(); 23 | }; 24 | 25 | const msgElement = 26 | type === "info" ? ( 27 | message 28 | ) : ( 29 | 30 | {type === "error" && } 31 | {type === "success" && } 32 | 33 | 37 | {message} 38 | 39 | 40 | ); 41 | 42 | return ( 43 | } 51 | transitionDuration={200} 52 | action={ 53 | 54 | 55 | 56 | } 57 | /> 58 | ); 59 | }; 60 | 61 | interface NoticeInstance { 62 | (props: Omit): void; 63 | 64 | info(message: ReactNode, duration?: number): void; 65 | error(message: ReactNode, duration?: number): void; 66 | success(message: ReactNode, duration?: number): void; 67 | } 68 | 69 | let parent: HTMLDivElement = null!; 70 | 71 | // @ts-ignore 72 | export const Notice: NoticeInstance = (props) => { 73 | if (!parent) { 74 | parent = document.createElement("div"); 75 | document.body.appendChild(parent); 76 | } 77 | 78 | const container = document.createElement("div"); 79 | parent.appendChild(container); 80 | const root = createRoot(container); 81 | 82 | const onUnmount = () => { 83 | root.unmount(); 84 | if (parent) setTimeout(() => parent.removeChild(container), 500); 85 | }; 86 | 87 | root.render(); 88 | }; 89 | 90 | (["info", "error", "success"] as const).forEach((type) => { 91 | Notice[type] = (message, duration) => { 92 | setTimeout(() => Notice({ type, message, duration }), 0); 93 | }; 94 | }); 95 | -------------------------------------------------------------------------------- /src/components/base/base-page.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import { Typography, alpha } from "@mui/material"; 3 | import { BaseErrorBoundary } from "./base-error-boundary"; 4 | import { useCustomTheme } from "@/components/layout/use-custom-theme"; 5 | 6 | interface Props { 7 | title?: React.ReactNode; // the page title 8 | header?: React.ReactNode; // something behind title 9 | contentStyle?: React.CSSProperties; 10 | children?: ReactNode; 11 | full?: boolean; 12 | } 13 | 14 | export const BasePage: React.FC = (props) => { 15 | const { title, header, contentStyle, full, children } = props; 16 | const { theme } = useCustomTheme(); 17 | 18 | const isDark = theme.palette.mode === "dark"; 19 | 20 | return ( 21 | 22 |
23 |
24 | 25 | {title} 26 | 27 | 28 | {header} 29 |
30 | 31 |
35 |
42 |
43 | {children} 44 |
45 |
46 |
47 |
48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/base/index.ts: -------------------------------------------------------------------------------- 1 | export { BaseDialog, type DialogRef } from "./base-dialog"; 2 | export { BasePage } from "./base-page"; 3 | export { BaseEmpty } from "./base-empty"; 4 | export { BaseLoading } from "./base-loading"; 5 | export { BaseErrorBoundary } from "./base-error-boundary"; 6 | export { Notice } from "./base-notice"; 7 | -------------------------------------------------------------------------------- /src/components/connection/connection-detail.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { forwardRef, useImperativeHandle, useState } from "react"; 3 | import { useLockFn } from "ahooks"; 4 | import { Box, Button, Snackbar } from "@mui/material"; 5 | import { deleteConnection } from "@/services/api"; 6 | import { truncateStr } from "@/utils/truncate-str"; 7 | import parseTraffic from "@/utils/parse-traffic"; 8 | 9 | export interface ConnectionDetailRef { 10 | open: (detail: IConnectionsItem) => void; 11 | } 12 | 13 | export const ConnectionDetail = forwardRef( 14 | (props, ref) => { 15 | const [open, setOpen] = useState(false); 16 | const [detail, setDetail] = useState(null!); 17 | 18 | useImperativeHandle(ref, () => ({ 19 | open: (detail: IConnectionsItem) => { 20 | if (open) return; 21 | setOpen(true); 22 | setDetail(detail); 23 | }, 24 | })); 25 | 26 | const onClose = () => setOpen(false); 27 | 28 | return ( 29 | 36 | ) : null 37 | } 38 | /> 39 | ); 40 | } 41 | ); 42 | 43 | interface InnerProps { 44 | data: IConnectionsItem; 45 | onClose?: () => void; 46 | } 47 | 48 | const InnerConnectionDetail = ({ data, onClose }: InnerProps) => { 49 | const { metadata, rulePayload } = data; 50 | const chains = [...data.chains].reverse().join(" / "); 51 | const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule; 52 | const host = metadata.host 53 | ? `${metadata.host}:${metadata.destinationPort}` 54 | : `${metadata.destinationIP}:${metadata.destinationPort}`; 55 | 56 | const information = [ 57 | { label: "Host", value: host }, 58 | { label: "Download", value: parseTraffic(data.download).join(" ") }, 59 | { label: "Upload", value: parseTraffic(data.upload).join(" ") }, 60 | { 61 | label: "DL Speed", 62 | value: parseTraffic(data.curDownload ?? -1).join(" ") + "/s", 63 | }, 64 | { 65 | label: "UL Speed", 66 | value: parseTraffic(data.curUpload ?? -1).join(" ") + "/s", 67 | }, 68 | { label: "Chains", value: chains }, 69 | { label: "Rule", value: rule }, 70 | { 71 | label: "Process", 72 | value: truncateStr(metadata.process || metadata.processPath), 73 | }, 74 | { label: "Time", value: dayjs(data.start).fromNow() }, 75 | { label: "Source", value: `${metadata.sourceIP}:${metadata.sourcePort}` }, 76 | { label: "Destination IP", value: metadata.destinationIP }, 77 | { label: "Type", value: `${metadata.type}(${metadata.network})` }, 78 | ]; 79 | 80 | const onDelete = useLockFn(async () => deleteConnection(data.id)); 81 | 82 | return ( 83 | 84 | {information.map((each) => ( 85 |
86 | {each.label}: {each.value} 87 |
88 | ))} 89 | 90 | 91 | 101 | 102 |
103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /src/components/connection/connection-item.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { useLockFn } from "ahooks"; 3 | import { 4 | styled, 5 | ListItem, 6 | IconButton, 7 | ListItemText, 8 | Box, 9 | alpha, 10 | } from "@mui/material"; 11 | import { CloseRounded } from "@mui/icons-material"; 12 | import { deleteConnection } from "@/services/api"; 13 | import parseTraffic from "@/utils/parse-traffic"; 14 | 15 | const Tag = styled("span")(({ theme }) => ({ 16 | fontSize: "10px", 17 | padding: "0 4px", 18 | lineHeight: 1.375, 19 | border: "1px solid", 20 | borderRadius: 4, 21 | borderColor: alpha(theme.palette.text.secondary, 0.35), 22 | marginRight: "4px", 23 | })); 24 | 25 | interface Props { 26 | value: IConnectionsItem; 27 | onShowDetail?: () => void; 28 | } 29 | 30 | export const ConnectionItem = (props: Props) => { 31 | const { value, onShowDetail } = props; 32 | 33 | const { id, metadata, chains, start, curUpload, curDownload } = value; 34 | 35 | const onDelete = useLockFn(async () => deleteConnection(id)); 36 | const showTraffic = curUpload! >= 100 || curDownload! >= 100; 37 | 38 | return ( 39 | 43 | 44 | 45 | } 46 | > 47 | 53 | 54 | {metadata.network} 55 | 56 | 57 | {metadata.type} 58 | 59 | {!!metadata.process && {metadata.process}} 60 | 61 | {chains?.length > 0 && {chains[value.chains.length - 1]}} 62 | 63 | {dayjs(start).fromNow()} 64 | 65 | {showTraffic && ( 66 | 67 | {parseTraffic(curUpload!)} / {parseTraffic(curDownload!)} 68 | 69 | )} 70 | 71 | } 72 | /> 73 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /src/components/connection/connection-table.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { useMemo, useState } from "react"; 3 | import { DataGrid, GridColDef } from "@mui/x-data-grid"; 4 | import { truncateStr } from "@/utils/truncate-str"; 5 | import parseTraffic from "@/utils/parse-traffic"; 6 | import { sortWithUnit, sortStringTime } from "@/utils/custom-comparator"; 7 | 8 | interface Props { 9 | connections: IConnectionsItem[]; 10 | onShowDetail: (data: IConnectionsItem) => void; 11 | } 12 | 13 | export const ConnectionTable = (props: Props) => { 14 | const { connections, onShowDetail } = props; 15 | 16 | const [columnVisible, setColumnVisible] = useState< 17 | Partial> 18 | >({}); 19 | 20 | const columns: GridColDef[] = [ 21 | { 22 | field: "time", 23 | headerName: "Time", 24 | flex: 120, 25 | minWidth: 100, 26 | align: "right", 27 | headerAlign: "right", 28 | sortComparator: sortStringTime, 29 | }, 30 | { field: "type", headerName: "Type", flex: 160, minWidth: 100 }, 31 | { field: "process", headerName: "Process", flex: 240, minWidth: 120 }, 32 | { field: "host", headerName: "Host", flex: 220, minWidth: 220 }, 33 | { field: "chains", headerName: "Chains", flex: 360, minWidth: 240 }, 34 | { field: "rule", headerName: "Rule", flex: 250, minWidth: 200 }, 35 | { 36 | field: "download", 37 | headerName: "Download", 38 | width: 88, 39 | align: "right", 40 | headerAlign: "right", 41 | sortComparator: sortWithUnit, 42 | }, 43 | { 44 | field: "upload", 45 | headerName: "Upload", 46 | width: 88, 47 | align: "right", 48 | headerAlign: "right", 49 | sortComparator: sortWithUnit, 50 | }, 51 | { 52 | field: "dlSpeed", 53 | headerName: "DL Speed", 54 | width: 88, 55 | align: "right", 56 | headerAlign: "right", 57 | sortComparator: sortWithUnit, 58 | }, 59 | { 60 | field: "ulSpeed", 61 | headerName: "UL Speed", 62 | width: 88, 63 | align: "right", 64 | headerAlign: "right", 65 | sortComparator: sortWithUnit, 66 | }, 67 | { field: "source", headerName: "Source IP", flex: 200, minWidth: 130 }, 68 | { 69 | field: "destinationIP", 70 | headerName: "Destination IP", 71 | flex: 200, 72 | minWidth: 130, 73 | }, 74 | ]; 75 | 76 | const connRows = useMemo(() => { 77 | return connections.map((each) => { 78 | const { metadata, rulePayload } = each; 79 | const chains = 80 | each.chains.length > 1 81 | ? [each.chains[each.chains.length - 1], each.chains[0]].join(" > ") 82 | : each.chains[0]; 83 | const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule; 84 | return { 85 | id: each.id, 86 | host: metadata.host 87 | ? `${metadata.host}:${metadata.destinationPort}` 88 | : `${metadata.destinationIP}:${metadata.destinationPort}`, 89 | download: parseTraffic(each.download).join(" "), 90 | upload: parseTraffic(each.upload).join(" "), 91 | dlSpeed: parseTraffic(each.curDownload).join(" ") + "/s", 92 | ulSpeed: parseTraffic(each.curUpload).join(" ") + "/s", 93 | chains, 94 | rule, 95 | process: truncateStr(metadata.process || metadata.processPath), 96 | time: dayjs(each.start).fromNow(), 97 | source: `${metadata.sourceIP}:${metadata.sourcePort}`, 98 | destinationIP: metadata.remoteDestination || metadata.destinationIP, 99 | type: `${metadata.type}(${metadata.network})`, 100 | 101 | connectionData: each, 102 | }; 103 | }); 104 | }, [connections]); 105 | 106 | return ( 107 | onShowDetail(e.row.connectionData)} 112 | density="compact" 113 | sx={{ border: "none", "div:focus": { outline: "none !important" } }} 114 | columnVisibilityModel={columnVisible} 115 | onColumnVisibilityModelChange={(e) => setColumnVisible(e)} 116 | /> 117 | ); 118 | }; 119 | -------------------------------------------------------------------------------- /src/components/layout/layout-control.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonGroup } from "@mui/material"; 2 | import { appWindow } from "@tauri-apps/api/window"; 3 | import { 4 | CloseRounded, 5 | CropSquareRounded, 6 | FilterNoneRounded, 7 | HorizontalRuleRounded, 8 | PushPinOutlined, 9 | PushPinRounded, 10 | } from "@mui/icons-material"; 11 | import { useState } from "react"; 12 | 13 | export const LayoutControl = () => { 14 | const minWidth = 40; 15 | 16 | const [isMaximized, setIsMaximized] = useState(false); 17 | const [isPined, setIsPined] = useState(false); 18 | appWindow.isMaximized().then((isMaximized) => { 19 | setIsMaximized(() => isMaximized); 20 | }); 21 | 22 | return ( 23 | 33 | 47 | 48 | 55 | 56 | 75 | 76 | 87 | 88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /src/components/layout/layout-item.tsx: -------------------------------------------------------------------------------- 1 | import { alpha, ListItem, ListItemButton, ListItemText } from "@mui/material"; 2 | import { useMatch, useResolvedPath, useNavigate } from "react-router-dom"; 3 | import type { LinkProps } from "react-router-dom"; 4 | 5 | export const LayoutItem = (props: LinkProps) => { 6 | const { to, children } = props; 7 | 8 | const resolved = useResolvedPath(to); 9 | const match = useMatch({ path: resolved.pathname, end: true }); 10 | const navigate = useNavigate(); 11 | 12 | return ( 13 | 14 | { 25 | const bgcolor = 26 | mode === "light" 27 | ? alpha(primary.main, 0.15) 28 | : alpha(primary.main, 0.35); 29 | const color = mode === "light" ? primary.main : primary.light; 30 | 31 | return { 32 | "&.Mui-selected": { bgcolor }, 33 | "&.Mui-selected:hover": { bgcolor }, 34 | "&.Mui-selected .MuiListItemText-primary": { color }, 35 | }; 36 | }, 37 | ]} 38 | onClick={() => navigate(to)} 39 | > 40 | 41 | 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/components/layout/update-button.tsx: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import { useRef } from "react"; 3 | import { Button } from "@mui/material"; 4 | import { checkUpdate } from "@tauri-apps/api/updater"; 5 | import { UpdateViewer } from "../setting/mods/update-viewer"; 6 | import { DialogRef } from "../base"; 7 | 8 | interface Props { 9 | className?: string; 10 | } 11 | 12 | export const UpdateButton = (props: Props) => { 13 | const { className } = props; 14 | 15 | const viewerRef = useRef(null); 16 | 17 | const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, { 18 | errorRetryCount: 2, 19 | revalidateIfStale: false, 20 | focusThrottleInterval: 36e5, // 1 hour 21 | }); 22 | 23 | if (!updateInfo?.shouldUpdate) return null; 24 | 25 | return ( 26 | <> 27 | 28 | 29 | 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/layout/use-log-setup.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { useEffect } from "react"; 3 | import { useRecoilValue, useSetRecoilState } from "recoil"; 4 | import { getClashLogs } from "@/services/cmds"; 5 | import { useClashInfo } from "@/hooks/use-clash"; 6 | import { atomEnableLog, atomLogData } from "@/services/states"; 7 | import { useWebsocket } from "@/hooks/use-websocket"; 8 | 9 | const MAX_LOG_NUM = 1000; 10 | 11 | // setup the log websocket 12 | export const useLogSetup = () => { 13 | const { clashInfo } = useClashInfo(); 14 | 15 | const enableLog = useRecoilValue(atomEnableLog); 16 | const setLogData = useSetRecoilState(atomLogData); 17 | 18 | const { connect, disconnect } = useWebsocket((event) => { 19 | const data = JSON.parse(event.data) as ILogItem; 20 | const time = dayjs().format("MM-DD HH:mm:ss"); 21 | setLogData((l) => { 22 | if (l.length >= MAX_LOG_NUM) l.shift(); 23 | return [...l, { ...data, time }]; 24 | }); 25 | }); 26 | 27 | useEffect(() => { 28 | if (!enableLog || !clashInfo) return; 29 | 30 | getClashLogs().then(setLogData); 31 | 32 | const { server = "", secret = "" } = clashInfo; 33 | connect(`ws://${server}/logs?token=${encodeURIComponent(secret)}`); 34 | 35 | return () => { 36 | disconnect(); 37 | }; 38 | }, [clashInfo, enableLog]); 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/log/log-item.tsx: -------------------------------------------------------------------------------- 1 | import { styled, Box } from "@mui/material"; 2 | 3 | const Item = styled(Box)(({ theme: { palette, typography } }) => ({ 4 | padding: "8px 0", 5 | margin: "0 12px", 6 | lineHeight: 1.35, 7 | borderBottom: `1px solid ${palette.divider}`, 8 | fontSize: "0.875rem", 9 | fontFamily: typography.fontFamily, 10 | userSelect: "text", 11 | "& .time": { 12 | color: palette.text.secondary, 13 | }, 14 | "& .type": { 15 | display: "inline-block", 16 | marginLeft: 8, 17 | textAlign: "center", 18 | borderRadius: 2, 19 | textTransform: "uppercase", 20 | fontWeight: "600", 21 | }, 22 | '& .type[data-type="error"], & .type[data-type="err"]': { 23 | color: palette.error.main, 24 | }, 25 | '& .type[data-type="warning"], & .type[data-type="warn"]': { 26 | color: palette.warning.main, 27 | }, 28 | '& .type[data-type="info"], & .type[data-type="inf"]': { 29 | color: palette.info.main, 30 | }, 31 | "& .data": { 32 | color: palette.text.primary, 33 | }, 34 | })); 35 | 36 | interface Props { 37 | value: ILogItem; 38 | } 39 | 40 | const LogItem = (props: Props) => { 41 | const { value } = props; 42 | 43 | return ( 44 | 45 |
46 | {value.time} 47 | 48 | {value.type} 49 | 50 |
51 |
52 | {value.payload} 53 |
54 |
55 | ); 56 | }; 57 | 58 | export default LogItem; 59 | -------------------------------------------------------------------------------- /src/components/profile/editor-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { useLockFn } from "ahooks"; 3 | import { useRecoilValue } from "recoil"; 4 | import { useTranslation } from "react-i18next"; 5 | import { 6 | Button, 7 | Dialog, 8 | DialogActions, 9 | DialogContent, 10 | DialogTitle, 11 | } from "@mui/material"; 12 | import { atomThemeMode } from "@/services/states"; 13 | import { readProfileFile, saveProfileFile } from "@/services/cmds"; 14 | import { Notice } from "@/components/base"; 15 | 16 | import "monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js"; 17 | import "monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution.js"; 18 | import "monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js"; 19 | import { editor } from "monaco-editor/esm/vs/editor/editor.api"; 20 | 21 | interface Props { 22 | uid: string; 23 | open: boolean; 24 | mode: "yaml" | "javascript"; 25 | onClose: () => void; 26 | onChange?: () => void; 27 | } 28 | 29 | export const EditorViewer = (props: Props) => { 30 | const { uid, open, mode, onClose, onChange } = props; 31 | 32 | const { t } = useTranslation(); 33 | const editorRef = useRef(); 34 | const instanceRef = useRef(null); 35 | const themeMode = useRecoilValue(atomThemeMode); 36 | 37 | useEffect(() => { 38 | if (!open) return; 39 | 40 | readProfileFile(uid).then((data) => { 41 | const dom = editorRef.current; 42 | 43 | if (!dom) return; 44 | if (instanceRef.current) instanceRef.current.dispose(); 45 | 46 | instanceRef.current = editor.create(editorRef.current, { 47 | value: data, 48 | language: mode, 49 | theme: themeMode === "light" ? "vs" : "vs-dark", 50 | minimap: { enabled: false }, 51 | }); 52 | }); 53 | 54 | return () => { 55 | if (instanceRef.current) { 56 | instanceRef.current.dispose(); 57 | instanceRef.current = null; 58 | } 59 | }; 60 | }, [open]); 61 | 62 | const onSave = useLockFn(async () => { 63 | const value = instanceRef.current?.getValue(); 64 | 65 | if (value == null) return; 66 | 67 | try { 68 | await saveProfileFile(uid, value); 69 | onChange?.(); 70 | onClose(); 71 | } catch (err: any) { 72 | Notice.error(err.message || err.toString()); 73 | } 74 | }); 75 | 76 | return ( 77 | 78 | {t("Edit File")} 79 | 80 | 81 |
82 | 83 | 84 | 85 | 88 | 91 | 92 |
93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /src/components/profile/file-input.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react"; 2 | import { useLockFn } from "ahooks"; 3 | import { useTranslation } from "react-i18next"; 4 | import { Box, Button, Typography } from "@mui/material"; 5 | 6 | interface Props { 7 | onChange: (value: string) => void; 8 | } 9 | 10 | export const FileInput = (props: Props) => { 11 | const { onChange } = props; 12 | 13 | const { t } = useTranslation(); 14 | // file input 15 | const inputRef = useRef(); 16 | const [loading, setLoading] = useState(false); 17 | const [fileName, setFileName] = useState(""); 18 | 19 | const onFileInput = useLockFn(async (e: any) => { 20 | const file = e.target.files?.[0] as File; 21 | 22 | if (!file) return; 23 | 24 | setFileName(file.name); 25 | setLoading(true); 26 | 27 | return new Promise((resolve, reject) => { 28 | const reader = new FileReader(); 29 | reader.onload = (event) => { 30 | resolve(null); 31 | onChange(event.target?.result as string); 32 | }; 33 | reader.onerror = reject; 34 | reader.readAsText(file); 35 | }).finally(() => setLoading(false)); 36 | }); 37 | 38 | return ( 39 | 40 | 47 | 48 | 55 | 56 | 57 | {loading ? "Loading..." : fileName} 58 | 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/components/profile/log-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { 4 | Button, 5 | Chip, 6 | Dialog, 7 | DialogActions, 8 | DialogContent, 9 | DialogTitle, 10 | Divider, 11 | Typography, 12 | } from "@mui/material"; 13 | import { BaseEmpty } from "@/components/base"; 14 | 15 | interface Props { 16 | open: boolean; 17 | logInfo: [string, string][]; 18 | onClose: () => void; 19 | } 20 | 21 | export const LogViewer = (props: Props) => { 22 | const { open, logInfo, onClose } = props; 23 | 24 | const { t } = useTranslation(); 25 | 26 | return ( 27 | 28 | {t("Script Console")} 29 | 30 | 39 | {logInfo.map(([level, log], index) => ( 40 | 41 | 42 | 53 | {log} 54 | 55 | 56 | 57 | ))} 58 | 59 | {logInfo.length === 0 && } 60 | 61 | 62 | 63 | 66 | 67 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/components/profile/profile-box.tsx: -------------------------------------------------------------------------------- 1 | import { alpha, Box, styled } from "@mui/material"; 2 | 3 | export const ProfileBox = styled(Box)( 4 | ({ theme, "aria-selected": selected }) => { 5 | const { mode, primary, text, grey, background } = theme.palette; 6 | const key = `${mode}-${!!selected}`; 7 | 8 | const backgroundColor = { 9 | "light-true": alpha(primary.main, 0.2), 10 | "light-false": alpha(background.paper, 0.75), 11 | "dark-true": alpha(primary.main, 0.45), 12 | "dark-false": alpha(grey[700], 0.45), 13 | }[key]!; 14 | 15 | const color = { 16 | "light-true": text.secondary, 17 | "light-false": text.secondary, 18 | "dark-true": alpha(text.secondary, 0.85), 19 | "dark-false": alpha(text.secondary, 0.65), 20 | }[key]!; 21 | 22 | const h2color = { 23 | "light-true": primary.main, 24 | "light-false": text.primary, 25 | "dark-true": primary.light, 26 | "dark-false": text.primary, 27 | }[key]!; 28 | 29 | return { 30 | position: "relative", 31 | width: "100%", 32 | display: "block", 33 | cursor: "pointer", 34 | textAlign: "left", 35 | borderRadius: theme.shape.borderRadius, 36 | boxShadow: theme.shadows[2], 37 | padding: "8px 16px", 38 | boxSizing: "border-box", 39 | backgroundColor, 40 | color, 41 | "& h2": { color: h2color }, 42 | }; 43 | } 44 | ); 45 | -------------------------------------------------------------------------------- /src/components/proxy/provider-button.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import useSWR, { mutate } from "swr"; 3 | import { useState } from "react"; 4 | import { 5 | Button, 6 | IconButton, 7 | List, 8 | ListItem, 9 | ListItemText, 10 | } from "@mui/material"; 11 | import { RefreshRounded } from "@mui/icons-material"; 12 | import { useTranslation } from "react-i18next"; 13 | import { useLockFn } from "ahooks"; 14 | import { getProviders, providerUpdate } from "@/services/api"; 15 | import { BaseDialog } from "../base"; 16 | 17 | export const ProviderButton = () => { 18 | const { t } = useTranslation(); 19 | const { data } = useSWR("getProviders", getProviders); 20 | 21 | const [open, setOpen] = useState(false); 22 | 23 | const hasProvider = Object.keys(data || {}).length > 0; 24 | 25 | const handleUpdate = useLockFn(async (key: string) => { 26 | await providerUpdate(key); 27 | await mutate("getProxies"); 28 | await mutate("getProviders"); 29 | }); 30 | 31 | if (!hasProvider) return null; 32 | 33 | return ( 34 | <> 35 | 43 | 44 | setOpen(false)} 51 | onCancel={() => setOpen(false)} 52 | > 53 | 54 | {Object.entries(data || {}).map(([key, item]) => { 55 | const time = dayjs(item.updatedAt); 56 | return ( 57 | 58 | 62 | 63 | Type: {item.vehicleType} 64 | 65 | 66 | Updated: {time.fromNow()} 67 | 68 | 69 | } 70 | /> 71 | handleUpdate(key)} 76 | > 77 | 78 | 79 | 80 | ); 81 | })} 82 | 83 | 84 | 85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /src/components/proxy/proxy-groups.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { useLockFn } from "ahooks"; 3 | import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; 4 | import { 5 | getConnections, 6 | providerHealthCheck, 7 | updateProxy, 8 | deleteConnection, 9 | } from "@/services/api"; 10 | import { useProfiles } from "@/hooks/use-profiles"; 11 | import { useVerge } from "@/hooks/use-verge"; 12 | import { BaseEmpty } from "../base"; 13 | import { useRenderList } from "./use-render-list"; 14 | import { ProxyRender } from "./proxy-render"; 15 | import delayManager from "@/services/delay"; 16 | 17 | interface Props { 18 | mode: string; 19 | } 20 | 21 | export const ProxyGroups = (props: Props) => { 22 | const { mode } = props; 23 | 24 | const { renderList, onProxies, onHeadState } = useRenderList(mode); 25 | 26 | const { verge } = useVerge(); 27 | const { current, patchCurrent } = useProfiles(); 28 | 29 | const virtuosoRef = useRef(null); 30 | 31 | // 切换分组的节点代理 32 | const handleChangeProxy = useLockFn( 33 | async (group: IProxyGroupItem, proxy: IProxyItem) => { 34 | if (group.type !== "Selector" && group.type !== "Fallback"&& group.type !== "URLTest") return; 35 | 36 | const { name, now } = group; 37 | await updateProxy(name, proxy.name); 38 | onProxies(); 39 | 40 | // 断开连接 41 | if (verge?.auto_close_connection) { 42 | getConnections().then(({ connections }) => { 43 | connections.forEach((conn) => { 44 | if (conn.chains.includes(now!)) { 45 | deleteConnection(conn.id); 46 | } 47 | }); 48 | }); 49 | } 50 | 51 | // 保存到selected中 52 | if (!current) return; 53 | if (!current.selected) current.selected = []; 54 | 55 | const index = current.selected.findIndex( 56 | (item) => item.name === group.name 57 | ); 58 | 59 | if (index < 0) { 60 | current.selected.push({ name, now: proxy.name }); 61 | } else { 62 | current.selected[index] = { name, now: proxy.name }; 63 | } 64 | await patchCurrent({ selected: current.selected }); 65 | } 66 | ); 67 | 68 | // 测全部延迟 69 | const handleCheckAll = useLockFn(async (groupName: string) => { 70 | const proxies = renderList 71 | .filter( 72 | (e) => e.group?.name === groupName && (e.type === 2 || e.type === 4) 73 | ) 74 | .flatMap((e) => e.proxyCol || e.proxy!) 75 | .filter(Boolean); 76 | 77 | const providers = new Set(proxies.map((p) => p!.provider!).filter(Boolean)); 78 | 79 | if (providers.size) { 80 | Promise.allSettled( 81 | [...providers].map((p) => providerHealthCheck(p)) 82 | ).then(() => onProxies()); 83 | } 84 | 85 | const names = proxies.filter((p) => !p!.provider).map((p) => p!.name); 86 | await delayManager.checkListDelay(names, groupName); 87 | 88 | onProxies(); 89 | }); 90 | 91 | // 滚到对应的节点 92 | const handleLocation = (group: IProxyGroupItem) => { 93 | if (!group) return; 94 | const { name, now } = group; 95 | 96 | const index = renderList.findIndex( 97 | (e) => 98 | e.group?.name === name && 99 | ((e.type === 2 && e.proxy?.name === now) || 100 | (e.type === 4 && e.proxyCol?.some((p) => p.name === now))) 101 | ); 102 | 103 | if (index >= 0) { 104 | virtuosoRef.current?.scrollToIndex?.({ 105 | index, 106 | align: "center", 107 | behavior: "smooth", 108 | }); 109 | } 110 | }; 111 | 112 | if (mode === "direct") { 113 | return ; 114 | } 115 | 116 | return ( 117 | ( 123 | 132 | )} 133 | /> 134 | ); 135 | }; 136 | -------------------------------------------------------------------------------- /src/components/proxy/use-filter-sort.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import delayManager from "@/services/delay"; 3 | 4 | // default | delay | alphabet 5 | export type ProxySortType = 0 | 1 | 2; 6 | 7 | export default function useFilterSort( 8 | proxies: IProxyItem[], 9 | groupName: string, 10 | filterText: string, 11 | sortType: ProxySortType 12 | ) { 13 | const [refresh, setRefresh] = useState({}); 14 | 15 | useEffect(() => { 16 | let last = 0; 17 | 18 | delayManager.setGroupListener(groupName, () => { 19 | // 简单节流 20 | const now = Date.now(); 21 | if (now - last > 666) { 22 | last = now; 23 | setRefresh({}); 24 | } 25 | }); 26 | 27 | return () => { 28 | delayManager.removeGroupListener(groupName); 29 | }; 30 | }, [groupName]); 31 | 32 | return useMemo(() => { 33 | const fp = filterProxies(proxies, groupName, filterText); 34 | const sp = sortProxies(fp, groupName, sortType); 35 | return sp; 36 | }, [proxies, groupName, filterText, sortType, refresh]); 37 | } 38 | 39 | export function filterSort( 40 | proxies: IProxyItem[], 41 | groupName: string, 42 | filterText: string, 43 | sortType: ProxySortType 44 | ) { 45 | const fp = filterProxies(proxies, groupName, filterText); 46 | const sp = sortProxies(fp, groupName, sortType); 47 | return sp; 48 | } 49 | 50 | /** 51 | * 可以通过延迟数/节点类型 过滤 52 | */ 53 | const regex1 = /delay([=<>])(\d+|timeout|error)/i; 54 | const regex2 = /type=(.*)/i; 55 | 56 | /** 57 | * filter the proxy 58 | * according to the regular conditions 59 | */ 60 | function filterProxies( 61 | proxies: IProxyItem[], 62 | groupName: string, 63 | filterText: string 64 | ) { 65 | if (!filterText) return proxies; 66 | 67 | const res1 = regex1.exec(filterText); 68 | if (res1) { 69 | const symbol = res1[1]; 70 | const symbol2 = res1[2].toLowerCase(); 71 | const value = 72 | symbol2 === "error" ? 1e5 : symbol2 === "timeout" ? 3000 : +symbol2; 73 | 74 | return proxies.filter((p) => { 75 | const delay = delayManager.getDelayFix(p, groupName); 76 | 77 | if (delay < 0) return false; 78 | if (symbol === "=" && symbol2 === "error") return delay >= 1e5; 79 | if (symbol === "=" && symbol2 === "timeout") 80 | return delay < 1e5 && delay >= 3000; 81 | if (symbol === "=") return delay == value; 82 | if (symbol === "<") return delay <= value; 83 | if (symbol === ">") return delay >= value; 84 | return false; 85 | }); 86 | } 87 | 88 | const res2 = regex2.exec(filterText); 89 | if (res2) { 90 | const type = res2[1].toLowerCase(); 91 | return proxies.filter((p) => p.type.toLowerCase().includes(type)); 92 | } 93 | 94 | return proxies.filter((p) => p.name.includes(filterText.trim())); 95 | } 96 | 97 | /** 98 | * sort the proxy 99 | */ 100 | function sortProxies( 101 | proxies: IProxyItem[], 102 | groupName: string, 103 | sortType: ProxySortType 104 | ) { 105 | if (!proxies) return []; 106 | if (sortType === 0) return proxies; 107 | 108 | const list = proxies.slice(); 109 | 110 | if (sortType === 1) { 111 | list.sort((a, b) => { 112 | const ad = delayManager.getDelayFix(a, groupName); 113 | const bd = delayManager.getDelayFix(b, groupName); 114 | 115 | if (ad === -1 || ad === -2) return 1; 116 | if (bd === -1 || bd === -2) return -1; 117 | 118 | return ad - bd; 119 | }); 120 | } else { 121 | list.sort((a, b) => a.name.localeCompare(b.name)); 122 | } 123 | 124 | return list; 125 | } 126 | -------------------------------------------------------------------------------- /src/components/proxy/use-head-state.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { ProxySortType } from "./use-filter-sort"; 3 | import { useProfiles } from "@/hooks/use-profiles"; 4 | 5 | export interface HeadState { 6 | open?: boolean; 7 | showType: boolean; 8 | sortType: ProxySortType; 9 | filterText: string; 10 | textState: "url" | "filter" | null; 11 | testUrl: string; 12 | } 13 | 14 | type HeadStateStorage = Record>; 15 | 16 | const HEAD_STATE_KEY = "proxy-head-state"; 17 | export const DEFAULT_STATE: HeadState = { 18 | open: false, 19 | showType: false, 20 | sortType: 0, 21 | filterText: "", 22 | textState: null, 23 | testUrl: "", 24 | }; 25 | 26 | export function useHeadStateNew() { 27 | const { profiles } = useProfiles(); 28 | const current = profiles?.current || ""; 29 | 30 | const [state, setState] = useState>({}); 31 | 32 | useEffect(() => { 33 | if (!current) { 34 | setState({}); 35 | return; 36 | } 37 | 38 | try { 39 | const data = JSON.parse( 40 | localStorage.getItem(HEAD_STATE_KEY)! 41 | ) as HeadStateStorage; 42 | 43 | const value = data[current] || {}; 44 | 45 | if (value && typeof value === "object") { 46 | setState(value); 47 | } else { 48 | setState({}); 49 | } 50 | } catch {} 51 | }, [current]); 52 | 53 | const setHeadState = useCallback( 54 | (groupName: string, obj: Partial) => { 55 | setState((old) => { 56 | const state = old[groupName] || DEFAULT_STATE; 57 | const ret = { ...old, [groupName]: { ...state, ...obj } }; 58 | 59 | // 保存到存储中 60 | setTimeout(() => { 61 | try { 62 | const item = localStorage.getItem(HEAD_STATE_KEY); 63 | 64 | let data = (item ? JSON.parse(item) : {}) as HeadStateStorage; 65 | 66 | if (!data || typeof data !== "object") data = {}; 67 | 68 | data[current] = ret; 69 | 70 | localStorage.setItem(HEAD_STATE_KEY, JSON.stringify(data)); 71 | } catch {} 72 | }); 73 | 74 | return ret; 75 | }); 76 | }, 77 | [current] 78 | ); 79 | 80 | return [state, setHeadState] as const; 81 | } 82 | -------------------------------------------------------------------------------- /src/components/proxy/use-render-list.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import { useEffect, useMemo } from "react"; 3 | import { getProxies } from "@/services/api"; 4 | import { useVerge } from "@/hooks/use-verge"; 5 | import { filterSort } from "./use-filter-sort"; 6 | import { useWindowWidth } from "./use-window-width"; 7 | import { 8 | useHeadStateNew, 9 | DEFAULT_STATE, 10 | type HeadState, 11 | } from "./use-head-state"; 12 | 13 | export interface IRenderItem { 14 | // 组 | head | item | empty | item col 15 | type: 0 | 1 | 2 | 3 | 4; 16 | key: string; 17 | group: IProxyGroupItem; 18 | proxy?: IProxyItem; 19 | col?: number; 20 | proxyCol?: IProxyItem[]; 21 | headState?: HeadState; 22 | } 23 | 24 | export const useRenderList = (mode: string) => { 25 | const { data: proxiesData, mutate: mutateProxies } = useSWR( 26 | "getProxies", 27 | getProxies, 28 | { refreshInterval: 45000 } 29 | ); 30 | 31 | const { verge } = useVerge(); 32 | const { width } = useWindowWidth(); 33 | 34 | let col = Math.floor(verge?.proxy_layout_column || 6); 35 | 36 | // 自适应 37 | if (col >= 6 || col <= 0) { 38 | if (width > 1450) col = 4; 39 | else if (width > 1024) col = 3; 40 | else if (width > 900) col = 2; 41 | else if (width >= 600) col = 2; 42 | else col = 1; 43 | } 44 | 45 | const [headStates, setHeadState] = useHeadStateNew(); 46 | 47 | // make sure that fetch the proxies successfully 48 | useEffect(() => { 49 | if (!proxiesData) return; 50 | const { groups, proxies } = proxiesData; 51 | 52 | if ( 53 | (mode === "rule" && !groups.length) || 54 | (mode === "global" && proxies.length < 2) 55 | ) { 56 | setTimeout(() => mutateProxies(), 500); 57 | } 58 | }, [proxiesData, mode]); 59 | 60 | const renderList: IRenderItem[] = useMemo(() => { 61 | if (!proxiesData) return []; 62 | 63 | // global 和 direct 使用展开的样式 64 | const useRule = mode === "rule" || mode === "script"; 65 | const renderGroups = 66 | (useRule && proxiesData.groups.length 67 | ? proxiesData.groups 68 | : [proxiesData.global!]) || []; 69 | 70 | const retList = renderGroups.flatMap((group) => { 71 | const headState = headStates[group.name] || DEFAULT_STATE; 72 | const ret: IRenderItem[] = [ 73 | { type: 0, key: group.name, group, headState }, 74 | ]; 75 | 76 | if (headState?.open || !useRule) { 77 | const proxies = filterSort( 78 | group.all, 79 | group.name, 80 | headState.filterText, 81 | headState.sortType 82 | ); 83 | 84 | ret.push({ type: 1, key: `head-${group.name}`, group, headState }); 85 | 86 | if (!proxies.length) { 87 | ret.push({ type: 3, key: `empty-${group.name}`, group, headState }); 88 | } 89 | 90 | // 支持多列布局 91 | if (col > 1) { 92 | return ret.concat( 93 | groupList(proxies, col).map((proxyCol) => ({ 94 | type: 4, 95 | key: `col-${group.name}-${proxyCol[0].name}`, 96 | group, 97 | headState, 98 | col, 99 | proxyCol, 100 | })) 101 | ); 102 | } 103 | 104 | return ret.concat( 105 | proxies.map((proxy) => ({ 106 | type: 2, 107 | key: `${group.name}-${proxy!.name}`, 108 | group, 109 | proxy, 110 | headState, 111 | })) 112 | ); 113 | } 114 | return ret; 115 | }); 116 | 117 | if (!useRule) return retList.slice(1); 118 | return retList; 119 | }, [headStates, proxiesData, mode, col]); 120 | 121 | return { 122 | renderList, 123 | onProxies: mutateProxies, 124 | onHeadState: setHeadState, 125 | }; 126 | }; 127 | 128 | function groupList(list: T[], size: number): T[][] { 129 | return list.reduce((p, n) => { 130 | if (!p.length) return [[n]]; 131 | 132 | const i = p.length - 1; 133 | if (p[i].length < size) { 134 | p[i].push(n); 135 | return p; 136 | } 137 | 138 | p.push([n]); 139 | return p; 140 | }, [] as T[][]); 141 | } 142 | -------------------------------------------------------------------------------- /src/components/proxy/use-window-width.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useWindowWidth = () => { 4 | const [width, setWidth] = useState(() => document.body.clientWidth); 5 | 6 | useEffect(() => { 7 | const handleResize = () => setWidth(document.body.clientWidth); 8 | 9 | window.addEventListener("resize", handleResize); 10 | return () => { 11 | window.removeEventListener("resize", handleResize); 12 | }; 13 | }, []); 14 | 15 | return { width }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/rule/rule-item.tsx: -------------------------------------------------------------------------------- 1 | import { styled, Box, Typography } from "@mui/material"; 2 | 3 | const Item = styled(Box)(({ theme }) => ({ 4 | display: "flex", 5 | padding: "4px 16px", 6 | color: theme.palette.text.primary, 7 | })); 8 | 9 | const COLOR = [ 10 | "primary", 11 | "secondary", 12 | "info.main", 13 | "warning.main", 14 | "success.main", 15 | ]; 16 | 17 | interface Props { 18 | index: number; 19 | value: IRuleItem; 20 | } 21 | 22 | const parseColor = (text: string) => { 23 | if (text === "REJECT") return "error.main"; 24 | if (text === "DIRECT") return "text.primary"; 25 | 26 | let sum = 0; 27 | for (let i = 0; i < text.length; i++) { 28 | sum += text.charCodeAt(i); 29 | } 30 | return COLOR[sum % COLOR.length]; 31 | }; 32 | 33 | const RuleItem = (props: Props) => { 34 | const { index, value } = props; 35 | 36 | return ( 37 | 38 | 43 | {index} 44 | 45 | 46 | 47 | 48 | {value.payload || "-"} 49 | 50 | 51 | 57 | {value.type} 58 | 59 | 60 | 65 | {value.proxy} 66 | 67 | 68 | 69 | ); 70 | }; 71 | 72 | export default RuleItem; 73 | -------------------------------------------------------------------------------- /src/components/setting/mods/clash-field-viewer.tsx: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import { forwardRef, useImperativeHandle, useState } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { Checkbox, Divider, Stack, Tooltip, Typography } from "@mui/material"; 5 | import { InfoRounded } from "@mui/icons-material"; 6 | import { getRuntimeExists } from "@/services/cmds"; 7 | import { 8 | HANDLE_FIELDS, 9 | DEFAULT_FIELDS, 10 | OTHERS_FIELDS, 11 | } from "@/utils/clash-fields"; 12 | import { BaseDialog, DialogRef } from "@/components/base"; 13 | import { useProfiles } from "@/hooks/use-profiles"; 14 | import { Notice } from "@/components/base"; 15 | 16 | const otherFields = [...OTHERS_FIELDS]; 17 | const handleFields = [...HANDLE_FIELDS, ...DEFAULT_FIELDS]; 18 | 19 | export const ClashFieldViewer = forwardRef((props, ref) => { 20 | const { t } = useTranslation(); 21 | 22 | const { profiles = {}, patchProfiles } = useProfiles(); 23 | const { data: existsKeys = [], mutate: mutateExists } = useSWR( 24 | "getRuntimeExists", 25 | getRuntimeExists 26 | ); 27 | 28 | const [open, setOpen] = useState(false); 29 | const [selected, setSelected] = useState([]); 30 | 31 | useImperativeHandle(ref, () => ({ 32 | open: () => { 33 | mutateExists(); 34 | setSelected(profiles.valid || []); 35 | setOpen(true); 36 | }, 37 | close: () => setOpen(false), 38 | })); 39 | 40 | const handleChange = (item: string) => { 41 | if (!item) return; 42 | 43 | setSelected((old) => 44 | old.includes(item) ? old.filter((e) => e !== item) : [...old, item] 45 | ); 46 | }; 47 | 48 | const handleSave = async () => { 49 | setOpen(false); 50 | 51 | const oldSet = new Set(profiles.valid || []); 52 | const curSet = new Set(selected); 53 | const joinSet = new Set(selected.concat([...oldSet])); 54 | 55 | if (curSet.size === oldSet.size && curSet.size === joinSet.size) return; 56 | 57 | try { 58 | await patchProfiles({ valid: [...curSet] }); 59 | // Notice.success("Refresh clash config", 1000); 60 | } catch (err: any) { 61 | Notice.error(err?.message || err.toString()); 62 | } 63 | }; 64 | 65 | return ( 66 | setOpen(false)} 79 | onCancel={() => setOpen(false)} 80 | onOk={handleSave} 81 | > 82 | {otherFields.map((item) => { 83 | const inSelect = selected.includes(item); 84 | const inConfig = existsKeys.includes(item); 85 | 86 | return ( 87 | 88 | handleChange(item)} 93 | /> 94 | {item} 95 | 96 | {!inSelect && inConfig && } 97 | 98 | ); 99 | })} 100 | 101 | 102 | 103 | Clash Verge Control Fields 104 | 105 | 106 | 107 | {handleFields.map((item) => ( 108 | 109 | 110 | {item} 111 | 112 | ))} 113 | 114 | ); 115 | }); 116 | 117 | function WarnIcon() { 118 | return ( 119 | 120 | 121 | 122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /src/components/setting/mods/clash-port-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useImperativeHandle, useState } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useLockFn } from "ahooks"; 4 | import { List, ListItem, ListItemText, TextField } from "@mui/material"; 5 | import { useClashInfo } from "@/hooks/use-clash"; 6 | import { BaseDialog, DialogRef, Notice } from "@/components/base"; 7 | import { useVerge } from "@/hooks/use-verge"; 8 | 9 | export const ClashPortViewer = forwardRef((props, ref) => { 10 | const { t } = useTranslation(); 11 | 12 | const { clashInfo, patchInfo } = useClashInfo(); 13 | const { verge, patchVerge } = useVerge(); 14 | 15 | const [open, setOpen] = useState(false); 16 | const [port, setPort] = useState( 17 | verge?.verge_mixed_port ?? clashInfo?.port ?? 7897 18 | ); 19 | 20 | useImperativeHandle(ref, () => ({ 21 | open: () => { 22 | if (verge?.verge_mixed_port) setPort(verge?.verge_mixed_port); 23 | setOpen(true); 24 | }, 25 | close: () => setOpen(false), 26 | })); 27 | 28 | const onSave = useLockFn(async () => { 29 | if (port === verge?.verge_mixed_port) { 30 | setOpen(false); 31 | return; 32 | } 33 | try { 34 | await patchInfo({ "mixed-port": port }); 35 | await patchVerge({ verge_mixed_port: port }); 36 | setOpen(false); 37 | Notice.success("Change Clash port successfully!", 1000); 38 | } catch (err: any) { 39 | Notice.error(err.message || err.toString(), 4000); 40 | } 41 | }); 42 | 43 | return ( 44 | setOpen(false)} 51 | onCancel={() => setOpen(false)} 52 | onOk={onSave} 53 | > 54 | 55 | 56 | 57 | 63 | setPort(+e.target.value?.replace(/\D+/, "").slice(0, 5)) 64 | } 65 | /> 66 | 67 | 68 | 69 | ); 70 | }); 71 | -------------------------------------------------------------------------------- /src/components/setting/mods/config-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | forwardRef, 3 | useEffect, 4 | useImperativeHandle, 5 | useRef, 6 | useState, 7 | } from "react"; 8 | import { useTranslation } from "react-i18next"; 9 | import { useRecoilValue } from "recoil"; 10 | import { Chip } from "@mui/material"; 11 | import { atomThemeMode } from "@/services/states"; 12 | import { getRuntimeYaml } from "@/services/cmds"; 13 | import { BaseDialog, DialogRef } from "@/components/base"; 14 | import { editor } from "monaco-editor/esm/vs/editor/editor.api"; 15 | 16 | import "monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js"; 17 | import "monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution.js"; 18 | import "monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js"; 19 | 20 | export const ConfigViewer = forwardRef((props, ref) => { 21 | const { t } = useTranslation(); 22 | const [open, setOpen] = useState(false); 23 | 24 | const editorRef = useRef(); 25 | const instanceRef = useRef(null); 26 | const themeMode = useRecoilValue(atomThemeMode); 27 | 28 | useEffect(() => { 29 | return () => { 30 | if (instanceRef.current) { 31 | instanceRef.current.dispose(); 32 | instanceRef.current = null; 33 | } 34 | }; 35 | }, []); 36 | 37 | useImperativeHandle(ref, () => ({ 38 | open: () => { 39 | setOpen(true); 40 | 41 | getRuntimeYaml().then((data) => { 42 | const dom = editorRef.current; 43 | 44 | if (!dom) return; 45 | if (instanceRef.current) instanceRef.current.dispose(); 46 | 47 | instanceRef.current = editor.create(editorRef.current, { 48 | value: data ?? "# Error\n", 49 | language: "yaml", 50 | theme: themeMode === "light" ? "vs" : "vs-dark", 51 | minimap: { enabled: false }, 52 | readOnly: true, 53 | }); 54 | }); 55 | }, 56 | close: () => setOpen(false), 57 | })); 58 | 59 | return ( 60 | 64 | {t("Runtime Config")} 65 | 66 | } 67 | contentSx={{ width: 520, pb: 1, userSelect: "text" }} 68 | cancelBtn={t("Back")} 69 | disableOk 70 | onClose={() => setOpen(false)} 71 | onCancel={() => setOpen(false)} 72 | > 73 |
74 | 75 | ); 76 | }); 77 | -------------------------------------------------------------------------------- /src/components/setting/mods/controller-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useImperativeHandle, useState } from "react"; 2 | import { useLockFn } from "ahooks"; 3 | import { useTranslation } from "react-i18next"; 4 | import { List, ListItem, ListItemText, TextField } from "@mui/material"; 5 | import { useClashInfo } from "@/hooks/use-clash"; 6 | import { BaseDialog, DialogRef, Notice } from "@/components/base"; 7 | 8 | export const ControllerViewer = forwardRef((props, ref) => { 9 | const { t } = useTranslation(); 10 | const [open, setOpen] = useState(false); 11 | 12 | const { clashInfo, patchInfo } = useClashInfo(); 13 | 14 | const [controller, setController] = useState(clashInfo?.server || ""); 15 | const [secret, setSecret] = useState(clashInfo?.secret || ""); 16 | 17 | useImperativeHandle(ref, () => ({ 18 | open: () => { 19 | setOpen(true); 20 | setController(clashInfo?.server || ""); 21 | setSecret(clashInfo?.secret || ""); 22 | }, 23 | close: () => setOpen(false), 24 | })); 25 | 26 | const onSave = useLockFn(async () => { 27 | try { 28 | await patchInfo({ "external-controller": controller, secret }); 29 | Notice.success("Change Clash Config successfully!", 1000); 30 | setOpen(false); 31 | } catch (err: any) { 32 | Notice.error(err.message || err.toString(), 4000); 33 | } 34 | }); 35 | 36 | return ( 37 | setOpen(false)} 44 | onCancel={() => setOpen(false)} 45 | onOk={onSave} 46 | > 47 | 48 | 49 | 50 | setController(e.target.value)} 57 | /> 58 | 59 | 60 | 61 | 62 | setSecret(e.target.value)} 69 | /> 70 | 71 | 72 | 73 | ); 74 | }); 75 | -------------------------------------------------------------------------------- /src/components/setting/mods/guard-state.tsx: -------------------------------------------------------------------------------- 1 | import { cloneElement, isValidElement, ReactNode, useRef } from "react"; 2 | import noop from "@/utils/noop"; 3 | 4 | interface Props { 5 | value?: Value; 6 | valueProps?: string; 7 | onChangeProps?: string; 8 | waitTime?: number; 9 | onChange?: (value: Value) => void; 10 | onFormat?: (...args: any[]) => Value; 11 | onGuard?: (value: Value, oldValue: Value) => Promise; 12 | onCatch?: (error: Error) => void; 13 | children: ReactNode; 14 | } 15 | 16 | export function GuardState(props: Props) { 17 | const { 18 | value, 19 | children, 20 | valueProps = "value", 21 | onChangeProps = "onChange", 22 | waitTime = 0, // debounce wait time default 0 23 | onGuard = noop, 24 | onCatch = noop, 25 | onChange = noop, 26 | onFormat = (v: T) => v, 27 | } = props; 28 | 29 | const lockRef = useRef(false); 30 | const saveRef = useRef(value); 31 | const lastRef = useRef(0); 32 | const timeRef = useRef(); 33 | 34 | if (!isValidElement(children)) { 35 | return children as any; 36 | } 37 | 38 | const childProps = { ...children.props }; 39 | 40 | childProps[valueProps] = value; 41 | childProps[onChangeProps] = async (...args: any[]) => { 42 | // 多次操作无效 43 | if (lockRef.current) return; 44 | 45 | lockRef.current = true; 46 | 47 | try { 48 | const newValue = (onFormat as any)(...args); 49 | // 先在ui上响应操作 50 | onChange(newValue); 51 | 52 | const now = Date.now(); 53 | 54 | // save the old value 55 | if (waitTime <= 0 || now - lastRef.current >= waitTime) { 56 | saveRef.current = value; 57 | } 58 | 59 | lastRef.current = now; 60 | 61 | if (waitTime <= 0) { 62 | await onGuard(newValue, value!); 63 | } else { 64 | // debounce guard 65 | clearTimeout(timeRef.current); 66 | 67 | timeRef.current = setTimeout(async () => { 68 | try { 69 | await onGuard(newValue, saveRef.current!); 70 | } catch (err: any) { 71 | // 状态回退 72 | onChange(saveRef.current!); 73 | onCatch(err); 74 | } 75 | }, waitTime); 76 | } 77 | } catch (err: any) { 78 | // 状态回退 79 | onChange(saveRef.current!); 80 | onCatch(err); 81 | } 82 | lockRef.current = false; 83 | }; 84 | return cloneElement(children, childProps); 85 | } 86 | -------------------------------------------------------------------------------- /src/components/setting/mods/hotkey-input.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react"; 2 | import { alpha, Box, IconButton, styled } from "@mui/material"; 3 | import { DeleteRounded } from "@mui/icons-material"; 4 | import { parseHotkey } from "@/utils/parse-hotkey"; 5 | 6 | const KeyWrapper = styled("div")(({ theme }) => ({ 7 | position: "relative", 8 | width: 165, 9 | minHeight: 36, 10 | 11 | "> input": { 12 | position: "absolute", 13 | top: 0, 14 | left: 0, 15 | width: "100%", 16 | height: "100%", 17 | zIndex: 1, 18 | opacity: 0, 19 | }, 20 | "> input:focus + .list": { 21 | borderColor: alpha(theme.palette.primary.main, 0.75), 22 | }, 23 | ".list": { 24 | display: "flex", 25 | alignItems: "center", 26 | flexWrap: "wrap", 27 | width: "100%", 28 | height: "100%", 29 | minHeight: 36, 30 | boxSizing: "border-box", 31 | padding: "3px 4px", 32 | border: "1px solid", 33 | borderRadius: 4, 34 | borderColor: alpha(theme.palette.text.secondary, 0.15), 35 | "&:last-child": { 36 | marginRight: 0, 37 | }, 38 | }, 39 | ".item": { 40 | color: theme.palette.text.primary, 41 | border: "1px solid", 42 | borderColor: alpha(theme.palette.text.secondary, 0.2), 43 | borderRadius: "2px", 44 | padding: "1px 1px", 45 | margin: "2px 0", 46 | marginRight: 8, 47 | }, 48 | })); 49 | 50 | interface Props { 51 | value: string[]; 52 | onChange: (value: string[]) => void; 53 | } 54 | 55 | export const HotkeyInput = (props: Props) => { 56 | const { value, onChange } = props; 57 | 58 | const changeRef = useRef([]); 59 | const [keys, setKeys] = useState(value); 60 | 61 | return ( 62 | 63 | 64 | { 66 | const ret = changeRef.current.slice(); 67 | if (ret.length) { 68 | onChange(ret); 69 | changeRef.current = []; 70 | } 71 | }} 72 | onKeyDown={(e) => { 73 | const evt = e.nativeEvent; 74 | e.preventDefault(); 75 | e.stopPropagation(); 76 | 77 | const key = parseHotkey(evt.key); 78 | if (key === "UNIDENTIFIED") return; 79 | 80 | changeRef.current = [...new Set([...changeRef.current, key])]; 81 | setKeys(changeRef.current); 82 | }} 83 | /> 84 | 85 |
86 | {keys.map((key) => ( 87 |
88 | {key} 89 |
90 | ))} 91 |
92 |
93 | 94 | { 99 | onChange([]); 100 | setKeys([]); 101 | }} 102 | > 103 | 104 | 105 |
106 | ); 107 | }; 108 | -------------------------------------------------------------------------------- /src/components/setting/mods/hotkey-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useImperativeHandle, useState } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { useLockFn } from "ahooks"; 4 | import { styled, Typography } from "@mui/material"; 5 | import { useVerge } from "@/hooks/use-verge"; 6 | import { BaseDialog, DialogRef, Notice } from "@/components/base"; 7 | import { HotkeyInput } from "./hotkey-input"; 8 | 9 | const ItemWrapper = styled("div")` 10 | display: flex; 11 | align-items: center; 12 | justify-content: space-between; 13 | margin-bottom: 8px; 14 | `; 15 | 16 | const HOTKEY_FUNC = [ 17 | "open_dashboard", 18 | "clash_mode_rule", 19 | "clash_mode_global", 20 | "clash_mode_direct", 21 | "clash_mode_script", 22 | "toggle_system_proxy", 23 | "enable_system_proxy", 24 | "disable_system_proxy", 25 | "toggle_tun_mode", 26 | "enable_tun_mode", 27 | "disable_tun_mode", 28 | ]; 29 | 30 | export const HotkeyViewer = forwardRef((props, ref) => { 31 | const { t } = useTranslation(); 32 | const [open, setOpen] = useState(false); 33 | 34 | const { verge, patchVerge } = useVerge(); 35 | 36 | const [hotkeyMap, setHotkeyMap] = useState>({}); 37 | 38 | useImperativeHandle(ref, () => ({ 39 | open: () => { 40 | setOpen(true); 41 | 42 | const map = {} as typeof hotkeyMap; 43 | 44 | verge?.hotkeys?.forEach((text) => { 45 | const [func, key] = text.split(",").map((e) => e.trim()); 46 | 47 | if (!func || !key) return; 48 | 49 | map[func] = key 50 | .split("+") 51 | .map((e) => e.trim()) 52 | .map((k) => (k === "PLUS" ? "+" : k)); 53 | }); 54 | 55 | setHotkeyMap(map); 56 | }, 57 | close: () => setOpen(false), 58 | })); 59 | 60 | const onSave = useLockFn(async () => { 61 | const hotkeys = Object.entries(hotkeyMap) 62 | .map(([func, keys]) => { 63 | if (!func || !keys?.length) return ""; 64 | 65 | const key = keys 66 | .map((k) => k.trim()) 67 | .filter(Boolean) 68 | .map((k) => (k === "+" ? "PLUS" : k)) 69 | .join("+"); 70 | 71 | if (!key) return ""; 72 | return `${func},${key}`; 73 | }) 74 | .filter(Boolean); 75 | 76 | try { 77 | await patchVerge({ hotkeys }); 78 | setOpen(false); 79 | } catch (err: any) { 80 | Notice.error(err.message || err.toString()); 81 | } 82 | }); 83 | 84 | return ( 85 | setOpen(false)} 92 | onCancel={() => setOpen(false)} 93 | onOk={onSave} 94 | > 95 | {HOTKEY_FUNC.map((func) => ( 96 | 97 | {t(func)} 98 | setHotkeyMap((m) => ({ ...m, [func]: v }))} 101 | /> 102 | 103 | ))} 104 | 105 | ); 106 | }); 107 | -------------------------------------------------------------------------------- /src/components/setting/mods/layout-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useImperativeHandle, useState } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { List, Switch } from "@mui/material"; 4 | import { useVerge } from "@/hooks/use-verge"; 5 | import { BaseDialog, DialogRef, Notice } from "@/components/base"; 6 | import { SettingItem } from "./setting-comp"; 7 | import { GuardState } from "./guard-state"; 8 | 9 | export const LayoutViewer = forwardRef((props, ref) => { 10 | const { t } = useTranslation(); 11 | const { verge, patchVerge, mutateVerge } = useVerge(); 12 | 13 | const [open, setOpen] = useState(false); 14 | 15 | useImperativeHandle(ref, () => ({ 16 | open: () => setOpen(true), 17 | close: () => setOpen(false), 18 | })); 19 | 20 | const onSwitchFormat = (_e: any, value: boolean) => value; 21 | const onError = (err: any) => { 22 | Notice.error(err.message || err.toString()); 23 | }; 24 | const onChangeData = (patch: Partial) => { 25 | mutateVerge({ ...verge, ...patch }, false); 26 | }; 27 | 28 | return ( 29 | setOpen(false)} 36 | onCancel={() => setOpen(false)} 37 | > 38 | 39 | 40 | onChangeData({ traffic_graph: e })} 46 | onGuard={(e) => patchVerge({ traffic_graph: e })} 47 | > 48 | 49 | 50 | 51 | 52 | 53 | onChangeData({ enable_memory_usage: e })} 59 | onGuard={(e) => patchVerge({ enable_memory_usage: e })} 60 | > 61 | 62 | 63 | 64 | 65 | 66 | ); 67 | }); 68 | -------------------------------------------------------------------------------- /src/components/setting/mods/service-viewer.tsx: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import { forwardRef, useImperativeHandle, useState } from "react"; 3 | import { useLockFn } from "ahooks"; 4 | import { useTranslation } from "react-i18next"; 5 | import { Button, Stack, Typography } from "@mui/material"; 6 | import { 7 | checkService, 8 | installService, 9 | uninstallService, 10 | patchVergeConfig, 11 | } from "@/services/cmds"; 12 | import { BaseDialog, DialogRef, Notice } from "@/components/base"; 13 | 14 | interface Props { 15 | enable: boolean; 16 | } 17 | 18 | export const ServiceViewer = forwardRef((props, ref) => { 19 | const { enable } = props; 20 | 21 | const { t } = useTranslation(); 22 | const [open, setOpen] = useState(false); 23 | 24 | const { data: status, mutate: mutateCheck } = useSWR( 25 | "checkService", 26 | checkService, 27 | { 28 | revalidateIfStale: false, 29 | shouldRetryOnError: false, 30 | focusThrottleInterval: 36e5, // 1 hour 31 | } 32 | ); 33 | 34 | useImperativeHandle(ref, () => ({ 35 | open: () => setOpen(true), 36 | close: () => setOpen(false), 37 | })); 38 | 39 | const state = status != null ? status : "pending"; 40 | 41 | const onInstall = useLockFn(async () => { 42 | try { 43 | await installService(); 44 | mutateCheck(); 45 | setOpen(false); 46 | Notice.success("Service installed successfully"); 47 | } catch (err: any) { 48 | mutateCheck(); 49 | Notice.error(err.message || err.toString()); 50 | } 51 | }); 52 | 53 | const onUninstall = useLockFn(async () => { 54 | try { 55 | if (enable) { 56 | await patchVergeConfig({ enable_service_mode: false }); 57 | } 58 | 59 | await uninstallService(); 60 | mutateCheck(); 61 | setOpen(false); 62 | Notice.success("Service uninstalled successfully"); 63 | } catch (err: any) { 64 | mutateCheck(); 65 | Notice.error(err.message || err.toString()); 66 | } 67 | }); 68 | 69 | // fix unhandled error of the service mode 70 | const onDisable = useLockFn(async () => { 71 | try { 72 | await patchVergeConfig({ enable_service_mode: false }); 73 | mutateCheck(); 74 | setOpen(false); 75 | } catch (err: any) { 76 | mutateCheck(); 77 | Notice.error(err.message || err.toString()); 78 | } 79 | }); 80 | 81 | return ( 82 | setOpen(false)} 88 | > 89 | Current State: {state} 90 | 91 | {(state === "unknown" || state === "uninstall") && ( 92 | 93 | Information: Please make sure that the Clash Verge Service is 94 | installed and enabled 95 | 96 | )} 97 | 98 | 103 | {state === "uninstall" && enable && ( 104 | 107 | )} 108 | 109 | {state === "uninstall" && ( 110 | 113 | )} 114 | 115 | {(state === "active" || state === "installed") && ( 116 | 119 | )} 120 | 121 | 122 | ); 123 | }); 124 | -------------------------------------------------------------------------------- /src/components/setting/mods/setting-comp.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import { 3 | Box, 4 | List, 5 | ListItem, 6 | ListItemText, 7 | ListSubheader, 8 | } from "@mui/material"; 9 | 10 | interface ItemProps { 11 | label: ReactNode; 12 | extra?: ReactNode; 13 | children?: ReactNode; 14 | secondary?: ReactNode; 15 | } 16 | 17 | export const SettingItem: React.FC = (props) => { 18 | const { label, extra, children, secondary } = props; 19 | 20 | const primary = !extra ? ( 21 | label 22 | ) : ( 23 | 24 | {label} 25 | {extra} 26 | 27 | ); 28 | 29 | return ( 30 | 31 | 32 | {children} 33 | 34 | ); 35 | }; 36 | 37 | export const SettingList: React.FC<{ 38 | title: string; 39 | children: ReactNode; 40 | }> = (props) => ( 41 | 42 | 43 | {props.title} 44 | 45 | 46 | {props.children} 47 | 48 | ); 49 | -------------------------------------------------------------------------------- /src/components/setting/mods/theme-mode-switch.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Button, ButtonGroup } from "@mui/material"; 3 | 4 | type ThemeValue = IVergeConfig["theme_mode"]; 5 | 6 | interface Props { 7 | value?: ThemeValue; 8 | onChange?: (value: ThemeValue) => void; 9 | } 10 | 11 | export const ThemeModeSwitch = (props: Props) => { 12 | const { value, onChange } = props; 13 | const { t } = useTranslation(); 14 | 15 | const modes = ["light", "dark", "system"] as const; 16 | 17 | return ( 18 | 19 | {modes.map((mode) => ( 20 | 28 | ))} 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/setting/mods/theme-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useImperativeHandle, useState } from "react"; 2 | import { useLockFn } from "ahooks"; 3 | import { useTranslation } from "react-i18next"; 4 | import { 5 | List, 6 | ListItem, 7 | ListItemText, 8 | styled, 9 | TextField, 10 | useTheme, 11 | } from "@mui/material"; 12 | import { useVerge } from "@/hooks/use-verge"; 13 | import { defaultTheme, defaultDarkTheme } from "@/pages/_theme"; 14 | import { BaseDialog, DialogRef, Notice } from "@/components/base"; 15 | 16 | export const ThemeViewer = forwardRef((props, ref) => { 17 | const { t } = useTranslation(); 18 | 19 | const [open, setOpen] = useState(false); 20 | const { verge, patchVerge } = useVerge(); 21 | const { theme_setting } = verge ?? {}; 22 | const [theme, setTheme] = useState(theme_setting || {}); 23 | 24 | useImperativeHandle(ref, () => ({ 25 | open: () => { 26 | setOpen(true); 27 | setTheme({ ...theme_setting } || {}); 28 | }, 29 | close: () => setOpen(false), 30 | })); 31 | 32 | const textProps = { 33 | size: "small", 34 | autoComplete: "off", 35 | sx: { width: 135 }, 36 | } as const; 37 | 38 | const handleChange = (field: keyof typeof theme) => (e: any) => { 39 | setTheme((t) => ({ ...t, [field]: e.target.value })); 40 | }; 41 | 42 | const onSave = useLockFn(async () => { 43 | try { 44 | await patchVerge({ theme_setting: theme }); 45 | setOpen(false); 46 | } catch (err: any) { 47 | Notice.error(err.message || err.toString()); 48 | } 49 | }); 50 | 51 | // default theme 52 | const { palette } = useTheme(); 53 | 54 | const dt = palette.mode === "light" ? defaultTheme : defaultDarkTheme; 55 | 56 | type ThemeKey = keyof typeof theme & keyof typeof defaultTheme; 57 | 58 | const renderItem = (label: string, key: ThemeKey) => { 59 | return ( 60 | 61 | 62 | 63 | e.key === "Enter" && onSave()} 69 | /> 70 | 71 | ); 72 | }; 73 | 74 | return ( 75 | setOpen(false)} 82 | onCancel={() => setOpen(false)} 83 | onOk={onSave} 84 | > 85 | 86 | {renderItem("Primary Color", "primary_color")} 87 | 88 | {renderItem("Secondary Color", "secondary_color")} 89 | 90 | {renderItem("Primary Text", "primary_text")} 91 | 92 | {renderItem("Secondary Text", "secondary_text")} 93 | 94 | {renderItem("Info Color", "info_color")} 95 | 96 | {renderItem("Error Color", "error_color")} 97 | 98 | {renderItem("Warning Color", "warning_color")} 99 | 100 | {renderItem("Success Color", "success_color")} 101 | 102 | 103 | 104 | e.key === "Enter" && onSave()} 109 | /> 110 | 111 | 112 | 113 | 114 | e.key === "Enter" && onSave()} 119 | /> 120 | 121 | 122 | 123 | ); 124 | }); 125 | 126 | const Item = styled(ListItem)(() => ({ 127 | padding: "5px 2px", 128 | })); 129 | 130 | const Round = styled("div")(() => ({ 131 | width: "24px", 132 | height: "24px", 133 | borderRadius: "18px", 134 | display: "inline-block", 135 | marginRight: "8px", 136 | })); 137 | -------------------------------------------------------------------------------- /src/components/setting/mods/update-viewer.tsx: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import snarkdown from "snarkdown"; 3 | import { forwardRef, useImperativeHandle, useState, useMemo } from "react"; 4 | import { useLockFn } from "ahooks"; 5 | import { Box, LinearProgress, styled } from "@mui/material"; 6 | import { useRecoilState } from "recoil"; 7 | import { useTranslation } from "react-i18next"; 8 | import { relaunch } from "@tauri-apps/api/process"; 9 | import { checkUpdate, installUpdate } from "@tauri-apps/api/updater"; 10 | import { BaseDialog, DialogRef, Notice } from "@/components/base"; 11 | import { atomUpdateState } from "@/services/states"; 12 | import { listen, Event, UnlistenFn } from "@tauri-apps/api/event"; 13 | import { portableFlag } from "@/pages/_layout"; 14 | 15 | const UpdateLog = styled(Box)(() => ({ 16 | "h1,h2,h3,ul,ol,p": { margin: "0.5em 0", color: "inherit" }, 17 | })); 18 | let eventListener: UnlistenFn | null = null; 19 | 20 | export const UpdateViewer = forwardRef((props, ref) => { 21 | const { t } = useTranslation(); 22 | 23 | const [open, setOpen] = useState(false); 24 | const [updateState, setUpdateState] = useRecoilState(atomUpdateState); 25 | 26 | const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, { 27 | errorRetryCount: 2, 28 | revalidateIfStale: false, 29 | focusThrottleInterval: 36e5, // 1 hour 30 | }); 31 | 32 | const [downloaded, setDownloaded] = useState(0); 33 | const [buffer, setBuffer] = useState(0); 34 | const [total, setTotal] = useState(0); 35 | 36 | useImperativeHandle(ref, () => ({ 37 | open: () => setOpen(true), 38 | close: () => setOpen(false), 39 | })); 40 | 41 | // markdown parser 42 | const parseContent = useMemo(() => { 43 | if (!updateInfo?.manifest?.body) { 44 | return "New Version is available"; 45 | } 46 | return snarkdown(updateInfo?.manifest?.body); 47 | }, [updateInfo]); 48 | 49 | const onUpdate = useLockFn(async () => { 50 | if (portableFlag) { 51 | Notice.error(t("Portable Updater Error")); 52 | return; 53 | } 54 | if (updateState) return; 55 | setUpdateState(true); 56 | if (eventListener !== null) { 57 | eventListener(); 58 | } 59 | eventListener = await listen( 60 | "tauri://update-download-progress", 61 | (e: Event) => { 62 | setTotal(e.payload.contentLength); 63 | setBuffer(e.payload.chunkLength); 64 | setDownloaded((a) => { 65 | return a + e.payload.chunkLength; 66 | }); 67 | } 68 | ); 69 | try { 70 | await installUpdate(); 71 | await relaunch(); 72 | } catch (err: any) { 73 | Notice.error(err?.message || err.toString()); 74 | } finally { 75 | setUpdateState(false); 76 | } 77 | }); 78 | 79 | return ( 80 | setOpen(false)} 87 | onCancel={() => setOpen(false)} 88 | onOk={onUpdate} 89 | > 90 | 94 | {updateState && ( 95 | 101 | )} 102 | 103 | ); 104 | }); 105 | -------------------------------------------------------------------------------- /src/components/setting/mods/web-ui-item.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { 3 | Divider, 4 | IconButton, 5 | Stack, 6 | TextField, 7 | Typography, 8 | } from "@mui/material"; 9 | import { 10 | CheckRounded, 11 | CloseRounded, 12 | DeleteRounded, 13 | EditRounded, 14 | OpenInNewRounded, 15 | } from "@mui/icons-material"; 16 | 17 | interface Props { 18 | value?: string; 19 | onlyEdit?: boolean; 20 | onChange: (value?: string) => void; 21 | onOpenUrl?: (value?: string) => void; 22 | onDelete?: () => void; 23 | onCancel?: () => void; 24 | } 25 | 26 | export const WebUIItem = (props: Props) => { 27 | const { 28 | value, 29 | onlyEdit = false, 30 | onChange, 31 | onDelete, 32 | onOpenUrl, 33 | onCancel, 34 | } = props; 35 | 36 | const [editing, setEditing] = useState(false); 37 | const [editValue, setEditValue] = useState(value); 38 | 39 | if (editing || onlyEdit) { 40 | return ( 41 | <> 42 | 43 | setEditValue(e.target.value)} 48 | placeholder={`Support %host %port %secret`} 49 | autoComplete="off" 50 | /> 51 | { 56 | onChange(editValue); 57 | setEditing(false); 58 | }} 59 | > 60 | 61 | 62 | { 67 | onCancel?.(); 68 | setEditing(false); 69 | }} 70 | > 71 | 72 | 73 | 74 | 75 | 76 | ); 77 | } 78 | 79 | const html = value 80 | ?.replace("%host", "%host") 81 | .replace("%port", "%port") 82 | .replace("%secret", "%secret"); 83 | 84 | return ( 85 | <> 86 | 87 | ({ 93 | "> span": { 94 | color: palette.primary.main, 95 | }, 96 | })} 97 | dangerouslySetInnerHTML={{ __html: html || "NULL" }} 98 | /> 99 | onOpenUrl?.(value)} 104 | > 105 | 106 | 107 | { 112 | setEditing(true); 113 | setEditValue(value); 114 | }} 115 | > 116 | 117 | 118 | 124 | 125 | 126 | 127 | 128 | 129 | ); 130 | }; 131 | -------------------------------------------------------------------------------- /src/components/setting/mods/web-ui-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useImperativeHandle, useState } from "react"; 2 | import { useLockFn } from "ahooks"; 3 | import { useTranslation } from "react-i18next"; 4 | import { Button, Box, Typography } from "@mui/material"; 5 | import { useVerge } from "@/hooks/use-verge"; 6 | import { openWebUrl } from "@/services/cmds"; 7 | import { BaseDialog, BaseEmpty, DialogRef, Notice } from "@/components/base"; 8 | import { useClashInfo } from "@/hooks/use-clash"; 9 | import { WebUIItem } from "./web-ui-item"; 10 | 11 | export const WebUIViewer = forwardRef((props, ref) => { 12 | const { t } = useTranslation(); 13 | 14 | const { clashInfo } = useClashInfo(); 15 | const { verge, patchVerge, mutateVerge } = useVerge(); 16 | 17 | const [open, setOpen] = useState(false); 18 | const [editing, setEditing] = useState(false); 19 | 20 | useImperativeHandle(ref, () => ({ 21 | open: () => setOpen(true), 22 | close: () => setOpen(false), 23 | })); 24 | 25 | const webUIList = verge?.web_ui_list || []; 26 | 27 | const handleAdd = useLockFn(async (value: string) => { 28 | const newList = [value, ...webUIList]; 29 | mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false); 30 | await patchVerge({ web_ui_list: newList }); 31 | }); 32 | 33 | const handleChange = useLockFn(async (index: number, value?: string) => { 34 | const newList = [...webUIList]; 35 | newList[index] = value ?? ""; 36 | mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false); 37 | await patchVerge({ web_ui_list: newList }); 38 | }); 39 | 40 | const handleDelete = useLockFn(async (index: number) => { 41 | const newList = [...webUIList]; 42 | newList.splice(index, 1); 43 | mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false); 44 | await patchVerge({ web_ui_list: newList }); 45 | }); 46 | 47 | const handleOpenUrl = useLockFn(async (value?: string) => { 48 | if (!value) return; 49 | try { 50 | let url = value.trim().replaceAll("%host", "127.0.0.1"); 51 | 52 | if (url.includes("%port") || url.includes("%secret")) { 53 | if (!clashInfo) throw new Error("failed to get clash info"); 54 | if (!clashInfo.server?.includes(":")) { 55 | throw new Error(`failed to parse the server "${clashInfo.server}"`); 56 | } 57 | 58 | const port = clashInfo.server 59 | .slice(clashInfo.server.indexOf(":") + 1) 60 | .trim(); 61 | 62 | url = url.replaceAll("%port", port || "9097"); 63 | url = url.replaceAll( 64 | "%secret", 65 | encodeURIComponent(clashInfo.secret || "") 66 | ); 67 | } 68 | 69 | await openWebUrl(url); 70 | } catch (e: any) { 71 | Notice.error(e.message || e.toString()); 72 | } 73 | }); 74 | 75 | return ( 76 | 80 | {t("Web UI")} 81 | 89 | 90 | } 91 | contentSx={{ 92 | width: 450, 93 | height: 300, 94 | pb: 1, 95 | overflowY: "auto", 96 | userSelect: "text", 97 | }} 98 | cancelBtn={t("Back")} 99 | disableOk 100 | onClose={() => setOpen(false)} 101 | onCancel={() => setOpen(false)} 102 | > 103 | {editing && ( 104 | { 108 | setEditing(false); 109 | handleAdd(v || ""); 110 | }} 111 | onCancel={() => setEditing(false)} 112 | /> 113 | )} 114 | 115 | {!editing && webUIList.length === 0 && ( 116 | 120 | Replace host, port, secret with "%host" "%port" "%secret" 121 | 122 | } 123 | /> 124 | )} 125 | 126 | {webUIList.map((item, index) => ( 127 | handleChange(index, v)} 131 | onDelete={() => handleDelete(index)} 132 | onOpenUrl={handleOpenUrl} 133 | /> 134 | ))} 135 | 136 | ); 137 | }); 138 | -------------------------------------------------------------------------------- /src/hooks/use-clash.ts: -------------------------------------------------------------------------------- 1 | import useSWR, { mutate } from "swr"; 2 | import { useLockFn } from "ahooks"; 3 | import { 4 | getAxios, 5 | getClashConfig, 6 | getVersion, 7 | updateConfigs, 8 | } from "@/services/api"; 9 | import { getClashInfo, patchClashConfig } from "@/services/cmds"; 10 | 11 | export const useClash = () => { 12 | const { data: clash, mutate: mutateClash } = useSWR( 13 | "getClashConfig", 14 | getClashConfig 15 | ); 16 | 17 | const { data: versionData, mutate: mutateVersion } = useSWR( 18 | "getVersion", 19 | getVersion 20 | ); 21 | 22 | const patchClash = useLockFn(async (patch: Partial) => { 23 | await updateConfigs(patch); 24 | await patchClashConfig(patch); 25 | mutateClash(); 26 | }); 27 | 28 | const version = versionData?.premium 29 | ? `${versionData.version} Premium` 30 | : versionData?.meta 31 | ? `${versionData.version} Meta` 32 | : versionData?.version || "-"; 33 | 34 | return { 35 | clash, 36 | version, 37 | mutateClash, 38 | mutateVersion, 39 | patchClash, 40 | }; 41 | }; 42 | 43 | export const useClashInfo = () => { 44 | const { data: clashInfo, mutate: mutateInfo } = useSWR( 45 | "getClashInfo", 46 | getClashInfo 47 | ); 48 | 49 | const patchInfo = async ( 50 | patch: Partial< 51 | Pick 52 | > 53 | ) => { 54 | const hasInfo = 55 | patch["mixed-port"] != null || 56 | patch["external-controller"] != null || 57 | patch.secret != null; 58 | 59 | if (!hasInfo) return; 60 | 61 | if (patch["mixed-port"]) { 62 | const port = patch["mixed-port"]; 63 | if (port < 1000) { 64 | throw new Error("The port should not < 1000"); 65 | } 66 | if (port > 65536) { 67 | throw new Error("The port should not > 65536"); 68 | } 69 | } 70 | 71 | await patchClashConfig(patch); 72 | mutateInfo(); 73 | mutate("getClashConfig"); 74 | // 刷新接口 75 | getAxios(true); 76 | }; 77 | 78 | return { 79 | clashInfo, 80 | mutateInfo, 81 | patchInfo, 82 | }; 83 | }; 84 | -------------------------------------------------------------------------------- /src/hooks/use-profiles.ts: -------------------------------------------------------------------------------- 1 | import useSWR, { mutate } from "swr"; 2 | import { 3 | getProfiles, 4 | patchProfile, 5 | patchProfilesConfig, 6 | } from "@/services/cmds"; 7 | import { getProxies, updateProxy } from "@/services/api"; 8 | 9 | export const useProfiles = () => { 10 | const { data: profiles, mutate: mutateProfiles } = useSWR( 11 | "getProfiles", 12 | getProfiles 13 | ); 14 | 15 | const patchProfiles = async (value: Partial) => { 16 | await patchProfilesConfig(value); 17 | mutateProfiles(); 18 | }; 19 | 20 | const patchCurrent = async (value: Partial) => { 21 | if (profiles?.current) { 22 | await patchProfile(profiles.current, value); 23 | mutateProfiles(); 24 | } 25 | }; 26 | 27 | // 根据selected的节点选择 28 | const activateSelected = async () => { 29 | const proxiesData = await getProxies(); 30 | const profileData = await getProfiles(); 31 | 32 | if (!profileData || !proxiesData) return; 33 | 34 | const current = profileData.items?.find( 35 | (e) => e && e.uid === profileData.current 36 | ); 37 | 38 | if (!current) return; 39 | 40 | // init selected array 41 | const { selected = [] } = current; 42 | const selectedMap = Object.fromEntries( 43 | selected.map((each) => [each.name!, each.now!]) 44 | ); 45 | 46 | let hasChange = false; 47 | 48 | const newSelected: typeof selected = []; 49 | const { global, groups } = proxiesData; 50 | 51 | [global, ...groups].forEach(({ type, name, now }) => { 52 | if (!now || type !== "Selector") return; 53 | if (selectedMap[name] != null && selectedMap[name] !== now) { 54 | hasChange = true; 55 | updateProxy(name, selectedMap[name]); 56 | } 57 | newSelected.push({ name, now: selectedMap[name] }); 58 | }); 59 | 60 | if (hasChange) { 61 | patchProfile(profileData.current!, { selected: newSelected }); 62 | mutate("getProxies", getProxies()); 63 | } 64 | }; 65 | 66 | return { 67 | profiles, 68 | current: profiles?.items?.find((p) => p && p.uid === profiles.current), 69 | activateSelected, 70 | patchProfiles, 71 | patchCurrent, 72 | mutateProfiles, 73 | }; 74 | }; 75 | -------------------------------------------------------------------------------- /src/hooks/use-verge.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import { getVergeConfig, patchVergeConfig } from "@/services/cmds"; 3 | 4 | export const useVerge = () => { 5 | const { data: verge, mutate: mutateVerge } = useSWR( 6 | "getVergeConfig", 7 | getVergeConfig 8 | ); 9 | 10 | const patchVerge = async (value: Partial) => { 11 | await patchVergeConfig(value); 12 | mutateVerge(); 13 | }; 14 | 15 | return { 16 | verge, 17 | mutateVerge, 18 | patchVerge, 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/hooks/use-visibility.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useVisibility = () => { 4 | const [visible, setVisible] = useState(true); 5 | 6 | useEffect(() => { 7 | const handleVisibilityChange = () => { 8 | setVisible(document.visibilityState === "visible"); 9 | }; 10 | 11 | const handleFocus = () => setVisible(true); 12 | const handleClick = () => setVisible(true); 13 | 14 | handleVisibilityChange(); 15 | document.addEventListener("focus", handleFocus); 16 | document.addEventListener("pointerdown", handleClick); 17 | document.addEventListener("visibilitychange", handleVisibilityChange); 18 | 19 | return () => { 20 | document.removeEventListener("focus", handleFocus); 21 | document.removeEventListener("pointerdown", handleClick); 22 | document.removeEventListener("visibilitychange", handleVisibilityChange); 23 | }; 24 | }, []); 25 | 26 | return visible; 27 | }; 28 | -------------------------------------------------------------------------------- /src/hooks/use-websocket.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | 3 | export type WsMsgFn = (event: MessageEvent) => void; 4 | 5 | export interface WsOptions { 6 | errorCount?: number; // default is 5 7 | retryInterval?: number; // default is 2500 8 | onError?: () => void; 9 | } 10 | 11 | export const useWebsocket = (onMessage: WsMsgFn, options?: WsOptions) => { 12 | const wsRef = useRef(null); 13 | const timerRef = useRef(null); 14 | 15 | const disconnect = () => { 16 | if (wsRef.current) { 17 | wsRef.current.close(); 18 | wsRef.current = null; 19 | } 20 | if (timerRef.current) { 21 | clearTimeout(timerRef.current); 22 | } 23 | }; 24 | 25 | const connect = (url: string) => { 26 | let errorCount = options?.errorCount ?? 5; 27 | 28 | if (!url) return; 29 | 30 | const connectHelper = () => { 31 | disconnect(); 32 | 33 | const ws = new WebSocket(url); 34 | wsRef.current = ws; 35 | 36 | ws.addEventListener("message", onMessage); 37 | ws.addEventListener("error", () => { 38 | errorCount -= 1; 39 | 40 | if (errorCount >= 0) { 41 | timerRef.current = setTimeout(connectHelper, 2500); 42 | } else { 43 | disconnect(); 44 | options?.onError?.(); 45 | } 46 | }); 47 | }; 48 | 49 | connectHelper(); 50 | }; 51 | 52 | return { connect, disconnect }; 53 | }; 54 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | Clash Verge 12 | 33 | 34 | 35 |
36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/locales/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "Label-Proxies": "代 理", 3 | "Label-Profiles": "订 阅", 4 | "Label-Connections": "连 接", 5 | "Label-Logs": "日 志", 6 | "Label-Rules": "规 则", 7 | "Label-Settings": "设 置", 8 | 9 | "Connections": "连接", 10 | "Logs": "日志", 11 | "Clear": "清除", 12 | "Proxies": "代理", 13 | "Proxy Groups": "代理组", 14 | "rule": "规则", 15 | "global": "全局", 16 | "direct": "直连", 17 | "script": "脚本", 18 | 19 | "Profiles": "订阅", 20 | "Profile URL": "订阅文件链接", 21 | "Import": "导入", 22 | "New": "新建", 23 | "Create Profile": "新建订阅", 24 | "Choose File": "选择文件", 25 | "Close All": "关闭全部", 26 | "Select": "使用", 27 | "Edit Info": "编辑信息", 28 | "Edit File": "编辑文件", 29 | "Open File": "打开文件", 30 | "Update": "更新", 31 | "Update(Proxy)": "更新(代理)", 32 | "Delete": "删除", 33 | "Enable": "启用", 34 | "Disable": "禁用", 35 | "Refresh": "刷新", 36 | "To Top": "移到最前", 37 | "To End": "移到末尾", 38 | "Update All Profiles": "更新所有订阅", 39 | "View Runtime Config": "查看运行时订阅", 40 | "Reactivate Profiles": "重新激活订阅", 41 | 42 | "Location": "当前节点", 43 | "Delay check": "延迟测试", 44 | "Sort by default": "默认排序", 45 | "Sort by delay": "按延迟排序", 46 | "Sort by name": "按名称排序", 47 | "Delay check URL": "延迟测试链接", 48 | "Proxy detail": "展示节点细节", 49 | "Filter": "过滤节点", 50 | "Filter conditions": "过滤条件", 51 | "Refresh profiles": "刷新订阅", 52 | "Rules": "规则", 53 | 54 | "Type": "类型", 55 | "Name": "名称", 56 | "Descriptions": "描述", 57 | "Subscription URL": "订阅链接", 58 | "Update Interval": "更新间隔", 59 | "Use System Proxy": "使用系统代理更新", 60 | "Use Clash Proxy": "使用Clash代理更新", 61 | 62 | "Settings": "设置", 63 | "Clash Setting": "Clash 设置", 64 | "System Setting": "系统设置", 65 | "Verge Setting": "Verge 设置", 66 | "Allow Lan": "局域网连接", 67 | "IPv6": "IPv6", 68 | "Log Level": "日志等级", 69 | "Mixed Port": "端口设置", 70 | "Random Port": "随机端口", 71 | "After restart to take effect": "重启后生效", 72 | "External": "外部控制", 73 | "Clash Core": "Clash 内核", 74 | "Grant": "授权", 75 | "Tun mode requires": "如需启用TUN模式需要授权", 76 | "Tun Mode": "Tun 模式", 77 | "Service Mode": "服务模式", 78 | "Auto Launch": "开机自启", 79 | "Silent Start": "静默启动", 80 | "System Proxy": "系统代理", 81 | "System Proxy Setting": "系统代理设置", 82 | "Open UWP tool": "UWP工具", 83 | "Update GeoData": "更新 GeoData", 84 | "Proxy Guard": "系统代理守卫", 85 | "Guard Duration": "代理守卫间隔", 86 | "Proxy Bypass": "代理绕过", 87 | "Use Registry": "使用注册表", 88 | "Current System Proxy": "当前系统代理", 89 | "Enable status": "开启状态:", 90 | "Server Addr": "服务地址:", 91 | "Bypass": "当前绕过:", 92 | "Theme Mode": "主题模式", 93 | "Tray Click Event": "托盘点击事件", 94 | "Copy Env Type": "复制环境变量类型", 95 | "Show Main Window": "显示主窗口", 96 | "Theme Setting": "主题设置", 97 | "Layout Setting": "界面设置", 98 | "Miscellaneous": "杂项设置", 99 | "Hotkey Setting": "热键设置", 100 | "Traffic Graph": "流量图显", 101 | "Memory Usage": "内存使用", 102 | "Language": "语言设置", 103 | "Open App Dir": "应用目录", 104 | "Open Core Dir": "内核目录", 105 | "Open Logs Dir": "日志目录", 106 | "Check for Updates": "检查更新", 107 | "Verge Version": "Verge 版本", 108 | "theme.light": "浅色", 109 | "theme.dark": "深色", 110 | "theme.system": "系统", 111 | "Clash Field": "Clash 字段", 112 | "Runtime Config": "当前配置", 113 | "ReadOnly": "只读", 114 | "Restart": "重启内核", 115 | "Upgrade": "升级内核", 116 | 117 | "Back": "返回", 118 | "Save": "保存", 119 | "Cancel": "取消", 120 | 121 | "Default": "默认", 122 | "Download Speed": "下载速度", 123 | "Upload Speed": "上传速度", 124 | 125 | "open_dashboard": "打开面板", 126 | "clash_mode_rule": "规则模式", 127 | "clash_mode_global": "全局模式", 128 | "clash_mode_direct": "直连模式", 129 | "clash_mode_script": "脚本模式", 130 | "toggle_system_proxy": "切换系统代理", 131 | "enable_system_proxy": "开启系统代理", 132 | "disable_system_proxy": "关闭系统代理", 133 | "toggle_tun_mode": "切换Tun模式", 134 | "enable_tun_mode": "开启Tun模式", 135 | "disable_tun_mode": "关闭Tun模式", 136 | 137 | "App Log Level": "App日志等级", 138 | "Auto Close Connections": "自动关闭连接", 139 | "Enable Clash Fields Filter": "开启Clash字段过滤", 140 | "Enable Builtin Enhanced": "开启内建增强功能", 141 | "Proxy Layout Column": "代理页布局列数", 142 | "Default Latency Test": "默认测试链接", 143 | 144 | "Auto Log Clean": "自动清理日志", 145 | "Never Clean": "不清理", 146 | "Retain 7 Days": "保留7天", 147 | "Retain 30 Days": "保留30天", 148 | "Retain 90 Days": "保留90天", 149 | 150 | "Portable Updater Error": "便携版不支持应用内更新,请手动下载替换", 151 | "Please disable the system proxy": "请先关闭系统代理", 152 | "Using the registry instead of Windows API": "使用注册表替代Windows API" 153 | } 154 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | import "./assets/styles/index.scss"; 4 | 5 | import { ResizeObserver } from "@juggle/resize-observer"; 6 | if (!window.ResizeObserver) { 7 | window.ResizeObserver = ResizeObserver; 8 | } 9 | 10 | import React from "react"; 11 | import { createRoot } from "react-dom/client"; 12 | import { RecoilRoot } from "recoil"; 13 | import { BrowserRouter } from "react-router-dom"; 14 | import { BaseErrorBoundary } from "./components/base"; 15 | import Layout from "./pages/_layout"; 16 | import "./services/i18n"; 17 | 18 | const mainElementId = "root"; 19 | const container = document.getElementById(mainElementId); 20 | 21 | if (!container) { 22 | throw new Error( 23 | `No container '${mainElementId}' found to render application` 24 | ); 25 | } 26 | 27 | createRoot(container).render( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | -------------------------------------------------------------------------------- /src/pages/_routers.tsx: -------------------------------------------------------------------------------- 1 | import LogsPage from "./logs"; 2 | import ProxiesPage from "./proxies"; 3 | import ProfilesPage from "./profiles"; 4 | import SettingsPage from "./settings"; 5 | import ConnectionsPage from "./connections"; 6 | import RulesPage from "./rules"; 7 | 8 | export const routers = [ 9 | { 10 | label: "Label-Proxies", 11 | link: "/", 12 | ele: ProxiesPage, 13 | }, 14 | { 15 | label: "Label-Profiles", 16 | link: "/profile", 17 | ele: ProfilesPage, 18 | }, 19 | { 20 | label: "Label-Connections", 21 | link: "/connections", 22 | ele: ConnectionsPage, 23 | }, 24 | { 25 | label: "Label-Rules", 26 | link: "/rules", 27 | ele: RulesPage, 28 | }, 29 | { 30 | label: "Label-Logs", 31 | link: "/logs", 32 | ele: LogsPage, 33 | }, 34 | { 35 | label: "Label-Settings", 36 | link: "/settings", 37 | ele: SettingsPage, 38 | }, 39 | ]; 40 | -------------------------------------------------------------------------------- /src/pages/_theme.tsx: -------------------------------------------------------------------------------- 1 | import getSystem from "@/utils/get-system"; 2 | const OS = getSystem(); 3 | 4 | // default theme setting 5 | export const defaultTheme = { 6 | primary_color: "#1867c0", 7 | secondary_color: "#3a88bb", 8 | primary_text: "#1d1d1f", 9 | secondary_text: "#424245", 10 | info_color: "#0288d1", 11 | error_color: "#d32f2f", 12 | warning_color: "#ed6c02", 13 | success_color: "#2e7d32", 14 | background_color: "#ffffff", 15 | font_family: `"Roboto", "Helvetica", "Arial", sans-serif, ${ 16 | OS === "windows" ? "twemoji mozilla" : "" 17 | }`, 18 | }; 19 | 20 | // dark mode 21 | export const defaultDarkTheme = { 22 | ...defaultTheme, 23 | primary_text: "#E8E8ED", 24 | background_color: "#181818", 25 | secondary_text: "#bbbbbb", 26 | }; 27 | -------------------------------------------------------------------------------- /src/pages/logs.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import { useRecoilState } from "recoil"; 3 | import { 4 | Box, 5 | Button, 6 | IconButton, 7 | MenuItem, 8 | Paper, 9 | Select, 10 | TextField, 11 | } from "@mui/material"; 12 | import { Virtuoso } from "react-virtuoso"; 13 | import { useTranslation } from "react-i18next"; 14 | import { 15 | PlayCircleOutlineRounded, 16 | PauseCircleOutlineRounded, 17 | } from "@mui/icons-material"; 18 | import { atomEnableLog, atomLogData } from "@/services/states"; 19 | import { BaseEmpty, BasePage } from "@/components/base"; 20 | import LogItem from "@/components/log/log-item"; 21 | 22 | const LogPage = () => { 23 | const { t } = useTranslation(); 24 | const [logData, setLogData] = useRecoilState(atomLogData); 25 | const [enableLog, setEnableLog] = useRecoilState(atomEnableLog); 26 | 27 | const [logState, setLogState] = useState("all"); 28 | const [filterText, setFilterText] = useState(""); 29 | 30 | const filterLogs = useMemo(() => { 31 | return logData.filter((data) => { 32 | return ( 33 | data.payload.includes(filterText) && 34 | (logState === "all" ? true : data.type.includes(logState)) 35 | ); 36 | }); 37 | }, [logData, logState, filterText]); 38 | 39 | return ( 40 | 46 | setEnableLog((e) => !e)} 50 | > 51 | {enableLog ? ( 52 | 53 | ) : ( 54 | 55 | )} 56 | 57 | 58 | 65 | 66 | } 67 | > 68 | 78 | 90 | 91 | setFilterText(e.target.value)} 101 | sx={{ input: { py: 0.65, px: 1.25 } }} 102 | /> 103 | 104 | 105 | 106 | {filterLogs.length > 0 ? ( 107 | } 111 | followOutput={"smooth"} 112 | /> 113 | ) : ( 114 | 115 | )} 116 | 117 | 118 | ); 119 | }; 120 | 121 | export default LogPage; 122 | -------------------------------------------------------------------------------- /src/pages/proxies.tsx: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import { useEffect, useMemo } from "react"; 3 | import { useLockFn } from "ahooks"; 4 | import { useTranslation } from "react-i18next"; 5 | import { Box, Button, ButtonGroup, Paper } from "@mui/material"; 6 | import { 7 | closeAllConnections, 8 | getClashConfig, 9 | updateConfigs, 10 | } from "@/services/api"; 11 | import { patchClashConfig } from "@/services/cmds"; 12 | import { useVerge } from "@/hooks/use-verge"; 13 | import { BasePage } from "@/components/base"; 14 | import { ProxyGroups } from "@/components/proxy/proxy-groups"; 15 | import { ProviderButton } from "@/components/proxy/provider-button"; 16 | 17 | const ProxyPage = () => { 18 | const { t } = useTranslation(); 19 | 20 | const { data: clashConfig, mutate: mutateClash } = useSWR( 21 | "getClashConfig", 22 | getClashConfig 23 | ); 24 | 25 | const { verge } = useVerge(); 26 | 27 | const modeList = useMemo(() => { 28 | if (verge?.clash_core?.includes("clash-meta")) { 29 | return ["rule", "global", "direct"]; 30 | } 31 | return ["rule", "global", "direct", "script"]; 32 | }, [verge?.clash_core]); 33 | 34 | const curMode = clashConfig?.mode?.toLowerCase(); 35 | 36 | const onChangeMode = useLockFn(async (mode: string) => { 37 | // 断开连接 38 | if (mode !== curMode && verge?.auto_close_connection) { 39 | closeAllConnections(); 40 | } 41 | await updateConfigs({ mode }); 42 | await patchClashConfig({ mode }); 43 | mutateClash(); 44 | }); 45 | 46 | useEffect(() => { 47 | if (curMode && !modeList.includes(curMode)) { 48 | onChangeMode("rule"); 49 | } 50 | }, [curMode]); 51 | 52 | return ( 53 | 59 | 60 | 61 | 62 | {modeList.map((mode) => ( 63 | 71 | ))} 72 | 73 | 74 | } 75 | > 76 | 77 | 78 | ); 79 | }; 80 | 81 | export default ProxyPage; 82 | -------------------------------------------------------------------------------- /src/pages/rules.tsx: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import { useState, useMemo } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { Virtuoso } from "react-virtuoso"; 5 | import { Box, Paper, TextField } from "@mui/material"; 6 | import { getRules } from "@/services/api"; 7 | import { BaseEmpty, BasePage } from "@/components/base"; 8 | import RuleItem from "@/components/rule/rule-item"; 9 | 10 | const RulesPage = () => { 11 | const { t } = useTranslation(); 12 | const { data = [] } = useSWR("getRules", getRules); 13 | 14 | const [filterText, setFilterText] = useState(""); 15 | 16 | const rules = useMemo(() => { 17 | return data.filter((each) => each.payload.includes(filterText)); 18 | }, [data, filterText]); 19 | 20 | return ( 21 | 22 | 32 | setFilterText(e.target.value)} 42 | sx={{ input: { py: 0.65, px: 1.25 } }} 43 | /> 44 | 45 | 46 | 47 | {rules.length > 0 ? ( 48 | ( 51 | 52 | )} 53 | followOutput={"smooth"} 54 | /> 55 | ) : ( 56 | 57 | )} 58 | 59 | 60 | ); 61 | }; 62 | 63 | export default RulesPage; 64 | -------------------------------------------------------------------------------- /src/pages/settings.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Grid, IconButton, Paper } from "@mui/material"; 2 | import { useLockFn } from "ahooks"; 3 | import { useTranslation } from "react-i18next"; 4 | import { BasePage, Notice } from "@/components/base"; 5 | import { GitHub } from "@mui/icons-material"; 6 | import { openWebUrl } from "@/services/cmds"; 7 | import SettingVerge from "@/components/setting/setting-verge"; 8 | import SettingClash from "@/components/setting/setting-clash"; 9 | import SettingSystem from "@/components/setting/setting-system"; 10 | 11 | const SettingPage = () => { 12 | const { t } = useTranslation(); 13 | 14 | const onError = (err: any) => { 15 | Notice.error(err?.message || err.toString()); 16 | }; 17 | 18 | const toGithubRepo = useLockFn(() => { 19 | return openWebUrl("https://github.com/MetaCubeX/clash-verge"); 20 | }); 21 | 22 | return ( 23 | 32 | 33 | 34 | } 35 | > 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default SettingPage; 56 | -------------------------------------------------------------------------------- /src/services/delay.ts: -------------------------------------------------------------------------------- 1 | import { cmdGetProxyDelay } from "./cmds"; 2 | 3 | const hashKey = (name: string, group: string) => `${group ?? ""}::${name}`; 4 | 5 | class DelayManager { 6 | private cache = new Map(); 7 | private urlMap = new Map(); 8 | 9 | // 每个item的监听 10 | private listenerMap = new Map void>(); 11 | 12 | // 每个分组的监听 13 | private groupListenerMap = new Map void>(); 14 | 15 | setUrl(group: string, url: string) { 16 | this.urlMap.set(group, url); 17 | } 18 | 19 | getUrl(group: string) { 20 | return this.urlMap.get(group); 21 | } 22 | 23 | setListener(name: string, group: string, listener: (time: number) => void) { 24 | const key = hashKey(name, group); 25 | this.listenerMap.set(key, listener); 26 | } 27 | 28 | removeListener(name: string, group: string) { 29 | const key = hashKey(name, group); 30 | this.listenerMap.delete(key); 31 | } 32 | 33 | setGroupListener(group: string, listener: () => void) { 34 | this.groupListenerMap.set(group, listener); 35 | } 36 | 37 | removeGroupListener(group: string) { 38 | this.groupListenerMap.delete(group); 39 | } 40 | 41 | setDelay(name: string, group: string, delay: number) { 42 | const key = hashKey(name, group); 43 | this.cache.set(key, [Date.now(), delay]); 44 | this.listenerMap.get(key)?.(delay); 45 | this.groupListenerMap.get(group)?.(); 46 | } 47 | 48 | getDelay(name: string, group: string) { 49 | if (!name) return -1; 50 | 51 | const result = this.cache.get(hashKey(name, group)); 52 | if (result && Date.now() - result[0] <= 18e5) { 53 | return result[1]; 54 | } 55 | return -1; 56 | } 57 | 58 | /// 暂时修复provider的节点延迟排序的问题 59 | getDelayFix(proxy: IProxyItem, group: string) { 60 | if (!proxy.provider) { 61 | const delay = this.getDelay(proxy.name, group); 62 | if (delay >= 0 || delay === -2) return delay; 63 | } 64 | 65 | if (proxy.history.length > 0) { 66 | // 0ms以error显示 67 | return proxy.history[proxy.history.length - 1].delay || 1e6; 68 | } 69 | return -1; 70 | } 71 | 72 | async checkDelay(name: string, group: string) { 73 | let delay = -1; 74 | 75 | try { 76 | const url = this.getUrl(group); 77 | const result = await cmdGetProxyDelay(name, url); 78 | delay = result.delay; 79 | } catch { 80 | delay = 1e6; // error 81 | } 82 | 83 | this.setDelay(name, group, delay); 84 | return delay; 85 | } 86 | 87 | async checkListDelay(nameList: string[], group: string, concurrency = 36) { 88 | const names = nameList.filter(Boolean); 89 | // 设置正在延迟测试中 90 | names.forEach((name) => this.setDelay(name, group, -2)); 91 | 92 | let total = names.length; 93 | let current = 0; 94 | 95 | return new Promise((resolve) => { 96 | const help = async (): Promise => { 97 | if (current >= concurrency) return; 98 | const task = names.shift(); 99 | if (!task) return; 100 | current += 1; 101 | await this.checkDelay(task, group); 102 | current -= 1; 103 | total -= 1; 104 | if (total <= 0) resolve(null); 105 | else return help(); 106 | }; 107 | for (let i = 0; i < concurrency; ++i) help(); 108 | }); 109 | } 110 | 111 | formatDelay(delay: number) { 112 | if (delay < 0) return "-"; 113 | if (delay > 1e5) return "Error"; 114 | if (delay >= 10000) return "Timeout"; // 10s 115 | return `${delay}`; 116 | } 117 | 118 | formatDelayColor(delay: number) { 119 | if (delay >= 10000) return "error.main"; 120 | /*if (delay <= 0) return "text.secondary"; 121 | if (delay > 500) return "warning.main"; 122 | if (delay > 100) return "text.secondary";*/ 123 | return "success.main"; 124 | } 125 | } 126 | 127 | export default new DelayManager(); 128 | -------------------------------------------------------------------------------- /src/services/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import { initReactI18next } from "react-i18next"; 3 | import en from "@/locales/en.json"; 4 | import ru from "@/locales/ru.json"; 5 | import zh from "@/locales/zh.json"; 6 | 7 | const resources = { 8 | en: { translation: en }, 9 | ru: { translation: ru }, 10 | zh: { translation: zh }, 11 | }; 12 | 13 | i18n.use(initReactI18next).init({ 14 | resources, 15 | lng: "en", 16 | interpolation: { 17 | escapeValue: false, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /src/services/states.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil"; 2 | 3 | export const atomThemeMode = atom<"light" | "dark">({ 4 | key: "atomThemeMode", 5 | default: "light", 6 | }); 7 | 8 | export const atomLogData = atom({ 9 | key: "atomLogData", 10 | default: [], 11 | }); 12 | 13 | export const atomEnableLog = atom({ 14 | key: "atomEnableLog", 15 | effects: [ 16 | ({ setSelf, onSet }) => { 17 | const key = "enable-log"; 18 | 19 | try { 20 | setSelf(localStorage.getItem(key) !== "false"); 21 | } catch {} 22 | 23 | onSet((newValue, _, isReset) => { 24 | try { 25 | if (isReset) { 26 | localStorage.removeItem(key); 27 | } else { 28 | localStorage.setItem(key, newValue.toString()); 29 | } 30 | } catch {} 31 | }); 32 | }, 33 | ], 34 | }); 35 | 36 | interface IConnectionSetting { 37 | layout: "table" | "list"; 38 | } 39 | 40 | export const atomConnectionSetting = atom({ 41 | key: "atomConnectionSetting", 42 | effects: [ 43 | ({ setSelf, onSet }) => { 44 | const key = "connections-setting"; 45 | 46 | try { 47 | const value = localStorage.getItem(key); 48 | const data = value == null ? { layout: "table" } : JSON.parse(value); 49 | setSelf(data); 50 | } catch { 51 | setSelf({ layout: "table" }); 52 | } 53 | 54 | onSet((newValue) => { 55 | try { 56 | localStorage.setItem(key, JSON.stringify(newValue)); 57 | } catch {} 58 | }); 59 | }, 60 | ], 61 | }); 62 | 63 | // save the state of each profile item loading 64 | export const atomLoadingCache = atom>({ 65 | key: "atomLoadingCache", 66 | default: {}, 67 | }); 68 | 69 | // save update state 70 | export const atomUpdateState = atom({ 71 | key: "atomUpdateState", 72 | default: false, 73 | }); 74 | -------------------------------------------------------------------------------- /src/utils/clash-fields.ts: -------------------------------------------------------------------------------- 1 | export const HANDLE_FIELDS = [ 2 | "mode", 3 | "port", 4 | "socks-port", 5 | "mixed-port", 6 | "allow-lan", 7 | "log-level", 8 | "ipv6", 9 | "secret", 10 | "external-controller", 11 | ]; 12 | 13 | export const DEFAULT_FIELDS = [ 14 | "proxies", 15 | "proxy-groups", 16 | "proxy-providers", 17 | "rules", 18 | "rule-providers", 19 | ] as const; 20 | 21 | export const OTHERS_FIELDS = [ 22 | "dns", 23 | "tun", 24 | "ebpf", 25 | "hosts", 26 | "script", 27 | "profile", 28 | "payload", 29 | "tunnels", 30 | "auto-redir", 31 | "experimental", 32 | "interface-name", 33 | "routing-mark", 34 | "redir-port", 35 | "tproxy-port", 36 | "iptables", 37 | "external-ui", 38 | "bind-address", 39 | "authentication", 40 | "tls", // meta 41 | "sniffer", // meta 42 | "geox-url", // meta 43 | "listeners", // meta 44 | "sub-rules", // meta 45 | "geodata-mode", // meta 46 | "unified-delay", // meta 47 | "tcp-concurrent", // meta 48 | "enable-process", // meta 49 | "find-process-mode", // meta 50 | "skip-auth-prefixes", // meta 51 | "external-controller-tls", // meta 52 | "global-client-fingerprint", // meta 53 | ] as const; 54 | -------------------------------------------------------------------------------- /src/utils/custom-comparator.ts: -------------------------------------------------------------------------------- 1 | import { GridComparatorFn } from "@mui/x-data-grid"; 2 | 3 | const UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 4 | const unitMap = new Map(); 5 | unitMap.set("分钟前", 60); 6 | unitMap.set("小时前", 60 * 60); 7 | unitMap.set("天前", 60 * 60 * 24); 8 | unitMap.set("个月前", 60 * 60 * 24 * 30); 9 | unitMap.set("年前", 60 * 60 * 24 * 30 * 12); 10 | 11 | export const sortWithUnit: GridComparatorFn = (v1, v2) => { 12 | const [ret1, unit1] = v1.split(" "); 13 | const [ret2, unit2] = v2.split(" "); 14 | let value1 = 15 | parseFloat(ret1) * 16 | Math.pow(1024, UNITS.indexOf(unit1.replace("/s", "").trim())); 17 | let value2 = 18 | parseFloat(ret2) * 19 | Math.pow(1024, UNITS.indexOf(unit2.replace("/s", "").trim())); 20 | return value1 - value2; 21 | }; 22 | 23 | export const sortStringTime: GridComparatorFn = (v1, v2) => { 24 | if (v1 === "几秒前") { 25 | return -1; 26 | } 27 | if (v2 === "几秒前") { 28 | return 1; 29 | } 30 | 31 | const matches1 = v1.match(/[0-9]+/); 32 | const num1 = matches1 !== null ? parseInt(matches1[0]) : 0; 33 | const matches2 = v2.match(/[0-9]+/); 34 | const num2 = matches2 !== null ? parseInt(matches2[0]) : 0; 35 | const unit1 = unitMap.get(v1.replace(num1.toString(), "").trim()) || 0; 36 | const unit2 = unitMap.get(v2.replace(num2.toString(), "").trim()) || 0; 37 | return num1 * unit1 - num2 * unit2; 38 | }; 39 | -------------------------------------------------------------------------------- /src/utils/get-system.ts: -------------------------------------------------------------------------------- 1 | // get the system os 2 | // according to UA 3 | export default function getSystem() { 4 | const ua = navigator.userAgent; 5 | const platform = OS_PLATFORM; 6 | 7 | if (ua.includes("Mac OS X") || platform === "darwin") return "macos"; 8 | 9 | if (/win64|win32/i.test(ua) || platform === "win32") return "windows"; 10 | 11 | if (/linux/i.test(ua)) return "linux"; 12 | 13 | return "unknown"; 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/ignore-case.ts: -------------------------------------------------------------------------------- 1 | // Deep copy and change all keys to lowercase 2 | type TData = Record; 3 | 4 | export default function ignoreCase(data: TData): TData { 5 | if (!data) return {}; 6 | 7 | const newData = {} as TData; 8 | 9 | Object.entries(data).forEach(([key, value]) => { 10 | newData[key.toLowerCase()] = JSON.parse(JSON.stringify(value)); 11 | }); 12 | 13 | return newData; 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/noop.ts: -------------------------------------------------------------------------------- 1 | export default function noop() {} 2 | -------------------------------------------------------------------------------- /src/utils/parse-hotkey.ts: -------------------------------------------------------------------------------- 1 | const KEY_MAP: Record = { 2 | '"': "'", 3 | ":": ";", 4 | "?": "/", 5 | ">": ".", 6 | "<": ",", 7 | "{": "[", 8 | "}": "]", 9 | "|": "\\", 10 | "!": "1", 11 | "@": "2", 12 | "#": "3", 13 | $: "4", 14 | "%": "5", 15 | "^": "6", 16 | "&": "7", 17 | "*": "8", 18 | "(": "9", 19 | ")": "0", 20 | "~": "`", 21 | }; 22 | 23 | export const parseHotkey = (key: string) => { 24 | let temp = key.toUpperCase(); 25 | 26 | if (temp.startsWith("ARROW")) { 27 | temp = temp.slice(5); 28 | } else if (temp.startsWith("DIGIT")) { 29 | temp = temp.slice(5); 30 | } else if (temp.startsWith("KEY")) { 31 | temp = temp.slice(3); 32 | } else if (temp.endsWith("LEFT")) { 33 | temp = temp.slice(0, -4); 34 | } else if (temp.endsWith("RIGHT")) { 35 | temp = temp.slice(0, -5); 36 | } 37 | 38 | switch (temp) { 39 | case "CONTROL": 40 | return "CTRL"; 41 | case "META": 42 | return "CMD"; 43 | case " ": 44 | return "SPACE"; 45 | default: 46 | return KEY_MAP[temp] || temp; 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/utils/parse-traffic.ts: -------------------------------------------------------------------------------- 1 | const UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 2 | 3 | const parseTraffic = (num?: number) => { 4 | if (typeof num !== "number") return ["NaN", ""]; 5 | if (num < 1000) return [`${Math.round(num)}`, "B"]; 6 | const exp = Math.min(Math.floor(Math.log2(num) / 10), UNITS.length - 1); 7 | const dat = num / Math.pow(1024, exp); 8 | const ret = dat >= 1000 ? dat.toFixed(0) : dat.toPrecision(3); 9 | const unit = UNITS[exp]; 10 | 11 | return [ret, unit]; 12 | }; 13 | 14 | export default parseTraffic; 15 | -------------------------------------------------------------------------------- /src/utils/truncate-str.ts: -------------------------------------------------------------------------------- 1 | export const truncateStr = (str?: string, prefixLen = 16, maxLen = 56) => { 2 | if (!str || str.length <= maxLen) return str; 3 | return ( 4 | str.slice(0, prefixLen) + " ... " + str.slice(-(maxLen - prefixLen - 5)) 5 | ); 6 | }; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 7 | "allowJs": false, 8 | "skipLibCheck": true, 9 | "esModuleInterop": false, 10 | "allowSyntheticDefaultImports": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "module": "ESNext", 14 | "moduleResolution": "Node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | "paths": { 20 | "@/*": ["src/*"], 21 | "@root/*": ["./*"] 22 | } 23 | }, 24 | "include": ["./src"] 25 | } 26 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import path from "path"; 3 | import svgr from "vite-plugin-svgr"; 4 | import react from "@vitejs/plugin-react"; 5 | import monaco from "vite-plugin-monaco-editor"; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | root: "src", 10 | server: { port: 3000 }, 11 | plugins: [ 12 | svgr(), 13 | react(), 14 | monaco({ languageWorkers: ["editorWorkerService", "typescript"] }), 15 | ], 16 | build: { 17 | outDir: "../dist", 18 | emptyOutDir: true, 19 | }, 20 | resolve: { 21 | alias: { 22 | "@": path.resolve("./src"), 23 | "@root": path.resolve("."), 24 | }, 25 | }, 26 | define: { 27 | OS_PLATFORM: `"${process.platform}"`, 28 | }, 29 | }); 30 | --------------------------------------------------------------------------------