├── .cargo └── config.toml ├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ ├── feature_request.yml │ └── i18n_request.yml └── workflows │ ├── alpha.yml │ ├── autobuild-check-test.yml │ ├── autobuild.yml │ ├── build-all-platforms.yml │ ├── check-commit-needs-build.yml │ ├── clean-old-assets.yml │ ├── cross_check.yaml │ ├── dev.yml │ ├── fmt.yml │ ├── lint-clippy.yml │ ├── release.yml │ └── updater.yml ├── .gitignore ├── .husky ├── pre-commit └── pre-push ├── .prettierignore ├── .prettierrc ├── 1.png ├── 2.png ├── 3.png ├── 4.png ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── build.bat ├── crowdin.yml ├── docs ├── preview_dark.png └── preview_light.png ├── eslint.config.ts ├── package.json ├── package.json.backup ├── pnpm-lock.yaml ├── pnpm-lock.yaml.backup ├── query ├── renovate.json ├── scripts-workflow └── get_latest_tauri_commit.bash ├── scripts ├── build-all-platforms.mjs ├── check-unused-i18n.js ├── fix-alpha_version.mjs ├── portable-fixed-webview2.mjs ├── portable.mjs ├── prebuild.mjs ├── publish-version.mjs ├── release-version.mjs ├── set_dns.sh ├── telegram.mjs ├── unset_dns.sh ├── updatelog.mjs ├── updater-fixed-webview2.mjs ├── updater.mjs └── utils.mjs ├── src-tauri ├── .clippy.toml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── assets │ └── fonts │ │ └── SF-Pro.ttf ├── benches │ └── draft_benchmark.rs ├── build.rs ├── capabilities │ ├── desktop-windows.json │ ├── desktop.json │ └── migrated.json ├── deny.toml ├── 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.icns │ ├── icon.ico │ ├── icon.png │ ├── tray-icon-mono.ico │ ├── tray-icon-sys-mono-new.ico │ ├── tray-icon-sys-mono.ico │ ├── tray-icon-sys.ico │ ├── tray-icon-tun-mono-new.ico │ ├── tray-icon-tun-mono.ico │ ├── tray-icon-tun.ico │ └── tray-icon.ico ├── images │ └── background.png ├── packages │ ├── linux │ │ ├── clash-verge.desktop │ │ ├── post-install.sh │ │ └── pre-remove.sh │ ├── macos │ │ └── entitlements.plist │ └── windows │ │ └── installer.nsi ├── rustfmt.toml ├── src │ ├── cmd │ │ ├── app.rs │ │ ├── backup.rs │ │ ├── clash.rs │ │ ├── lightweight.rs │ │ ├── media_unlock_checker │ │ │ ├── bahamut.rs │ │ │ ├── bilibili.rs │ │ │ ├── chatgpt.rs │ │ │ ├── claude.rs │ │ │ ├── disney_plus.rs │ │ │ ├── gemini.rs │ │ │ ├── mod.rs │ │ │ ├── netflix.rs │ │ │ ├── prime_video.rs │ │ │ ├── spotify.rs │ │ │ ├── tiktok.rs │ │ │ ├── types.rs │ │ │ ├── utils.rs │ │ │ └── youtube.rs │ │ ├── mod.rs │ │ ├── network.rs │ │ ├── profile.rs │ │ ├── proxy.rs │ │ ├── runtime.rs │ │ ├── save_profile.rs │ │ ├── service.rs │ │ ├── system.rs │ │ ├── tun.rs │ │ ├── uwp.rs │ │ ├── validate.rs │ │ ├── verge.rs │ │ └── webdav.rs │ ├── config │ │ ├── clash.rs │ │ ├── config.rs │ │ ├── encrypt.rs │ │ ├── mod.rs │ │ ├── prfitem.rs │ │ ├── profiles.rs │ │ ├── runtime.rs │ │ └── verge.rs │ ├── core │ │ ├── async_proxy_query.rs │ │ ├── backup.rs │ │ ├── config_validator.rs │ │ ├── core.rs │ │ ├── event_driven_proxy.rs │ │ ├── handle.rs │ │ ├── hotkey.rs │ │ ├── logger.rs │ │ ├── mod.rs │ │ ├── process_manager.rs │ │ ├── service.rs │ │ ├── sysopt.rs │ │ ├── timer.rs │ │ ├── tray │ │ │ ├── mod.rs │ │ │ └── speed_rate.rs │ │ ├── tun_manager.rs │ │ └── win_uwp.rs │ ├── enhance │ │ ├── builtin │ │ │ ├── meta_guard.js │ │ │ └── meta_hy_alpn.js │ │ ├── chain.rs │ │ ├── field.rs │ │ ├── merge.rs │ │ ├── mod.rs │ │ ├── script.rs │ │ ├── seq.rs │ │ └── tun.rs │ ├── feat │ │ ├── backup.rs │ │ ├── clash.rs │ │ ├── config.rs │ │ ├── config_flags.rs │ │ ├── mod.rs │ │ ├── profile.rs │ │ ├── proxy.rs │ │ └── window.rs │ ├── lib.rs │ ├── main.rs │ ├── module │ │ ├── lightweight.rs │ │ ├── mod.rs │ │ └── sysinfo.rs │ ├── process │ │ ├── async_handler.rs │ │ ├── guard.rs │ │ └── mod.rs │ └── utils │ │ ├── autostart.rs │ │ ├── dirs.rs │ │ ├── draft.rs │ │ ├── format.rs │ │ ├── help.rs │ │ ├── i18n.rs │ │ ├── init.rs │ │ ├── linux.rs │ │ ├── logging.rs │ │ ├── mod.rs │ │ ├── network.rs │ │ ├── notification.rs │ │ ├── permission.rs │ │ ├── resolve │ │ ├── dns.rs │ │ ├── mod.rs │ │ ├── scheme.rs │ │ ├── ui.rs │ │ ├── window.rs │ │ └── window_script.rs │ │ ├── server.rs │ │ ├── singleton.rs │ │ ├── tmpl.rs │ │ └── window_manager.rs ├── tauri.conf.json ├── tauri.linux.conf.json ├── tauri.macos.conf.json ├── tauri.windows.conf.json ├── webview2.arm64.json ├── webview2.x64.json └── webview2.x86.json ├── src ├── App.tsx ├── assets │ ├── fonts │ │ └── Twemoji.Mozilla.ttf │ ├── image │ │ ├── component │ │ │ ├── match_case.svg │ │ │ ├── match_whole_word.svg │ │ │ └── use_regular_expression.svg │ │ ├── icon_dark.svg │ │ ├── icon_light.svg │ │ ├── itemicon │ │ │ ├── connections.svg │ │ │ ├── home.svg │ │ │ ├── logs.svg │ │ │ ├── profiles.svg │ │ │ ├── proxies.svg │ │ │ ├── rules.svg │ │ │ ├── settings.svg │ │ │ ├── test.svg │ │ │ └── unlock.svg │ │ ├── logo.ico │ │ ├── logo.png │ │ ├── logo.svg │ │ └── test │ │ │ ├── apple.svg │ │ │ ├── github.svg │ │ │ ├── google.svg │ │ │ └── youtube.svg │ └── styles │ │ ├── font.scss │ │ ├── index.scss │ │ ├── layout.scss │ │ └── page.scss ├── components │ ├── base │ │ ├── NoticeManager.tsx │ │ ├── base-button.tsx │ │ ├── base-card.tsx │ │ ├── base-dialog.tsx │ │ ├── base-empty.tsx │ │ ├── base-error-boundary.tsx │ │ ├── base-fieldset.tsx │ │ ├── base-loading-overlay.tsx │ │ ├── base-loading.tsx │ │ ├── base-menu.tsx │ │ ├── base-mode-selector.tsx │ │ ├── base-page.tsx │ │ ├── base-search-box.tsx │ │ ├── base-styled-text-field.tsx │ │ ├── base-switch.tsx │ │ ├── base-tooltip-icon.tsx │ │ ├── index.ts │ │ ├── select-menu-props.ts │ │ └── theme-tokens.ts │ ├── center.tsx │ ├── common │ │ ├── traffic-error-boundary.tsx │ │ └── with-traffic-error-boundary.tsx │ ├── connection │ │ ├── connection-detail.tsx │ │ ├── connection-item.tsx │ │ └── connection-table.tsx │ ├── controller │ │ └── window-controller.tsx │ ├── home │ │ ├── clash-info-card.tsx │ │ ├── clash-mode-card.tsx │ │ ├── current-proxy-card.tsx │ │ ├── enhanced-canvas-traffic-graph.tsx │ │ ├── enhanced-card.tsx │ │ ├── enhanced-traffic-stats.tsx │ │ ├── home-profile-card.tsx │ │ ├── ip-info-card.tsx │ │ ├── proxy-tun-card.tsx │ │ ├── system-info-card.tsx │ │ └── test-card.tsx │ ├── layout │ │ ├── custom-titlebar.tsx │ │ ├── layout-item.tsx │ │ ├── layout-traffic.tsx │ │ ├── scroll-top-button.tsx │ │ ├── traffic-graph.tsx │ │ ├── update-button.tsx │ │ └── use-custom-theme.ts │ ├── log │ │ └── log-item.tsx │ ├── profile │ │ ├── confirm-viewer.tsx │ │ ├── editor-viewer.tsx │ │ ├── file-input.tsx │ │ ├── group-item.tsx │ │ ├── groups-editor-viewer.tsx │ │ ├── log-viewer.tsx │ │ ├── profile-box.tsx │ │ ├── profile-group-manager.tsx │ │ ├── profile-item.tsx │ │ ├── profile-more.tsx │ │ ├── profile-viewer.tsx │ │ ├── proxies-editor-viewer.tsx │ │ ├── proxy-item.tsx │ │ ├── rule-item.tsx │ │ └── rules-editor-viewer.tsx │ ├── proxy │ │ ├── provider-button.tsx │ │ ├── proxy-chain.tsx │ │ ├── proxy-group-navigator.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 │ │ ├── provider-button.tsx │ │ └── rule-item.tsx │ ├── setting │ │ ├── mods │ │ │ ├── backup-config-viewer.tsx │ │ │ ├── backup-table-viewer.tsx │ │ │ ├── backup-viewer.tsx │ │ │ ├── clash-core-viewer.tsx │ │ │ ├── clash-port-viewer.tsx │ │ │ ├── config-viewer.tsx │ │ │ ├── controller-viewer.tsx │ │ │ ├── dns-viewer.tsx │ │ │ ├── enhanced-dialog-components.tsx │ │ │ ├── external-controller-cors.tsx │ │ │ ├── guard-state.tsx │ │ │ ├── hotkey-input.tsx │ │ │ ├── hotkey-viewer.tsx │ │ │ ├── layout-viewer.tsx │ │ │ ├── lite-mode-viewer.tsx │ │ │ ├── local-backup-actions.tsx │ │ │ ├── misc-viewer.tsx │ │ │ ├── network-interface-viewer.tsx │ │ │ ├── password-input.tsx │ │ │ ├── setting-comp.tsx │ │ │ ├── stack-mode-switch.tsx │ │ │ ├── sysproxy-viewer.tsx │ │ │ ├── theme-mode-switch.tsx │ │ │ ├── theme-preset-card.tsx │ │ │ ├── theme-presets.json │ │ │ ├── theme-types.ts │ │ │ ├── theme-utils.ts │ │ │ ├── theme-viewer-enhanced.tsx │ │ │ ├── theme-viewer.tsx │ │ │ ├── tun-viewer.tsx │ │ │ ├── update-viewer.tsx │ │ │ ├── web-ui-item.tsx │ │ │ └── web-ui-viewer.tsx │ │ ├── setting-clash.tsx │ │ ├── setting-system-styles.ts │ │ ├── setting-system.tsx │ │ ├── setting-verge-advanced.tsx │ │ └── setting-verge-basic.tsx │ ├── shared │ │ ├── ProxyControlSwitches.tsx │ │ ├── TunStatusMonitor.tsx │ │ └── proxy-control-styles.ts │ ├── test │ │ ├── test-box.tsx │ │ ├── test-item.tsx │ │ └── test-viewer.tsx │ └── unlock │ │ └── unlock-item.tsx ├── config │ └── swr-config.ts ├── hooks │ ├── use-clash.ts │ ├── use-connection-data.ts │ ├── use-current-proxy.ts │ ├── use-i18n.ts │ ├── use-listen.ts │ ├── use-log-data-new.ts │ ├── use-log-data.ts │ ├── use-memory-data.ts │ ├── use-profiles.ts │ ├── use-proxy-selection.ts │ ├── use-resource-cleanup.ts │ ├── use-service-manager.ts │ ├── use-service-operations.ts │ ├── use-system-proxy-state.ts │ ├── use-system-state.ts │ ├── use-traffic-data.ts │ ├── use-traffic-monitor.ts │ ├── use-traffic-quota-reminder.ts │ ├── use-tun-mode.ts │ ├── use-verge.ts │ ├── use-visibility.ts │ └── use-window.ts ├── index.html ├── locales │ ├── ar.json │ ├── de.json │ ├── en.json │ ├── es.json │ ├── fa.json │ ├── id.json │ ├── jp.json │ ├── ko.json │ ├── ru.json │ ├── tr.json │ ├── tt.json │ ├── zh.json │ └── zhtw.json ├── main.tsx ├── pages │ ├── _layout.tsx │ ├── _routers.tsx │ ├── _theme.tsx │ ├── connections.tsx │ ├── home.tsx │ ├── logs.tsx │ ├── profiles.tsx │ ├── proxies.tsx │ ├── rules.tsx │ ├── settings.tsx │ ├── test.tsx │ ├── traffic-analytics.tsx │ └── unlock.tsx ├── polyfills │ ├── RegExp.js │ ├── WeakRef.js │ └── matchMedia.js ├── providers │ ├── app-data-context.ts │ ├── app-data-provider.tsx │ ├── chain-proxy-context.ts │ ├── chain-proxy-provider.tsx │ └── window │ │ ├── WindowContext.ts │ │ ├── WindowProvider.tsx │ │ └── index.ts ├── services │ ├── api.ts │ ├── cmds.ts │ ├── delay.ts │ ├── global-log-service.ts │ ├── i18n.ts │ ├── ipc-log-service.ts │ ├── noticeService.ts │ ├── states.ts │ └── types.d.ts └── utils │ ├── data-validator.ts │ ├── debounce.ts │ ├── get-system.ts │ ├── helper.ts │ ├── ignore-case.ts │ ├── is-async-function.ts │ ├── logger.ts │ ├── noop.ts │ ├── parse-hotkey.ts │ ├── parse-traffic.ts │ ├── performance-helpers.ts │ ├── port-validator.ts │ ├── resource-manager.ts │ ├── safe-storage.ts │ ├── traffic-diagnostics.ts │ ├── truncate-str.ts │ └── uri-parser.ts ├── tsconfig.json └── vite.config.mts /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.aarch64-unknown-linux-gnu] 2 | linker = "aarch64-linux-gnu-gcc" 3 | 4 | [target.armv7-unknown-linux-gnueabihf] 5 | linker = "arm-linux-gnueabihf-gcc" 6 | 7 | [alias] 8 | clippy-all = "clippy --all-targets --all-features -- -D warnings" 9 | clippy-only = "clippy --all-targets --features clippy -- -D warnings" 10 | -------------------------------------------------------------------------------- /.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/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: clash-verge-rev 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 讨论交流 / Communication 4 | url: https://t.me/clash_verge_rev 5 | about: 在 Telegram 群组中与其他用户讨论交流 / Communicate with other users in the Telegram group 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 功能请求 / Feature request 2 | title: "[Feature] " 3 | description: 提出你的功能请求 / Propose your feature request 4 | labels: ["enhancement"] 5 | type: "Feature" 6 | 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | ## 在提交问题之前,请确认以下事项: 12 | 1. 请 **确保** 您已经查阅了 [NeedyClash 官方文档](https://clash-verge-rev.github.io/guide/term.html) 确认软件不存在类似的功能 13 | 2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似issue,否则请在已有的issue下进行讨论 14 | 3. 请 **务必** 给issue填写一个简洁明了的标题,以便他人快速检索 15 | 4. 请 **务必** 先下载 [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) 版本测试,确保该功能还未实现 16 | 5. 请 **务必** 按照模板规范详细描述问题,否则issue将会被关闭 17 | ## Before submitting the issue, please make sure of the following checklist: 18 | 1. Please make sure you have read the [NeedyClash official documentation](https://clash-verge-rev.github.io/guide/term.html) to confirm that the software does not have similar functions 19 | 2. Please make sure there is no similar issue in the [existing issues](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue), otherwise please discuss under the existing issue 20 | 3. Please be sure to fill in a concise and clear title for the issue so that others can quickly search 21 | 4. Please be sure to download the [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) version for testing to ensure that the function has not been implemented 22 | 5. Please describe the problem in detail according to the template specification, otherwise the issue will be closed 23 | 24 | - type: textarea 25 | id: description 26 | attributes: 27 | label: 功能描述 / Feature description 28 | description: 详细清晰地描述你的功能请求 / A clear and concise description of what the feature is 29 | validations: 30 | required: true 31 | - type: textarea 32 | attributes: 33 | label: 使用场景 / Use case 34 | description: 请描述你的功能请求的使用场景 / Please describe the use case of your feature request 35 | validations: 36 | required: true 37 | - type: checkboxes 38 | id: os-labels 39 | attributes: 40 | label: 适用系统 / Target OS 41 | description: 请选择该功能适用的操作系统(至少选择一个) / Please select the operating system(s) for this feature request (select at least one) 42 | options: 43 | - label: windows 44 | - label: macos 45 | - label: linux 46 | validations: 47 | required: true 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/i18n_request.yml: -------------------------------------------------------------------------------- 1 | name: I18N / 多语言相关 2 | title: "[I18N] " 3 | description: 用于多语言翻译、国际化相关问题或建议 / For issues or suggestions related to translations and internationalization 4 | labels: ["I18n"] 5 | type: "Task" 6 | 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | ## I18N 相关问题/建议 12 | 请用此模板提交翻译错误、缺失、建议或新增语言请求。 13 | Please use this template for translation errors, missing translations, suggestions, or new language requests. 14 | 15 | - type: textarea 16 | id: description 17 | attributes: 18 | label: 问题描述 / Description 19 | description: 详细描述你的 I18N 问题或建议 / Please describe your I18N issue or suggestion in detail 20 | validations: 21 | required: true 22 | 23 | - type: input 24 | id: language 25 | attributes: 26 | label: 相关语言 / Language 27 | description: 例如 zh, en, jp, ru, ... / e.g. zh, en, jp, ru, ... 28 | validations: 29 | required: true 30 | 31 | - type: textarea 32 | id: suggestion 33 | attributes: 34 | label: 建议或修正内容 / Suggestion or Correction 35 | description: 如果是翻译修正或建议,请填写建议的内容 / If this is a translation correction or suggestion, please provide the suggested content 36 | validations: 37 | required: false 38 | 39 | - type: checkboxes 40 | id: i18n-type 41 | attributes: 42 | label: 问题类型 / Issue Type 43 | description: 请选择适用类型(可多选) / Please select the applicable type(s) 44 | options: 45 | - label: 翻译错误 / Translation error 46 | - label: 翻译缺失 / Missing translation 47 | - label: 建议优化 / Suggestion 48 | - label: 新增语言 / New language 49 | validations: 50 | required: true 51 | 52 | - type: input 53 | id: verge-version 54 | attributes: 55 | label: 软件版本 / CVR Version 56 | description: 请提供你使用的 CVR 具体版本 / Please provide the specific version of CVR you are using 57 | validations: 58 | required: true 59 | -------------------------------------------------------------------------------- /.github/workflows/cross_check.yaml: -------------------------------------------------------------------------------- 1 | name: Cross Platform Cargo Check 2 | 3 | on: 4 | workflow_dispatch: 5 | # pull_request: 6 | # push: 7 | # branches: [main, dev] 8 | 9 | permissions: 10 | contents: read 11 | 12 | env: 13 | HUSKY: 0 14 | 15 | jobs: 16 | cargo-check: 17 | # Treat all Rust compiler warnings as errors 18 | env: 19 | RUSTFLAGS: "-D warnings" 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | include: 24 | - os: macos-latest 25 | target: aarch64-apple-darwin 26 | - os: windows-latest 27 | target: x86_64-pc-windows-msvc 28 | - os: ubuntu-22.04 29 | target: x86_64-unknown-linux-gnu 30 | runs-on: ${{ matrix.os }} 31 | steps: 32 | - name: Checkout Repository 33 | uses: actions/checkout@v4 34 | 35 | - name: Install Rust Stable 36 | uses: dtolnay/rust-toolchain@stable 37 | with: 38 | targets: ${{ matrix.target }} 39 | 40 | - name: Add Rust Target 41 | run: rustup target add ${{ matrix.target }} 42 | 43 | - name: Install Node 44 | uses: actions/setup-node@v4 45 | with: 46 | node-version: "20" 47 | 48 | - uses: pnpm/action-setup@v4 49 | name: Install pnpm 50 | with: 51 | run_install: false 52 | 53 | - name: Pnpm install and check 54 | run: | 55 | pnpm i 56 | pnpm run prebuild ${{ matrix.target }} 57 | 58 | - name: Rust Cache 59 | uses: Swatinem/rust-cache@v2 60 | with: 61 | workspaces: src-tauri 62 | save-if: false 63 | 64 | - name: Cargo Check (deny warnings) 65 | working-directory: src-tauri 66 | run: | 67 | cargo check --target ${{ matrix.target }} --workspace --all-features 68 | -------------------------------------------------------------------------------- /.github/workflows/lint-clippy.yml: -------------------------------------------------------------------------------- 1 | name: Clippy Lint 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | env: 7 | HUSKY: 0 8 | 9 | jobs: 10 | clippy: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - os: windows-latest 16 | target: x86_64-pc-windows-msvc 17 | - os: macos-latest 18 | target: aarch64-apple-darwin 19 | - os: ubuntu-22.04 20 | target: x86_64-unknown-linux-gnu 21 | 22 | runs-on: ${{ matrix.os }} 23 | steps: 24 | - name: Check src-tauri changes 25 | id: check_changes 26 | uses: dorny/paths-filter@v3 27 | with: 28 | filters: | 29 | rust: 30 | - 'src-tauri/**' 31 | 32 | - name: Skip if src-tauri not changed 33 | if: steps.check_changes.outputs.rust != 'true' 34 | run: echo "No src-tauri changes, skipping clippy lint." 35 | 36 | - name: Continue if src-tauri changed 37 | if: steps.check_changes.outputs.rust == 'true' 38 | run: echo "src-tauri changed, running clippy lint." 39 | 40 | - name: Checkout Repository 41 | uses: actions/checkout@v4 42 | 43 | - name: Install Rust Stable 44 | uses: dtolnay/rust-toolchain@master 45 | with: 46 | toolchain: stable 47 | components: clippy 48 | 49 | - name: Add Rust Target 50 | run: rustup target add ${{ matrix.target }} 51 | 52 | - name: Rust Cache 53 | uses: Swatinem/rust-cache@v2 54 | with: 55 | workspaces: src-tauri 56 | save-if: false 57 | cache-all-crates: false 58 | shared-key: autobuild-shared 59 | 60 | - name: Install dependencies (ubuntu only) 61 | if: matrix.os == 'ubuntu-22.04' 62 | run: | 63 | sudo apt-get update 64 | sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf 65 | 66 | - name: Run Clippy 67 | working-directory: ./src-tauri 68 | run: cargo clippy-all 69 | -------------------------------------------------------------------------------- /.github/workflows/updater.yml: -------------------------------------------------------------------------------- 1 | name: Updater CI 2 | 3 | on: workflow_dispatch 4 | permissions: write-all 5 | env: 6 | HUSKY: 0 7 | 8 | jobs: 9 | release-update: 10 | runs-on: ubuntu-22.04 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Install Node 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: "22" 19 | 20 | - uses: pnpm/action-setup@v4 21 | name: Install pnpm 22 | with: 23 | run_install: false 24 | 25 | - name: Pnpm install 26 | run: pnpm i 27 | 28 | - name: Release updater file 29 | run: pnpm updater 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | release-update-for-fixed-webview2: 34 | runs-on: ubuntu-22.04 35 | steps: 36 | - name: Checkout repository 37 | uses: actions/checkout@v4 38 | 39 | - name: Install Node 40 | uses: actions/setup-node@v4 41 | with: 42 | node-version: "22" 43 | 44 | - uses: pnpm/action-setup@v4 45 | name: Install pnpm 46 | with: 47 | run_install: false 48 | 49 | - name: Pnpm install 50 | run: pnpm i 51 | 52 | - name: Release updater file 53 | run: pnpm updater-fixed-webview2 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .pnpm-store 3 | .DS_Store 4 | dist 5 | dist-ssr 6 | *.local 7 | update.json 8 | scripts/_env.sh 9 | .vscode 10 | .tool-versions 11 | .idea 12 | .old 13 | .eslintcache 14 | .cursor 15 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | echo "[pre-commit] Running lint-staged for JS/TS files..." 5 | # Auto-fix staged JS/TS files, print warnings but don't fail commit 6 | npx lint-staged || true 7 | 8 | # Check staged Rust files 9 | RUST_FILES=$(git diff --cached --name-only | grep -E '^src-tauri/.*\.rs$' || true) 10 | if [ -n "$RUST_FILES" ]; then 11 | echo "[pre-commit] Running rustfmt and clippy on staged Rust files..." 12 | cd src-tauri || exit 13 | 14 | # Auto-format Rust code 15 | cargo fmt 16 | 17 | # Lint with clippy, print warnings but don't fail commit 18 | cargo clippy-all || echo "⚠️ clippy found issues, but commit will continue." 19 | 20 | cd .. 21 | fi 22 | 23 | echo "[pre-commit] Checks completed. Some warnings may exist, please review." 24 | exit 0 25 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | remote_name="$1" 5 | 6 | # --- Rust clippy for staged files in src-tauri --- 7 | if git diff --cached --name-only | grep -q '^src-tauri/'; then 8 | echo "[pre-push] Running clippy on src-tauri..." 9 | cargo clippy --manifest-path ./src-tauri/Cargo.toml -- -D warnings || { 10 | echo "❌ Clippy found issues in src-tauri. Please fix them before pushing." 11 | exit 1 12 | } 13 | fi 14 | 15 | # --- JS/TS format check only for main repo --- 16 | if git remote get-url "$remote_name" >/dev/null 2>&1; then 17 | remote_url=$(git remote get-url "$remote_name") 18 | if [[ "$remote_url" =~ github\.com[:/]+clash-verge-rev/clash-verge-rev(\.git)?$ ]]; then 19 | echo "[pre-push] Detected push to clash-verge-rev/clash-verge-rev ($remote_url)" 20 | echo "[pre-push] Running pnpm format:check..." 21 | if ! pnpm format:check; then 22 | echo "❌ Code format check failed. Please fix formatting before pushing." 23 | exit 1 24 | fi 25 | else 26 | echo "[pre-push] Not pushing to target repo. Skipping format check." 27 | fi 28 | else 29 | echo "[pre-push] Remote '$remote_name' does not exist. Skipping format check." 30 | fi 31 | 32 | echo "[pre-push] All checks passed." 33 | exit 0 34 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # README.md 2 | # UPDATELOG.md 3 | # CONTRIBUTING.md 4 | 5 | pnpm-lock.yaml 6 | 7 | src-tauri/target/ 8 | src-tauri/gen/ 9 | 10 | target 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "jsxSingleQuote": false, 8 | "trailingComma": "all", 9 | "bracketSpacing": true, 10 | "bracketSameLine": false, 11 | "arrowParens": "always", 12 | "proseWrap": "preserve", 13 | "htmlWhitespaceSensitivity": "css", 14 | "endOfLine": "auto", 15 | "embeddedLanguageFormatting": "auto" 16 | } 17 | -------------------------------------------------------------------------------- /1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/1.png -------------------------------------------------------------------------------- /2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/2.png -------------------------------------------------------------------------------- /3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/3.png -------------------------------------------------------------------------------- /4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/4.png -------------------------------------------------------------------------------- /build.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | chcp 65001 >nul 3 | echo ======================================== 4 | echo NeedyClash - Lythrilla Edition 5 | echo 构建脚本 6 | echo ======================================== 7 | echo. 8 | 9 | 10 | echo [3/3] 开始构建... 11 | echo 这可能需要 10-30 分钟,请耐心等待... 12 | echo. 13 | call pnpm run build 14 | if %errorlevel% neq 0 ( 15 | echo [错误] 构建失败 16 | pause 17 | exit /b 1 18 | ) 19 | 20 | echo. 21 | echo ======================================== 22 | echo ✓ 构建完成! 23 | echo ======================================== 24 | echo. 25 | echo 构建产物位置: 26 | echo - EXE 文件: src-tauri\target\release\clash-verge.exe 27 | echo - 安装包: src-tauri\target\release\bundle\ 28 | echo. 29 | pause 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | files: 2 | - source: /src/locales/en.json 3 | translation: /src/locales 4 | multilingual: 1 5 | -------------------------------------------------------------------------------- /docs/preview_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/docs/preview_dark.png -------------------------------------------------------------------------------- /docs/preview_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/docs/preview_light.png -------------------------------------------------------------------------------- /query: -------------------------------------------------------------------------------- 1 | clash-verge-service 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:recommended", ":disableDependencyDashboard"], 3 | "baseBranches": ["dev"], 4 | "enabledManagers": ["cargo", "npm"], 5 | "labels": ["dependencies"], 6 | "ignorePaths": [ 7 | "**/node_modules/**", 8 | "**/bower_components/**", 9 | "**/vendor/**", 10 | "**/__tests__/**", 11 | "**/test/**", 12 | "**/tests/**", 13 | "**/__fixtures__/**", 14 | "**/crate/**", 15 | "shared/**" 16 | ], 17 | "rangeStrategy": "bump", 18 | "packageRules": [ 19 | { 20 | "semanticCommitType": "chore", 21 | "matchPackageNames": ["*"] 22 | }, 23 | { 24 | "description": "Disable node/pnpm version updates", 25 | "matchPackageNames": ["node", "pnpm"], 26 | "matchDepTypes": ["engines", "packageManager"], 27 | "enabled": false 28 | }, 29 | { 30 | "description": "Group all cargo dependencies into a single PR", 31 | "matchManagers": ["cargo"], 32 | "groupName": "cargo dependencies" 33 | }, 34 | { 35 | "description": "Group all npm dependencies into a single PR", 36 | "matchManagers": ["npm"], 37 | "groupName": "npm dependencies" 38 | }, 39 | { 40 | "description": "Group all GitHub Actions updates into a single PR", 41 | "matchManagers": ["github-actions"], 42 | "groupName": "github actions" 43 | } 44 | ], 45 | "postUpdateOptions": ["pnpmDedupe"], 46 | "ignoreDeps": ["criterion"] 47 | } 48 | -------------------------------------------------------------------------------- /scripts-workflow/get_latest_tauri_commit.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 获取最近一个和 Tauri 相关的改动的 commit hash 4 | # This script finds the latest commit that modified Tauri-related files 5 | 6 | # Tauri 相关文件的模式 7 | TAURI_PATTERNS=( 8 | "src-tauri/" 9 | "Cargo.toml" 10 | "Cargo.lock" 11 | "tauri.*.conf.json" 12 | "package.json" 13 | "pnpm-lock.yaml" 14 | "src/" 15 | ) 16 | 17 | # 排除的文件模式(build artifacts 等) 18 | EXCLUDE_PATTERNS=( 19 | "src-tauri/target/" 20 | "src-tauri/gen/" 21 | "*.log" 22 | "*.tmp" 23 | "node_modules/" 24 | ".git/" 25 | ) 26 | 27 | # 构建 git log 的路径过滤参数 28 | PATHS="" 29 | for pattern in "${TAURI_PATTERNS[@]}"; do 30 | if [[ -e "$pattern" ]]; then 31 | PATHS="$PATHS $pattern" 32 | fi 33 | done 34 | 35 | # 如果没有找到相关路径,返回错误 36 | if [[ -z "$PATHS" ]]; then 37 | echo "Error: No Tauri-related paths found in current directory" >&2 38 | exit 1 39 | fi 40 | 41 | # 获取最新的 commit hash 42 | # 使用 git log 查找最近修改了 Tauri 相关文件的提交 43 | LATEST_COMMIT=$(git log --format="%H" -n 1 -- $PATHS) 44 | 45 | # 验证是否找到了 commit 46 | if [[ -z "$LATEST_COMMIT" ]]; then 47 | echo "Error: No commits found for Tauri-related files" >&2 48 | exit 1 49 | fi 50 | 51 | # 输出结果 52 | echo "$LATEST_COMMIT" 53 | 54 | # 如果需要更多信息,可以取消注释以下行 55 | # echo "Latest Tauri-related commit: $LATEST_COMMIT" 56 | # git show --stat --oneline "$LATEST_COMMIT" -------------------------------------------------------------------------------- /scripts/fix-alpha_version.mjs: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import { promisify } from "util"; 3 | import fs from "fs/promises"; 4 | import path from "path"; 5 | 6 | /** 7 | * 为Alpha版本重命名版本号 8 | */ 9 | const execPromise = promisify(exec); 10 | 11 | /** 12 | * 标准输出HEAD hash 13 | */ 14 | async function getLatestCommitHash() { 15 | try { 16 | const { stdout } = await execPromise("git rev-parse HEAD"); 17 | const commitHash = stdout.trim(); 18 | // 格式化,只截取前7位字符 19 | const formathash = commitHash.substring(0, 7); 20 | console.log(`Found the latest commit hash code: ${commitHash}`); 21 | return formathash; 22 | } catch (error) { 23 | console.error("pnpm run fix-alpha-version ERROR", error); 24 | } 25 | } 26 | 27 | /** 28 | * @param string 传入格式化后的hash 29 | * 将新的版本号写入文件 package.json 30 | */ 31 | async function updatePackageVersion(newVersion) { 32 | // 获取内容根目录 33 | const _dirname = process.cwd(); 34 | const packageJsonPath = path.join(_dirname, "package.json"); 35 | try { 36 | // 读取文件 37 | const data = await fs.readFile(packageJsonPath, "utf8"); 38 | const packageJson = JSON.parse(data); 39 | // 获取键值替换 40 | let result = packageJson.version.replace("alpha", newVersion); 41 | // 检查当前版本号是否已经包含了 alpha- 后缀 42 | if (!packageJson.version.includes(`alpha-`)) { 43 | // 如果只有 alpha 而没有 alpha-,则替换为 alpha-newVersion 44 | result = packageJson.version.replace("alpha", `alpha-${newVersion}`); 45 | } else { 46 | // 如果已经是 alpha-xxx 格式,则更新 xxx 部分 47 | result = packageJson.version.replace( 48 | /alpha-[^-]*/, 49 | `alpha-${newVersion}`, 50 | ); 51 | } 52 | console.log("[INFO]: Current version is: ", result); 53 | packageJson.version = result; 54 | // 写入版本号 55 | await fs.writeFile( 56 | packageJsonPath, 57 | JSON.stringify(packageJson, null, 2), 58 | "utf8", 59 | ); 60 | console.log(`[INFO]: Alpha version update to: ${newVersion}`); 61 | } catch (error) { 62 | console.error("pnpm run fix-alpha-version ERROR", error); 63 | } 64 | } 65 | 66 | const newVersion = await getLatestCommitHash(); 67 | updatePackageVersion(newVersion).catch(console.error); 68 | -------------------------------------------------------------------------------- /scripts/portable.mjs: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import AdmZip from "adm-zip"; 4 | import { createRequire } from "module"; 5 | import fsp from "fs/promises"; 6 | 7 | const target = process.argv.slice(2)[0]; 8 | const ARCH_MAP = { 9 | "x86_64-pc-windows-msvc": "x64", 10 | "aarch64-pc-windows-msvc": "arm64", 11 | }; 12 | 13 | const PROCESS_MAP = { 14 | x64: "x64", 15 | arm64: "arm64", 16 | }; 17 | const arch = target ? ARCH_MAP[target] : PROCESS_MAP[process.arch]; 18 | /// Script for ci 19 | /// 打包绿色版/便携版 (only Windows) 20 | async function resolvePortable() { 21 | if (process.platform !== "win32") return; 22 | 23 | const releaseDir = target 24 | ? `./src-tauri/target/${target}/release` 25 | : `./src-tauri/target/release`; 26 | const configDir = path.join(releaseDir, ".config"); 27 | 28 | if (!fs.existsSync(releaseDir)) { 29 | throw new Error("could not found the release dir"); 30 | } 31 | 32 | await fsp.mkdir(configDir, { recursive: true }); 33 | if (!fs.existsSync(path.join(configDir, "PORTABLE"))) { 34 | await fsp.writeFile(path.join(configDir, "PORTABLE"), ""); 35 | } 36 | const zip = new AdmZip(); 37 | 38 | zip.addLocalFile(path.join(releaseDir, "clash-verge.exe")); 39 | zip.addLocalFile(path.join(releaseDir, "verge-mihomo.exe")); 40 | zip.addLocalFile(path.join(releaseDir, "verge-mihomo-alpha.exe")); 41 | zip.addLocalFolder(path.join(releaseDir, "resources"), "resources"); 42 | zip.addLocalFolder(configDir, ".config"); 43 | 44 | const require = createRequire(import.meta.url); 45 | const packageJson = require("../package.json"); 46 | const { version } = packageJson; 47 | const zipFile = `Clash.Verge_${version}_${arch}_portable.zip`; 48 | zip.writeZip(zipFile); 49 | console.log("[INFO]: create portable zip successfully"); 50 | } 51 | 52 | resolvePortable().catch(console.error); 53 | -------------------------------------------------------------------------------- /scripts/publish-version.mjs: -------------------------------------------------------------------------------- 1 | // scripts/publish-version.mjs 2 | import { spawn } from "child_process"; 3 | import { existsSync } from "fs"; 4 | import path from "path"; 5 | 6 | const rootDir = process.cwd(); 7 | const scriptPath = path.join(rootDir, "scripts", "release-version.mjs"); 8 | 9 | if (!existsSync(scriptPath)) { 10 | console.error("release-version.mjs not found!"); 11 | process.exit(1); 12 | } 13 | 14 | const versionArg = process.argv[2]; 15 | if (!versionArg) { 16 | console.error("Usage: pnpm publish-version "); 17 | process.exit(1); 18 | } 19 | 20 | // 1. 调用 release-version.mjs 21 | const runRelease = () => 22 | new Promise((resolve, reject) => { 23 | const child = spawn("node", [scriptPath, versionArg], { stdio: "inherit" }); 24 | child.on("exit", (code) => { 25 | if (code === 0) resolve(); 26 | else reject(new Error("release-version failed")); 27 | }); 28 | }); 29 | 30 | // 2. 判断是否需要打 tag 31 | function isSemver(version) { 32 | return /^v?\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?$/.test(version); 33 | } 34 | 35 | async function run() { 36 | await runRelease(); 37 | 38 | let tag = null; 39 | if (versionArg === "alpha") { 40 | // 读取 package.json 里的主版本 41 | const pkg = await import(path.join(rootDir, "package.json"), { 42 | assert: { type: "json" }, 43 | }); 44 | tag = `v${pkg.default.version}-alpha`; 45 | } else if (isSemver(versionArg)) { 46 | // 1.2.3 或 v1.2.3 47 | tag = versionArg.startsWith("v") ? versionArg : `v${versionArg}`; 48 | } 49 | 50 | if (tag) { 51 | // 打 tag 并推送 52 | const { execSync } = await import("child_process"); 53 | try { 54 | execSync(`git tag ${tag}`, { stdio: "inherit" }); 55 | execSync(`git push origin ${tag}`, { stdio: "inherit" }); 56 | console.log(`[INFO]: Git tag ${tag} created and pushed.`); 57 | } catch { 58 | console.error(`[ERROR]: Failed to create or push git tag: ${tag}`); 59 | process.exit(1); 60 | } 61 | } else { 62 | console.log("[INFO]: No git tag created for this version."); 63 | } 64 | } 65 | 66 | run(); 67 | -------------------------------------------------------------------------------- /scripts/set_dns.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 验证IPv4地址格式 4 | function is_valid_ipv4() { 5 | local ip=$1 6 | local IFS='.' 7 | local -a octets 8 | 9 | [[ ! $ip =~ ^([0-9]+\.){3}[0-9]+$ ]] && return 1 10 | read -r -a octets <<<"$ip" 11 | [ "${#octets[@]}" -ne 4 ] && return 1 12 | 13 | for octet in "${octets[@]}"; do 14 | if ! [[ "$octet" =~ ^[0-9]+$ ]] || ((octet < 0 || octet > 255)); then 15 | return 1 16 | fi 17 | done 18 | return 0 19 | } 20 | 21 | # 验证IPv6地址格式 22 | function is_valid_ipv6() { 23 | local ip=$1 24 | if [[ ! $ip =~ ^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}$ ]] && 25 | [[ ! $ip =~ ^(([0-9a-fA-F]{0,4}:){0,7}:|(:[0-9a-fA-F]{0,4}:){0,6}:[0-9a-fA-F]{0,4})$ ]]; then 26 | return 1 27 | fi 28 | return 0 29 | } 30 | 31 | # 验证IP地址是否为有效的IPv4或IPv6 32 | function is_valid_ip() { 33 | is_valid_ipv4 "$1" || is_valid_ipv6 "$1" 34 | } 35 | 36 | # 检查参数 37 | [ $# -lt 1 ] && echo "Usage: $0 " && exit 1 38 | ! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1 39 | 40 | # 获取网络接口和硬件端口 41 | nic=$(route -n get default | grep "interface" | awk '{print $2}') 42 | hardware_port=$(networksetup -listallhardwareports | awk -v dev="$nic" ' 43 | /Hardware Port:/{port=$0; gsub("Hardware Port: ", "", port)} 44 | /Device: /{if ($2 == dev) {print port; exit}} 45 | ') 46 | 47 | # 获取当前DNS设置 48 | original_dns=$(networksetup -getdnsservers "$hardware_port") 49 | 50 | # 检查当前DNS设置是否有效 51 | is_valid_dns=false 52 | for ip in $original_dns; do 53 | ip=$(echo "$ip" | tr -d '[:space:]') 54 | if [ -n "$ip" ] && (is_valid_ipv4 "$ip" || is_valid_ipv6 "$ip"); then 55 | is_valid_dns=true 56 | break 57 | fi 58 | done 59 | 60 | # 更新DNS设置 61 | if [ "$is_valid_dns" = false ]; then 62 | echo "empty" >.original_dns.txt 63 | else 64 | echo "$original_dns" >.original_dns.txt 65 | fi 66 | networksetup -setdnsservers "$hardware_port" "$1" 67 | -------------------------------------------------------------------------------- /scripts/unset_dns.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | nic=$(route -n get default | grep "interface" | awk '{print $2}') 3 | 4 | hardware_port=$(networksetup -listallhardwareports | awk -v dev="$nic" ' 5 | /Hardware Port:/{ 6 | port=$0; gsub("Hardware Port: ", "", port) 7 | } 8 | /Device: /{ 9 | if ($2 == dev) { 10 | print port; 11 | exit 12 | } 13 | } 14 | ') 15 | 16 | if [ -f .original_dns.txt ]; then 17 | original_dns=$(cat .original_dns.txt) 18 | networksetup -setdnsservers "$hardware_port" $original_dns 19 | rm -rf .original_dns.txt 20 | fi 21 | -------------------------------------------------------------------------------- /scripts/updatelog.mjs: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import fsp from "fs/promises"; 3 | import path from "path"; 4 | 5 | const UPDATE_LOG = "UPDATELOG.md"; 6 | 7 | // parse the UPDATELOG.md 8 | export async function resolveUpdateLog(tag) { 9 | const cwd = process.cwd(); 10 | 11 | const reTitle = /^## v[\d.]+/; 12 | const reEnd = /^---/; 13 | 14 | const file = path.join(cwd, UPDATE_LOG); 15 | 16 | if (!fs.existsSync(file)) { 17 | throw new Error("could not found UPDATELOG.md"); 18 | } 19 | 20 | const data = await fsp.readFile(file, "utf-8"); 21 | 22 | const map = {}; 23 | let p = ""; 24 | 25 | data.split("\n").forEach((line) => { 26 | if (reTitle.test(line)) { 27 | p = line.slice(3).trim(); 28 | if (!map[p]) { 29 | map[p] = []; 30 | } else { 31 | throw new Error(`Tag ${p} dup`); 32 | } 33 | } else if (reEnd.test(line)) { 34 | p = ""; 35 | } else if (p) { 36 | map[p].push(line); 37 | } 38 | }); 39 | 40 | if (!map[tag]) { 41 | throw new Error(`could not found "${tag}" in UPDATELOG.md`); 42 | } 43 | 44 | return map[tag].join("\n").trim(); 45 | } 46 | 47 | export async function resolveUpdateLogDefault() { 48 | const cwd = process.cwd(); 49 | const file = path.join(cwd, UPDATE_LOG); 50 | 51 | if (!fs.existsSync(file)) { 52 | throw new Error("could not found UPDATELOG.md"); 53 | } 54 | 55 | const data = await fsp.readFile(file, "utf-8"); 56 | 57 | const reTitle = /^## v[\d.]+/; 58 | const reEnd = /^---/; 59 | 60 | let isCapturing = false; 61 | let content = []; 62 | let firstTag = ""; 63 | 64 | for (const line of data.split("\n")) { 65 | if (reTitle.test(line) && !isCapturing) { 66 | isCapturing = true; 67 | firstTag = line.slice(3).trim(); 68 | continue; 69 | } 70 | 71 | if (isCapturing) { 72 | if (reEnd.test(line)) { 73 | break; 74 | } 75 | content.push(line); 76 | } 77 | } 78 | 79 | if (!firstTag) { 80 | throw new Error("could not found any version tag in UPDATELOG.md"); 81 | } 82 | 83 | return content.join("\n").trim(); 84 | } 85 | -------------------------------------------------------------------------------- /scripts/utils.mjs: -------------------------------------------------------------------------------- 1 | import clc from "cli-color"; 2 | 3 | export const log_success = (msg, ...optionalParams) => 4 | console.log(clc.green(msg), ...optionalParams); 5 | export const log_error = (msg, ...optionalParams) => 6 | console.log(clc.red(msg), ...optionalParams); 7 | export const log_info = (msg, ...optionalParams) => 8 | console.log(clc.bgBlue(msg), ...optionalParams); 9 | var debugMsg = clc.xterm(245); 10 | export const log_debug = (msg, ...optionalParams) => 11 | console.log(debugMsg(msg), ...optionalParams); 12 | -------------------------------------------------------------------------------- /src-tauri/.clippy.toml: -------------------------------------------------------------------------------- 1 | avoid-breaking-exported-api = true -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | gen/ 5 | WixTools 6 | resources 7 | sidecar 8 | 9 | -------------------------------------------------------------------------------- /src-tauri/assets/fonts/SF-Pro.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/assets/fonts/SF-Pro.ttf -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | #[cfg(feature = "clippy")] 3 | { 4 | println!("cargo:warning=Skipping tauri_build during Clippy"); 5 | } 6 | 7 | #[cfg(not(feature = "clippy"))] 8 | tauri_build::build(); 9 | } 10 | -------------------------------------------------------------------------------- /src-tauri/capabilities/desktop-windows.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "desktop-windows-capability", 3 | "description": "permissions for desktop windows applications", 4 | "windows": ["main"], 5 | "permissions": [ 6 | "core:webview:allow-create-webview", 7 | "core:webview:allow-create-webview-window" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src-tauri/capabilities/desktop.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "desktop-capability", 3 | "platforms": ["macOS", "windows", "linux"], 4 | "webviews": ["main"], 5 | "windows": ["main"], 6 | "permissions": [ 7 | "global-shortcut:default", 8 | "updater:default", 9 | "dialog:default", 10 | "dialog:allow-ask", 11 | "dialog:allow-message", 12 | "updater:default", 13 | "updater:allow-check", 14 | "updater:allow-download-and-install", 15 | "process:allow-restart", 16 | "deep-link:default", 17 | "autostart:allow-enable", 18 | "autostart:allow-disable", 19 | "autostart:allow-is-enabled", 20 | "core:window:allow-set-theme", 21 | "notification:default", 22 | "http:default", 23 | "http:allow-fetch", 24 | { 25 | "identifier": "http:default", 26 | "allow": [{ "url": "https://*/*" }, { "url": "http://*/*" }] 27 | }, 28 | "mihomo:default" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon-mono.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/tray-icon-mono.ico -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon-sys-mono-new.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/tray-icon-sys-mono-new.ico -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon-sys-mono.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/tray-icon-sys-mono.ico -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon-sys.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/tray-icon-sys.ico -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon-tun-mono-new.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/tray-icon-tun-mono-new.ico -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon-tun-mono.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/tray-icon-tun-mono.ico -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon-tun.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/tray-icon-tun.ico -------------------------------------------------------------------------------- /src-tauri/icons/tray-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/icons/tray-icon.ico -------------------------------------------------------------------------------- /src-tauri/images/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src-tauri/images/background.png -------------------------------------------------------------------------------- /src-tauri/packages/linux/clash-verge.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Categories={{{categories}}} 3 | Comment={{{comment}}} 4 | Exec={{{exec}}} %u 5 | StartupWMClass={{{exec}}} 6 | Icon={{{icon}}} 7 | Name={{{name}}} 8 | Terminal=false 9 | Type=Application 10 | MimeType=x-scheme-handler/clash; 11 | -------------------------------------------------------------------------------- /src-tauri/packages/linux/post-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | chmod +x /usr/bin/clash-verge-service-install 3 | chmod +x /usr/bin/clash-verge-service-uninstall 4 | chmod +x /usr/bin/clash-verge-service 5 | -------------------------------------------------------------------------------- /src-tauri/packages/linux/pre-remove.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | /usr/bin/clash-verge-service-uninstall 3 | -------------------------------------------------------------------------------- /src-tauri/packages/macos/entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | io.github.clash-verge-rev.clash-verge-rev 10 | 11 | com.apple.security.inherit 12 | 13 | 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src-tauri/src/cmd/backup.rs: -------------------------------------------------------------------------------- 1 | use super::CmdResult; 2 | use crate::{feat, wrap_err}; 3 | use feat::LocalBackupFile; 4 | 5 | /// Create a local backup 6 | #[tauri::command] 7 | pub async fn create_local_backup() -> CmdResult<()> { 8 | wrap_err!(feat::create_local_backup().await) 9 | } 10 | 11 | /// List local backups 12 | #[tauri::command] 13 | pub fn list_local_backup() -> CmdResult> { 14 | wrap_err!(feat::list_local_backup()) 15 | } 16 | 17 | /// Delete local backup 18 | #[tauri::command] 19 | pub async fn delete_local_backup(filename: String) -> CmdResult<()> { 20 | wrap_err!(feat::delete_local_backup(filename).await) 21 | } 22 | 23 | /// Restore local backup 24 | #[tauri::command] 25 | pub async fn restore_local_backup(filename: String) -> CmdResult<()> { 26 | wrap_err!(feat::restore_local_backup(filename).await) 27 | } 28 | 29 | /// Export local backup to a user selected destination 30 | #[tauri::command] 31 | pub fn export_local_backup(filename: String, destination: String) -> CmdResult<()> { 32 | wrap_err!(feat::export_local_backup(filename, destination)) 33 | } 34 | -------------------------------------------------------------------------------- /src-tauri/src/cmd/lightweight.rs: -------------------------------------------------------------------------------- 1 | use crate::module::lightweight; 2 | 3 | use super::CmdResult; 4 | 5 | #[tauri::command] 6 | pub async fn entry_lightweight_mode() -> CmdResult { 7 | lightweight::entry_lightweight_mode().await; 8 | Ok(()) 9 | } 10 | 11 | #[tauri::command] 12 | pub async fn exit_lightweight_mode() -> CmdResult { 13 | lightweight::exit_lightweight_mode().await; 14 | Ok(()) 15 | } 16 | -------------------------------------------------------------------------------- /src-tauri/src/cmd/media_unlock_checker/claude.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Client; 2 | 3 | use super::UnlockItem; 4 | use super::utils::{country_code_to_emoji, get_local_date_string}; 5 | 6 | const BLOCKED_CODES: [&str; 10] = ["AF", "BY", "CN", "CU", "HK", "IR", "KP", "MO", "RU", "SY"]; 7 | 8 | pub(super) async fn check_claude(client: &Client) -> UnlockItem { 9 | let url = "https://claude.ai/cdn-cgi/trace"; 10 | 11 | match client.get(url).send().await { 12 | Ok(response) => match response.text().await { 13 | Ok(body) => { 14 | let mut country_code: Option = None; 15 | 16 | for line in body.lines() { 17 | if let Some(rest) = line.strip_prefix("loc=") { 18 | country_code = Some(rest.trim().to_uppercase()); 19 | break; 20 | } 21 | } 22 | 23 | if let Some(code) = country_code { 24 | let emoji = country_code_to_emoji(&code); 25 | let status = if BLOCKED_CODES.contains(&code.as_str()) { 26 | "No" 27 | } else { 28 | "Yes" 29 | }; 30 | 31 | UnlockItem { 32 | name: "Claude".to_string(), 33 | status: status.to_string(), 34 | region: Some(format!("{emoji}{code}")), 35 | check_time: Some(get_local_date_string()), 36 | } 37 | } else { 38 | UnlockItem { 39 | name: "Claude".to_string(), 40 | status: "Failed".to_string(), 41 | region: None, 42 | check_time: Some(get_local_date_string()), 43 | } 44 | } 45 | } 46 | Err(_) => UnlockItem { 47 | name: "Claude".to_string(), 48 | status: "Failed".to_string(), 49 | region: None, 50 | check_time: Some(get_local_date_string()), 51 | }, 52 | }, 53 | Err(_) => UnlockItem { 54 | name: "Claude".to_string(), 55 | status: "Failed".to_string(), 56 | region: None, 57 | check_time: Some(get_local_date_string()), 58 | }, 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src-tauri/src/cmd/media_unlock_checker/gemini.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use reqwest::Client; 3 | 4 | use crate::{logging, utils::logging::Type}; 5 | 6 | use super::UnlockItem; 7 | use super::utils::{country_code_to_emoji, get_local_date_string}; 8 | 9 | pub(super) async fn check_gemini(client: &Client) -> UnlockItem { 10 | let url = "https://gemini.google.com"; 11 | 12 | match client.get(url).send().await { 13 | Ok(response) => { 14 | if let Ok(body) = response.text().await { 15 | let is_ok = body.contains("45631641,null,true"); 16 | let status = if is_ok { "Yes" } else { "No" }; 17 | 18 | let re = match Regex::new(r#",2,1,200,"([A-Z]{3})""#) { 19 | Ok(re) => re, 20 | Err(e) => { 21 | logging!( 22 | error, 23 | Type::Network, 24 | "Failed to compile Gemini regex: {}", 25 | e 26 | ); 27 | return UnlockItem { 28 | name: "Gemini".to_string(), 29 | status: "Failed".to_string(), 30 | region: None, 31 | check_time: Some(get_local_date_string()), 32 | }; 33 | } 34 | }; 35 | 36 | let region = re.captures(&body).and_then(|caps| { 37 | caps.get(1).map(|m| { 38 | let country_code = m.as_str(); 39 | let emoji = country_code_to_emoji(country_code); 40 | format!("{emoji}{country_code}") 41 | }) 42 | }); 43 | 44 | UnlockItem { 45 | name: "Gemini".to_string(), 46 | status: status.to_string(), 47 | region, 48 | check_time: Some(get_local_date_string()), 49 | } 50 | } else { 51 | UnlockItem { 52 | name: "Gemini".to_string(), 53 | status: "Failed".to_string(), 54 | region: None, 55 | check_time: Some(get_local_date_string()), 56 | } 57 | } 58 | } 59 | Err(_) => UnlockItem { 60 | name: "Gemini".to_string(), 61 | status: "Failed".to_string(), 62 | region: None, 63 | check_time: Some(get_local_date_string()), 64 | }, 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src-tauri/src/cmd/media_unlock_checker/types.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Serialize, Deserialize)] 4 | pub struct UnlockItem { 5 | pub name: String, 6 | pub status: String, 7 | pub region: Option, 8 | pub check_time: Option, 9 | } 10 | 11 | impl UnlockItem { 12 | pub fn pending(name: &str) -> Self { 13 | Self { 14 | name: name.to_string(), 15 | status: "Pending".to_string(), 16 | region: None, 17 | check_time: None, 18 | } 19 | } 20 | } 21 | 22 | const DEFAULT_UNLOCK_ITEM_NAMES: [&str; 13] = [ 23 | "哔哩哔哩大陆", 24 | "哔哩哔哩港澳台", 25 | "ChatGPT iOS", 26 | "ChatGPT Web", 27 | "Claude", 28 | "Gemini", 29 | "Youtube Premium", 30 | "Bahamut Anime", 31 | "Netflix", 32 | "Disney+", 33 | "Prime Video", 34 | "Spotify", 35 | "TikTok", 36 | ]; 37 | 38 | pub fn default_unlock_items() -> Vec { 39 | DEFAULT_UNLOCK_ITEM_NAMES 40 | .iter() 41 | .map(|name| UnlockItem::pending(name)) 42 | .collect() 43 | } 44 | -------------------------------------------------------------------------------- /src-tauri/src/cmd/media_unlock_checker/utils.rs: -------------------------------------------------------------------------------- 1 | use chrono::Local; 2 | 3 | pub fn get_local_date_string() -> String { 4 | let now = Local::now(); 5 | now.format("%Y-%m-%d %H:%M:%S").to_string() 6 | } 7 | 8 | pub fn country_code_to_emoji(country_code: &str) -> String { 9 | let country_code = country_code.to_uppercase(); 10 | if country_code.len() < 2 { 11 | return String::new(); 12 | } 13 | 14 | let bytes = country_code.as_bytes(); 15 | let c1 = 0x1F1E6 + (bytes[0] as u32) - ('A' as u32); 16 | let c2 = 0x1F1E6 + (bytes[1] as u32) - ('A' as u32); 17 | 18 | char::from_u32(c1) 19 | .and_then(|c1| char::from_u32(c2).map(|c2| format!("{c1}{c2}"))) 20 | .unwrap_or_default() 21 | } 22 | -------------------------------------------------------------------------------- /src-tauri/src/cmd/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | pub type CmdResult = Result; 4 | 5 | // Command modules 6 | pub mod app; 7 | pub mod backup; 8 | pub mod clash; 9 | pub mod lightweight; 10 | pub mod media_unlock_checker; 11 | pub mod network; 12 | pub mod profile; 13 | pub mod proxy; 14 | pub mod runtime; 15 | pub mod save_profile; 16 | pub mod service; 17 | pub mod system; 18 | pub mod tun; 19 | pub mod uwp; 20 | pub mod validate; 21 | pub mod verge; 22 | pub mod webdav; 23 | 24 | // Re-export all command functions for backwards compatibility 25 | pub use app::*; 26 | pub use backup::*; 27 | pub use clash::*; 28 | pub use lightweight::*; 29 | pub use media_unlock_checker::*; 30 | pub use network::*; 31 | pub use profile::*; 32 | pub use proxy::*; 33 | pub use runtime::*; 34 | pub use save_profile::*; 35 | pub use service::*; 36 | pub use system::*; 37 | pub use tun::*; 38 | pub use uwp::*; 39 | pub use validate::*; 40 | pub use verge::*; 41 | pub use webdav::*; 42 | 43 | pub trait StringifyErr { 44 | fn stringify_err(self) -> CmdResult; 45 | fn stringify_err_log(self, log_fn: F) -> CmdResult 46 | where 47 | F: Fn(&str); 48 | } 49 | 50 | impl StringifyErr for Result { 51 | fn stringify_err(self) -> CmdResult { 52 | self.map_err(|e| e.to_string()) 53 | } 54 | 55 | fn stringify_err_log(self, log_fn: F) -> CmdResult 56 | where 57 | F: Fn(&str), 58 | { 59 | self.map_err(|e| { 60 | let msg = e.to_string(); 61 | log_fn(&msg); 62 | msg 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src-tauri/src/cmd/proxy.rs: -------------------------------------------------------------------------------- 1 | use super::CmdResult; 2 | use crate::{logging, utils::logging::Type}; 3 | 4 | // 前端事件驱动更新:通过 emit 发送更新事件,托盘监听更新事件 5 | /// 同步托盘和GUI的代理选择状态 6 | #[tauri::command] 7 | pub async fn sync_tray_proxy_selection() -> CmdResult<()> { 8 | use crate::core::tray::Tray; 9 | 10 | match Tray::global().update_menu().await { 11 | Ok(_) => { 12 | logging!(info, Type::Cmd, "Tray proxy selection synced successfully"); 13 | Ok(()) 14 | } 15 | Err(e) => { 16 | logging!(error, Type::Cmd, "Failed to sync tray proxy selection: {e}"); 17 | Err(e.to_string()) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src-tauri/src/cmd/service.rs: -------------------------------------------------------------------------------- 1 | use super::CmdResult; 2 | use crate::{ 3 | core::service::{self, SERVICE_MANAGER, ServiceStatus}, 4 | utils::i18n::t, 5 | }; 6 | 7 | async fn execute_service_operation_sync(status: ServiceStatus, op_type: &str) -> CmdResult { 8 | if let Err(e) = SERVICE_MANAGER 9 | .lock() 10 | .await 11 | .handle_service_status(&status) 12 | .await 13 | { 14 | let emsg = format!("{} Service failed: {}", op_type, e); 15 | return Err(t(emsg.as_str()).await); 16 | } 17 | Ok(()) 18 | } 19 | 20 | #[tauri::command] 21 | pub async fn install_service() -> CmdResult { 22 | execute_service_operation_sync(ServiceStatus::InstallRequired, "Install").await 23 | } 24 | 25 | #[tauri::command] 26 | pub async fn uninstall_service() -> CmdResult { 27 | execute_service_operation_sync(ServiceStatus::UninstallRequired, "Uninstall").await 28 | } 29 | 30 | #[tauri::command] 31 | pub async fn reinstall_service() -> CmdResult { 32 | execute_service_operation_sync(ServiceStatus::ReinstallRequired, "Reinstall").await 33 | } 34 | 35 | #[tauri::command] 36 | pub async fn repair_service() -> CmdResult { 37 | execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "Repair").await 38 | } 39 | 40 | #[tauri::command] 41 | pub async fn is_service_available() -> CmdResult { 42 | service::is_service_available() 43 | .await 44 | .map(|_| true) 45 | .map_err(|e| e.to_string()) 46 | } 47 | 48 | #[tauri::command] 49 | pub fn is_service_installed() -> CmdResult { 50 | Ok(service::is_service_installed()) 51 | } 52 | -------------------------------------------------------------------------------- /src-tauri/src/cmd/uwp.rs: -------------------------------------------------------------------------------- 1 | use super::CmdResult; 2 | 3 | /// Platform-specific implementation for UWP functionality 4 | #[cfg(windows)] 5 | mod platform { 6 | use super::CmdResult; 7 | use crate::{core::win_uwp, wrap_err}; 8 | 9 | pub fn invoke_uwp_tool() -> CmdResult { 10 | wrap_err!(win_uwp::invoke_uwptools()) 11 | } 12 | } 13 | 14 | /// Stub implementation for non-Windows platforms 15 | #[cfg(not(windows))] 16 | mod platform { 17 | use super::CmdResult; 18 | 19 | pub fn invoke_uwp_tool() -> CmdResult { 20 | Ok(()) 21 | } 22 | } 23 | 24 | /// Command exposed to Tauri 25 | #[tauri::command] 26 | pub async fn invoke_uwp_tool() -> CmdResult { 27 | platform::invoke_uwp_tool() 28 | } 29 | -------------------------------------------------------------------------------- /src-tauri/src/cmd/verge.rs: -------------------------------------------------------------------------------- 1 | use super::CmdResult; 2 | use crate::{config::*, feat, wrap_err}; 3 | 4 | /// 获取Verge配置 5 | #[tauri::command] 6 | pub async fn get_verge_config() -> CmdResult { 7 | let verge = Config::verge().await; 8 | let verge_data = { 9 | let ref_data = verge.latest_ref(); 10 | ref_data.clone() 11 | }; 12 | let verge_response = IVergeResponse::from(*verge_data); 13 | Ok(verge_response) 14 | } 15 | 16 | /// 修改Verge配置 17 | #[tauri::command] 18 | pub async fn patch_verge_config(payload: IVerge) -> CmdResult { 19 | wrap_err!(feat::patch_verge(payload, false).await) 20 | } 21 | -------------------------------------------------------------------------------- /src-tauri/src/cmd/webdav.rs: -------------------------------------------------------------------------------- 1 | use super::CmdResult; 2 | use crate::{config::*, core, feat, wrap_err}; 3 | use reqwest_dav::list_cmd::ListFile; 4 | 5 | /// 保存 WebDAV 配置 6 | #[tauri::command] 7 | pub async fn save_webdav_config(url: String, username: String, password: String) -> CmdResult<()> { 8 | let patch = IVerge { 9 | webdav_url: Some(url), 10 | webdav_username: Some(username), 11 | webdav_password: Some(password), 12 | ..IVerge::default() 13 | }; 14 | Config::verge() 15 | .await 16 | .draft_mut() 17 | .patch_config(patch.clone()); 18 | Config::verge().await.apply(); 19 | 20 | // 分离数据获取和异步调用 21 | let verge_data = Config::verge().await.latest_ref().clone(); 22 | verge_data 23 | .save_file() 24 | .await 25 | .map_err(|err| err.to_string())?; 26 | core::backup::WebDavClient::global().reset(); 27 | Ok(()) 28 | } 29 | 30 | /// 创建 WebDAV 备份并上传 31 | #[tauri::command] 32 | pub async fn create_webdav_backup() -> CmdResult<()> { 33 | wrap_err!(feat::create_backup_and_upload_webdav().await) 34 | } 35 | 36 | /// 列出 WebDAV 上的备份文件 37 | #[tauri::command] 38 | pub async fn list_webdav_backup() -> CmdResult> { 39 | wrap_err!(feat::list_wevdav_backup().await) 40 | } 41 | 42 | /// 删除 WebDAV 上的备份文件 43 | #[tauri::command] 44 | pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> { 45 | wrap_err!(feat::delete_webdav_backup(filename).await) 46 | } 47 | 48 | /// 从 WebDAV 恢复备份文件 49 | #[tauri::command] 50 | pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> { 51 | wrap_err!(feat::restore_webdav_backup(filename).await) 52 | } 53 | -------------------------------------------------------------------------------- /src-tauri/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | mod clash; 2 | #[allow(clippy::module_inception)] 3 | mod config; 4 | mod encrypt; 5 | mod prfitem; 6 | pub mod profiles; 7 | mod runtime; 8 | mod verge; 9 | 10 | pub use self::{clash::*, config::*, encrypt::*, prfitem::*, profiles::*, runtime::*, verge::*}; 11 | 12 | pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) { 13 | return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;"; 14 | } 15 | "#; 16 | -------------------------------------------------------------------------------- /src-tauri/src/core/logger.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::VecDeque, sync::Arc}; 2 | 3 | use compact_str::CompactString; 4 | use once_cell::sync::OnceCell; 5 | use parking_lot::{RwLock, RwLockReadGuard}; 6 | 7 | const LOGS_QUEUE_LEN: usize = 100; 8 | 9 | pub struct ClashLogger { 10 | logs: Arc>>, 11 | } 12 | 13 | impl ClashLogger { 14 | pub fn global() -> &'static ClashLogger { 15 | static LOGGER: OnceCell = OnceCell::new(); 16 | 17 | LOGGER.get_or_init(|| ClashLogger { 18 | logs: Arc::new(RwLock::new(VecDeque::with_capacity(LOGS_QUEUE_LEN + 10))), 19 | }) 20 | } 21 | 22 | pub fn get_logs(&self) -> RwLockReadGuard<'_, VecDeque> { 23 | self.logs.read() 24 | } 25 | 26 | pub fn append_log(&self, text: CompactString) { 27 | let mut logs = self.logs.write(); 28 | if logs.len() > LOGS_QUEUE_LEN { 29 | logs.pop_front(); 30 | } 31 | logs.push_back(text); 32 | } 33 | 34 | pub fn clear_logs(&self) { 35 | let mut logs = self.logs.write(); 36 | logs.clear(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src-tauri/src/core/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod async_proxy_query; 2 | pub mod backup; 3 | #[allow(clippy::module_inception)] 4 | mod config_validator; 5 | mod core; 6 | pub mod event_driven_proxy; 7 | pub mod handle; 8 | pub mod hotkey; 9 | pub mod logger; 10 | mod process_manager; 11 | pub mod service; 12 | pub mod sysopt; 13 | pub mod timer; 14 | pub mod tray; 15 | pub mod tun_manager; 16 | pub mod win_uwp; 17 | 18 | pub use self::{core::*, event_driven_proxy::EventDrivenProxyManager, timer::Timer}; 19 | -------------------------------------------------------------------------------- /src-tauri/src/core/tray/speed_rate.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src-tauri/src/core/win_uwp.rs: -------------------------------------------------------------------------------- 1 | #![cfg(target_os = "windows")] 2 | 3 | use crate::utils::dirs; 4 | use anyhow::{Result, bail}; 5 | use deelevate::{PrivilegeLevel, Token}; 6 | use runas::Command as RunasCommand; 7 | use std::process::Command as StdCommand; 8 | 9 | pub 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 | // This function is exported for use by the Clash core 2 | // eslint-disable-next-line no-unused-vars 3 | function main(config, _name) { 4 | if (config.mode === "script") { 5 | config.mode = "rule"; 6 | } 7 | return config; 8 | } 9 | -------------------------------------------------------------------------------- /src-tauri/src/enhance/builtin/meta_hy_alpn.js: -------------------------------------------------------------------------------- 1 | // This function is exported for use by the Clash core 2 | // eslint-disable-next-line no-unused-vars 3 | function main(config, _name) { 4 | if (Array.isArray(config.proxies)) { 5 | config.proxies.forEach((p, i) => { 6 | if (p.type === "hysteria" && typeof p.alpn === "string") { 7 | config.proxies[i].alpn = [p.alpn]; 8 | } 9 | }); 10 | } 11 | return config; 12 | } 13 | -------------------------------------------------------------------------------- /src-tauri/src/enhance/field.rs: -------------------------------------------------------------------------------- 1 | use serde_yaml_ng::{Mapping, Value}; 2 | use std::collections::HashSet; 3 | 4 | pub const HANDLE_FIELDS: [&str; 12] = [ 5 | "mode", 6 | "redir-port", 7 | "tproxy-port", 8 | "mixed-port", 9 | "socks-port", 10 | "port", 11 | "allow-lan", 12 | "log-level", 13 | "ipv6", 14 | "external-controller", 15 | "secret", 16 | "unified-delay", 17 | ]; 18 | 19 | pub const DEFAULT_FIELDS: [&str; 5] = [ 20 | "proxies", 21 | "proxy-providers", 22 | "proxy-groups", 23 | "rule-providers", 24 | "rules", 25 | ]; 26 | 27 | pub fn use_lowercase(config: Mapping) -> Mapping { 28 | let mut ret = Mapping::new(); 29 | 30 | for (key, value) in config.into_iter() { 31 | if let Some(key_str) = key.as_str() { 32 | let mut key_str = String::from(key_str); 33 | key_str.make_ascii_lowercase(); 34 | ret.insert(Value::from(key_str), value); 35 | } 36 | } 37 | ret 38 | } 39 | 40 | pub fn use_sort(config: Mapping) -> Mapping { 41 | let mut ret = Mapping::new(); 42 | HANDLE_FIELDS.into_iter().for_each(|key| { 43 | let key = Value::from(key); 44 | if let Some(value) = config.get(&key) { 45 | ret.insert(key, value.clone()); 46 | } 47 | }); 48 | 49 | let supported_keys: HashSet<&str> = HANDLE_FIELDS.into_iter().chain(DEFAULT_FIELDS).collect(); 50 | 51 | let config_keys: HashSet<&str> = config.keys().filter_map(|e| e.as_str()).collect(); 52 | 53 | config_keys.difference(&supported_keys).for_each(|&key| { 54 | let key = Value::from(key); 55 | if let Some(value) = config.get(&key) { 56 | ret.insert(key, value.clone()); 57 | } 58 | }); 59 | DEFAULT_FIELDS.into_iter().for_each(|key| { 60 | let key = Value::from(key); 61 | if let Some(value) = config.get(&key) { 62 | ret.insert(key, value.clone()); 63 | } 64 | }); 65 | 66 | ret 67 | } 68 | 69 | pub fn use_keys(config: &Mapping) -> Vec { 70 | config 71 | .iter() 72 | .filter_map(|(key, _)| key.as_str()) 73 | .map(|s| { 74 | let mut s = s.to_string(); 75 | s.make_ascii_lowercase(); 76 | s 77 | }) 78 | .collect() 79 | } 80 | -------------------------------------------------------------------------------- /src-tauri/src/enhance/merge.rs: -------------------------------------------------------------------------------- 1 | use super::use_lowercase; 2 | use serde_yaml_ng::{self, Mapping, Value}; 3 | 4 | fn deep_merge(a: &mut Value, b: &Value) { 5 | match (a, b) { 6 | (&mut Value::Mapping(ref mut a), Value::Mapping(b)) => { 7 | for (k, v) in b { 8 | deep_merge(a.entry(k.clone()).or_insert(Value::Null), v); 9 | } 10 | } 11 | (a, b) => *a = b.clone(), 12 | } 13 | } 14 | 15 | pub fn use_merge(merge: Mapping, config: Mapping) -> Mapping { 16 | let mut config = Value::from(config); 17 | let merge = use_lowercase(merge.clone()); 18 | 19 | deep_merge(&mut config, &Value::from(merge)); 20 | 21 | config.as_mapping().cloned().unwrap_or_else(|| { 22 | log::error!("Failed to convert merged config to mapping, using empty mapping"); 23 | Mapping::new() 24 | }) 25 | } 26 | 27 | #[test] 28 | fn test_merge() -> anyhow::Result<()> { 29 | let merge = r" 30 | prepend-rules: 31 | - prepend 32 | - 1123123 33 | append-rules: 34 | - append 35 | prepend-proxies: 36 | - 9999 37 | append-proxies: 38 | - 1111 39 | rules: 40 | - replace 41 | proxy-groups: 42 | - 123781923810 43 | tun: 44 | enable: true 45 | dns: 46 | enable: true 47 | "; 48 | 49 | let config = r" 50 | rules: 51 | - aaaaa 52 | script1: test 53 | "; 54 | 55 | let merge = serde_yaml_ng::from_str::(merge)?; 56 | let config = serde_yaml_ng::from_str::(config)?; 57 | 58 | let _ = serde_yaml_ng::to_string(&use_merge(merge, config))?; 59 | 60 | Ok(()) 61 | } 62 | -------------------------------------------------------------------------------- /src-tauri/src/feat/mod.rs: -------------------------------------------------------------------------------- 1 | mod backup; 2 | mod clash; 3 | mod config; 4 | mod config_flags; 5 | mod profile; 6 | mod proxy; 7 | mod window; 8 | 9 | // Re-export all functions from modules 10 | pub use backup::*; 11 | pub use clash::*; 12 | pub use config::*; 13 | pub use profile::*; 14 | pub use proxy::*; 15 | pub use window::*; 16 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 2 | fn main() { 3 | #[cfg(feature = "tokio-trace")] 4 | console_subscriber::init(); 5 | 6 | // Check for --no-tray command line argument 7 | let args: Vec = std::env::args().collect(); 8 | if args.contains(&"--no-tray".into()) { 9 | unsafe { 10 | std::env::set_var("CLASH_VERGE_DISABLE_TRAY", "1"); 11 | } 12 | } 13 | 14 | app_lib::run(); 15 | } 16 | -------------------------------------------------------------------------------- /src-tauri/src/module/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod lightweight; 2 | pub mod sysinfo; 3 | -------------------------------------------------------------------------------- /src-tauri/src/module/sysinfo.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | cmd::system, 3 | core::{CoreManager, handle}, 4 | }; 5 | use std::fmt::{self, Debug, Formatter}; 6 | use sysinfo::System; 7 | 8 | pub struct PlatformSpecification { 9 | system_name: String, 10 | system_version: String, 11 | system_kernel_version: String, 12 | system_arch: String, 13 | verge_version: String, 14 | running_mode: String, 15 | is_admin: bool, 16 | } 17 | 18 | impl Debug for PlatformSpecification { 19 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 20 | write!( 21 | f, 22 | "System Name: {}\nSystem Version: {}\nSystem kernel Version: {}\nSystem Arch: {}\nVerge Version: {}\nRunning Mode: {}\nIs Admin: {}", 23 | self.system_name, 24 | self.system_version, 25 | self.system_kernel_version, 26 | self.system_arch, 27 | self.verge_version, 28 | self.running_mode, 29 | self.is_admin 30 | ) 31 | } 32 | } 33 | 34 | impl PlatformSpecification { 35 | pub fn new() -> Self { 36 | let system_name = System::name().unwrap_or("Null".into()); 37 | let system_version = System::long_os_version().unwrap_or("Null".into()); 38 | let system_kernel_version = System::kernel_version().unwrap_or("Null".into()); 39 | let system_arch = System::cpu_arch(); 40 | 41 | let handler = handle::Handle::app_handle(); 42 | let verge_version = handler.package_info().version.to_string(); 43 | 44 | // 使用默认值避免在同步上下文中执行异步操作 45 | let running_mode = "NotRunning".to_string(); 46 | 47 | let is_admin = system::is_admin().unwrap_or_default(); 48 | 49 | Self { 50 | system_name, 51 | system_version, 52 | system_kernel_version, 53 | system_arch, 54 | verge_version, 55 | running_mode, 56 | is_admin, 57 | } 58 | } 59 | 60 | // 异步方法来获取完整的系统信息 61 | pub fn new_sync() -> Self { 62 | let mut info = Self::new(); 63 | 64 | let running_mode = CoreManager::global().get_running_mode(); 65 | info.running_mode = running_mode.to_string(); 66 | 67 | info 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src-tauri/src/process/mod.rs: -------------------------------------------------------------------------------- 1 | mod async_handler; 2 | pub use async_handler::AsyncHandler; 3 | mod guard; 4 | pub use guard::CommandChildGuard; 5 | -------------------------------------------------------------------------------- /src-tauri/src/utils/format.rs: -------------------------------------------------------------------------------- 1 | /// Format bytes into human readable string (B, KB, MB, GB) 2 | #[allow(unused)] 3 | pub fn fmt_bytes(bytes: u64) -> String { 4 | const UNITS: &[&str] = &["B", "KB", "MB", "GB"]; 5 | let (mut val, mut unit) = (bytes as f64, 0); 6 | while val >= 1024.0 && unit < 3 { 7 | val /= 1024.0; 8 | unit += 1; 9 | } 10 | format!("{:.1}{}", val, UNITS[unit]) 11 | } 12 | 13 | #[cfg(test)] 14 | mod tests { 15 | use super::*; 16 | 17 | #[test] 18 | fn test_fmt_bytes() { 19 | assert_eq!(fmt_bytes(0), "0.0B"); 20 | assert_eq!(fmt_bytes(512), "512.0B"); 21 | assert_eq!(fmt_bytes(1024), "1.0KB"); 22 | assert_eq!(fmt_bytes(1536), "1.5KB"); 23 | assert_eq!(fmt_bytes(1024 * 1024), "1.0MB"); 24 | assert_eq!(fmt_bytes(1024 * 1024 * 1024), "1.0GB"); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src-tauri/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod autostart; 2 | pub mod dirs; 3 | pub mod draft; 4 | pub mod format; 5 | pub mod help; 6 | pub mod i18n; 7 | pub mod init; 8 | #[cfg(target_os = "linux")] 9 | pub mod linux; 10 | pub mod logging; 11 | pub mod network; 12 | pub mod notification; 13 | pub mod permission; 14 | pub mod resolve; 15 | pub mod server; 16 | pub mod singleton; 17 | pub mod tmpl; 18 | pub mod window_manager; 19 | 20 | pub use draft::Draft; 21 | -------------------------------------------------------------------------------- /src-tauri/src/utils/notification.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::i18n::t; 2 | 3 | use tauri::AppHandle; 4 | use tauri_plugin_notification::NotificationExt; 5 | 6 | pub enum NotificationEvent<'a> { 7 | DashboardToggled, 8 | ClashModeChanged { 9 | mode: &'a str, 10 | }, 11 | SystemProxyToggled, 12 | TunModeToggled, 13 | LightweightModeEntered, 14 | AppQuit, 15 | #[cfg(target_os = "macos")] 16 | AppHidden, 17 | } 18 | 19 | fn notify(app: &AppHandle, title: &str, body: &str) { 20 | app.notification() 21 | .builder() 22 | .title(title) 23 | .body(body) 24 | .show() 25 | .ok(); 26 | } 27 | 28 | pub async fn notify_event<'a>(app: AppHandle, event: NotificationEvent<'a>) { 29 | match event { 30 | NotificationEvent::DashboardToggled => { 31 | notify( 32 | &app, 33 | &t("DashboardToggledTitle").await, 34 | &t("DashboardToggledBody").await, 35 | ); 36 | } 37 | NotificationEvent::ClashModeChanged { mode } => { 38 | notify( 39 | &app, 40 | &t("ClashModeChangedTitle").await, 41 | &t_with_args("ClashModeChangedBody", mode).await, 42 | ); 43 | } 44 | NotificationEvent::SystemProxyToggled => { 45 | notify( 46 | &app, 47 | &t("SystemProxyToggledTitle").await, 48 | &t("SystemProxyToggledBody").await, 49 | ); 50 | } 51 | NotificationEvent::TunModeToggled => { 52 | notify( 53 | &app, 54 | &t("TunModeToggledTitle").await, 55 | &t("TunModeToggledBody").await, 56 | ); 57 | } 58 | NotificationEvent::LightweightModeEntered => { 59 | notify( 60 | &app, 61 | &t("LightweightModeEnteredTitle").await, 62 | &t("LightweightModeEnteredBody").await, 63 | ); 64 | } 65 | NotificationEvent::AppQuit => { 66 | notify(&app, &t("AppQuitTitle").await, &t("AppQuitBody").await); 67 | } 68 | #[cfg(target_os = "macos")] 69 | NotificationEvent::AppHidden => { 70 | notify(&app, &t("AppHiddenTitle").await, &t("AppHiddenBody").await); 71 | } 72 | } 73 | } 74 | 75 | // 辅助函数,带参数的i18n 76 | async fn t_with_args(key: &str, mode: &str) -> String { 77 | t(key).await.replace("{mode}", mode) 78 | } 79 | -------------------------------------------------------------------------------- /src-tauri/src/utils/permission.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Result, bail}; 2 | 3 | /// 检查当前进程是否具有管理员/root权限 4 | pub fn check_admin_privileges() -> Result { 5 | #[cfg(target_os = "windows")] 6 | { 7 | use deelevate::{PrivilegeLevel, Token}; 8 | Token::with_current_process() 9 | .and_then(|token| token.privilege_level()) 10 | .map(|level| level != PrivilegeLevel::NotPrivileged) 11 | .map_err(|e| anyhow::anyhow!("Failed to check privilege level: {}", e)) 12 | } 13 | 14 | #[cfg(not(target_os = "windows"))] 15 | { 16 | Ok(unsafe { libc::geteuid() } == 0) 17 | } 18 | } 19 | 20 | /// 检查是否在服务模式或管理员模式下运行 21 | pub fn check_elevated_privileges() -> Result<()> { 22 | use crate::core::{CoreManager, RunningMode}; 23 | 24 | let running_mode = CoreManager::global().get_running_mode(); 25 | 26 | if running_mode == RunningMode::Service { 27 | return Ok(()); 28 | } 29 | 30 | let is_admin = check_admin_privileges()?; 31 | 32 | if !is_admin { 33 | #[cfg(target_os = "windows")] 34 | bail!("This operation requires Service Mode or Administrator privileges"); 35 | 36 | #[cfg(not(target_os = "windows"))] 37 | bail!("This operation requires Service Mode or root privileges"); 38 | } 39 | 40 | Ok(()) 41 | } 42 | -------------------------------------------------------------------------------- /src-tauri/src/utils/resolve/ui.rs: -------------------------------------------------------------------------------- 1 | use once_cell::sync::OnceCell; 2 | use parking_lot::RwLock; 3 | use std::sync::{ 4 | Arc, 5 | atomic::{AtomicBool, Ordering}, 6 | }; 7 | use tokio::sync::Notify; 8 | 9 | use crate::{logging, utils::logging::Type}; 10 | 11 | // 使用 AtomicBool 替代 RwLock,性能更好且无锁 12 | static UI_READY: OnceCell = OnceCell::new(); 13 | // 获取UI就绪状态细节 14 | static UI_READY_STATE: OnceCell = OnceCell::new(); 15 | // 添加通知机制,用于事件驱动的 UI 就绪检测 16 | static UI_READY_NOTIFY: OnceCell> = OnceCell::new(); 17 | 18 | // UI就绪阶段状态枚举 19 | #[derive(Debug, Clone, Copy, PartialEq)] 20 | pub enum UiReadyStage { 21 | NotStarted, 22 | Loading, 23 | DomReady, 24 | ResourcesLoaded, 25 | Ready, 26 | } 27 | 28 | // UI就绪详细状态 29 | #[derive(Debug)] 30 | struct UiReadyState { 31 | stage: RwLock, 32 | } 33 | 34 | impl Default for UiReadyState { 35 | fn default() -> Self { 36 | Self { 37 | stage: RwLock::new(UiReadyStage::NotStarted), 38 | } 39 | } 40 | } 41 | 42 | pub(super) fn get_ui_ready() -> &'static AtomicBool { 43 | UI_READY.get_or_init(|| AtomicBool::new(false)) 44 | } 45 | 46 | fn get_ui_ready_state() -> &'static UiReadyState { 47 | UI_READY_STATE.get_or_init(UiReadyState::default) 48 | } 49 | 50 | fn get_ui_ready_notify() -> &'static Arc { 51 | UI_READY_NOTIFY.get_or_init(|| Arc::new(Notify::new())) 52 | } 53 | 54 | pub fn update_ui_ready_stage(stage: UiReadyStage) { 55 | let state = get_ui_ready_state(); 56 | let mut stage_lock = state.stage.write(); 57 | 58 | *stage_lock = stage; 59 | if stage == UiReadyStage::Ready { 60 | mark_ui_ready(); 61 | } 62 | } 63 | 64 | // 标记UI已准备就绪 65 | pub fn mark_ui_ready() { 66 | get_ui_ready().store(true, Ordering::Release); 67 | logging!(info, Type::Window, "UI已标记为完全就绪"); 68 | 69 | // 通知所有等待的任务 70 | get_ui_ready_notify().notify_waiters(); 71 | } 72 | -------------------------------------------------------------------------------- /src-tauri/src/utils/resolve/window.rs: -------------------------------------------------------------------------------- 1 | use tauri::WebviewWindow; 2 | 3 | use crate::{ 4 | config::Config, 5 | core::handle, 6 | logging_error, 7 | utils::{ 8 | logging::Type, 9 | resolve::window_script::{INITIAL_LOADING_OVERLAY, WINDOW_INITIAL_SCRIPT}, 10 | }, 11 | }; 12 | 13 | // 定义默认窗口尺寸常量 14 | const DEFAULT_WIDTH: f64 = 940.0; 15 | const DEFAULT_HEIGHT: f64 = 700.0; 16 | 17 | const MINIMAL_WIDTH: f64 = 520.0; 18 | const MINIMAL_HEIGHT: f64 = 520.0; 19 | 20 | pub async fn build_new_window() -> Result { 21 | let app_handle = handle::Handle::app_handle(); 22 | 23 | // 读取配置以确定是否使用系统标题栏 24 | let use_system_titlebar = Config::verge() 25 | .await 26 | .latest_ref() 27 | .window_use_system_titlebar 28 | .unwrap_or(false); 29 | 30 | match tauri::WebviewWindowBuilder::new( 31 | app_handle, 32 | "main", /* the unique window label */ 33 | tauri::WebviewUrl::App("index.html".into()), 34 | ) 35 | .title("NeedyClash") 36 | .center() 37 | .decorations(use_system_titlebar) // 根据配置决定是否使用系统标题栏 38 | .fullscreen(false) 39 | .inner_size(DEFAULT_WIDTH, DEFAULT_HEIGHT) 40 | .min_inner_size(MINIMAL_WIDTH, MINIMAL_HEIGHT) 41 | .visible(true) 42 | .initialization_script(WINDOW_INITIAL_SCRIPT) 43 | .build() 44 | { 45 | Ok(window) => { 46 | logging_error!(Type::Window, window.eval(INITIAL_LOADING_OVERLAY)); 47 | Ok(window) 48 | } 49 | Err(e) => Err(e.to_string()), 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /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 NeedyClash 5 | 6 | proxies: [] 7 | 8 | proxy-groups: [] 9 | 10 | rules: [] 11 | "; 12 | 13 | /// enhanced profile 14 | pub const ITEM_MERGE: &str = "# Profile Enhancement Merge Template for NeedyClash 15 | 16 | profile: 17 | store-selected: true 18 | "; 19 | 20 | pub const ITEM_MERGE_EMPTY: &str = "# Profile Enhancement Merge Template for NeedyClash 21 | 22 | "; 23 | 24 | /// enhanced profile 25 | pub const ITEM_SCRIPT: &str = "// Define main function (script entry) 26 | 27 | function main(config, profileName) { 28 | return config; 29 | } 30 | "; 31 | 32 | /// enhanced profile 33 | pub const ITEM_RULES: &str = "# Profile Enhancement Rules Template for NeedyClash 34 | 35 | prepend: [] 36 | 37 | append: [] 38 | 39 | delete: [] 40 | "; 41 | 42 | /// enhanced profile 43 | pub const ITEM_PROXIES: &str = "# Profile Enhancement Proxies Template for NeedyClash 44 | 45 | prepend: [] 46 | 47 | append: [] 48 | 49 | delete: [] 50 | "; 51 | 52 | /// enhanced profile 53 | pub const ITEM_GROUPS: &str = "# Profile Enhancement Groups Template for NeedyClash 54 | 55 | prepend: [] 56 | 57 | append: [] 58 | 59 | delete: [] 60 | "; 61 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.1", 3 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 4 | "bundle": { 5 | "active": true, 6 | "longDescription": "NeedyClash - A secondary development version based on NeedyClash", 7 | "icon": [ 8 | "icons/32x32.png", 9 | "icons/128x128.png", 10 | "icons/128x128@2x.png", 11 | "icons/icon.icns", 12 | "icons/icon.ico" 13 | ], 14 | "resources": ["resources", "resources/locales/*"], 15 | "publisher": "Lythrilla", 16 | "externalBin": ["sidecar/verge-mihomo", "sidecar/verge-mihomo-alpha"], 17 | "copyright": "NeedyClash by Lythrilla - Based on NeedyClash (GPL-3.0)", 18 | "category": "DeveloperTool", 19 | "shortDescription": "NeedyClash - Secondary development of NeedyClash", 20 | "createUpdaterArtifacts": false 21 | }, 22 | "build": { 23 | "beforeBuildCommand": "pnpm run web:build", 24 | "frontendDist": "../dist", 25 | "beforeDevCommand": "pnpm run web:dev", 26 | "devUrl": "http://localhost:3000/" 27 | }, 28 | "productName": "NeedyClash", 29 | "identifier": "com.lythrilla.needyclash", 30 | "plugins": { 31 | "deep-link": { 32 | "desktop": { 33 | "schemes": ["clash", "clash-verge"] 34 | } 35 | }, 36 | "updater": { 37 | "active": false, 38 | "endpoints": [], 39 | "dialog": true, 40 | "pubkey": "" 41 | } 42 | }, 43 | "app": { 44 | "security": { 45 | "capabilities": ["desktop-capability", "migrated"], 46 | "assetProtocol": { 47 | "enable": true, 48 | "scope": { 49 | "allow": ["**"], 50 | "requireLiteralLeadingDot": false 51 | } 52 | }, 53 | "csp": null 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src-tauri/tauri.linux.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 3 | "identifier": "io.github.clash-verge-rev.clash-verge-rev", 4 | "bundle": { 5 | "targets": ["deb", "rpm"], 6 | "linux": { 7 | "deb": { 8 | "depends": ["openssl"], 9 | "desktopTemplate": "./packages/linux/clash-verge.desktop", 10 | "provides": ["clash-verge"], 11 | "conflicts": ["clash-verge"], 12 | "replaces": ["clash-verge"], 13 | "postInstallScript": "./packages/linux/post-install.sh", 14 | "preRemoveScript": "./packages/linux/pre-remove.sh" 15 | }, 16 | "rpm": { 17 | "depends": ["openssl"], 18 | "desktopTemplate": "./packages/linux/clash-verge.desktop", 19 | "provides": ["clash-verge"], 20 | "conflicts": ["clash-verge"], 21 | "obsoletes": ["clash-verge"], 22 | "postInstallScript": "./packages/linux/post-install.sh", 23 | "preRemoveScript": "./packages/linux/pre-remove.sh" 24 | } 25 | }, 26 | "externalBin": [ 27 | "./resources/clash-verge-service", 28 | "./resources/clash-verge-service-install", 29 | "./resources/clash-verge-service-uninstall", 30 | "./sidecar/verge-mihomo", 31 | "./sidecar/verge-mihomo-alpha" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src-tauri/tauri.macos.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 3 | "identifier": "io.github.clash-verge-rev.clash-verge-rev", 4 | "productName": "NeedyClash", 5 | "bundle": { 6 | "targets": ["app", "dmg"], 7 | "macOS": { 8 | "frameworks": [], 9 | "minimumSystemVersion": "10.15", 10 | "exceptionDomain": "", 11 | "signingIdentity": null, 12 | "entitlements": "packages/macos/entitlements.plist", 13 | "dmg": { 14 | "background": "images/background.png", 15 | "appPosition": { 16 | "x": 180, 17 | "y": 170 18 | }, 19 | "applicationFolderPosition": { 20 | "x": 480, 21 | "y": 170 22 | }, 23 | "windowSize": { 24 | "height": 400, 25 | "width": 660 26 | }, 27 | "windowPosition": { 28 | "x": 200, 29 | "y": 180 30 | } 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src-tauri/tauri.windows.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 3 | "identifier": "io.github.clash-verge-rev.clash-verge-rev", 4 | "bundle": { 5 | "targets": ["nsis"], 6 | "windows": { 7 | "certificateThumbprint": null, 8 | "digestAlgorithm": "sha256", 9 | "timestampUrl": "", 10 | "webviewInstallMode": { 11 | "type": "embedBootstrapper", 12 | "silent": true 13 | }, 14 | "nsis": { 15 | "displayLanguageSelector": true, 16 | "installerIcon": "icons/icon.ico", 17 | "languages": ["SimpChinese", "English"], 18 | "installMode": "perMachine", 19 | "template": "./packages/windows/installer.nsi" 20 | } 21 | } 22 | }, 23 | "app": { 24 | "windows": [], 25 | "security": { 26 | "capabilities": [ 27 | "desktop-capability", 28 | "desktop-windows-capability", 29 | "migrated" 30 | ] 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src-tauri/webview2.arm64.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 3 | "identifier": "io.github.clash-verge-rev.clash-verge-rev", 4 | "bundle": { 5 | "targets": ["nsis"], 6 | "windows": { 7 | "certificateThumbprint": null, 8 | "digestAlgorithm": "sha256", 9 | "timestampUrl": "", 10 | "webviewInstallMode": { 11 | "type": "fixedRuntime", 12 | "path": "./Microsoft.WebView2.FixedVersionRuntime.109.0.1518.78.arm64/" 13 | }, 14 | "nsis": { 15 | "displayLanguageSelector": true, 16 | "installerIcon": "icons/icon.ico", 17 | "languages": ["SimpChinese", "English"], 18 | "installMode": "perMachine", 19 | "template": "./packages/windows/installer.nsi" 20 | } 21 | } 22 | }, 23 | "plugins": { 24 | "updater": { 25 | "active": true, 26 | "dialog": false, 27 | "endpoints": [ 28 | "https://download.NeedyClash.dev/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json", 29 | "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json" 30 | ], 31 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src-tauri/webview2.x64.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 3 | "identifier": "io.github.clash-verge-rev.clash-verge-rev", 4 | "bundle": { 5 | "targets": ["nsis"], 6 | "windows": { 7 | "certificateThumbprint": null, 8 | "digestAlgorithm": "sha256", 9 | "timestampUrl": "", 10 | "webviewInstallMode": { 11 | "type": "fixedRuntime", 12 | "path": "./Microsoft.WebView2.FixedVersionRuntime.109.0.1518.78.x64/" 13 | }, 14 | "nsis": { 15 | "displayLanguageSelector": true, 16 | "installerIcon": "icons/icon.ico", 17 | "languages": ["SimpChinese", "English"], 18 | "installMode": "perMachine", 19 | "template": "./packages/windows/installer.nsi" 20 | } 21 | } 22 | }, 23 | "plugins": { 24 | "updater": { 25 | "active": true, 26 | "dialog": false, 27 | "endpoints": [ 28 | "https://download.NeedyClash.dev/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json", 29 | "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json" 30 | ], 31 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src-tauri/webview2.x86.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 3 | "identifier": "io.github.clash-verge-rev.clash-verge-rev", 4 | "bundle": { 5 | "targets": ["nsis"], 6 | "windows": { 7 | "certificateThumbprint": null, 8 | "digestAlgorithm": "sha256", 9 | "timestampUrl": "", 10 | "webviewInstallMode": { 11 | "type": "fixedRuntime", 12 | "path": "./Microsoft.WebView2.FixedVersionRuntime.109.0.1518.78.x86/" 13 | }, 14 | "nsis": { 15 | "displayLanguageSelector": true, 16 | "installerIcon": "icons/icon.ico", 17 | "languages": ["SimpChinese", "English"], 18 | "installMode": "perMachine", 19 | "template": "./packages/windows/installer.nsi" 20 | } 21 | } 22 | }, 23 | "plugins": { 24 | "updater": { 25 | "active": true, 26 | "dialog": false, 27 | "endpoints": [ 28 | "https://download.NeedyClash.dev/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json", 29 | "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json" 30 | ], 31 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import Layout from "./pages/_layout"; 2 | import { AppDataProvider } from "./providers/app-data-provider"; 3 | 4 | function App() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | 12 | export default App; 13 | -------------------------------------------------------------------------------- /src/assets/fonts/Twemoji.Mozilla.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src/assets/fonts/Twemoji.Mozilla.ttf -------------------------------------------------------------------------------- /src/assets/image/component/match_case.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /src/assets/image/component/match_whole_word.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /src/assets/image/component/use_regular_expression.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 9 | -------------------------------------------------------------------------------- /src/assets/image/icon_dark.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | -------------------------------------------------------------------------------- /src/assets/image/icon_light.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | -------------------------------------------------------------------------------- /src/assets/image/itemicon/connections.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/image/itemicon/logs.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/image/itemicon/profiles.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/image/itemicon/proxies.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/image/itemicon/rules.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/image/itemicon/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/image/itemicon/test.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/image/itemicon/unlock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/image/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src/assets/image/logo.ico -------------------------------------------------------------------------------- /src/assets/image/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lythrilla/NeedyClash/f652fc4e6da49736450ea80aa277dee8a18004df/src/assets/image/logo.png -------------------------------------------------------------------------------- /src/assets/image/test/apple.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/image/test/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/image/test/google.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/image/test/youtube.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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/page.scss: -------------------------------------------------------------------------------- 1 | /* ======================================== 2 | 页面基础布局 3 | ======================================== */ 4 | 5 | .base-page { 6 | width: 100%; 7 | height: 100%; 8 | display: flex; 9 | flex-direction: column; 10 | /* 防止页面初始化时抖动 */ 11 | min-height: 100%; 12 | box-sizing: border-box; 13 | 14 | /* 页面头部 */ 15 | > header { 16 | flex: 0 0 var(--cv-header-height); 17 | width: 100%; 18 | margin: 0; 19 | padding: 0 calc(var(--cv-spacing-unit) * 2); 20 | box-sizing: border-box; 21 | display: flex; 22 | align-items: center; 23 | justify-content: space-between; 24 | border-bottom: 1px solid rgba(0, 0, 0, 0.06); 25 | background: transparent; 26 | backdrop-filter: blur(calc(var(--cv-spacing-unit) * 1.25)); 27 | transition: all var(--cv-transition-fast); 28 | z-index: var(--cv-z-base); 29 | /* 确保头部高度固定 */ 30 | min-height: var(--cv-header-height); 31 | max-height: var(--cv-header-height); 32 | 33 | @media (min-width: 600px) { 34 | padding: 0 calc(var(--cv-spacing-unit) * 3); 35 | } 36 | } 37 | 38 | /* 暗色模式下的柔和分割线 */ 39 | @media (prefers-color-scheme: dark) { 40 | > header { 41 | border-bottom-color: rgba(255, 255, 255, 0.04); 42 | } 43 | } 44 | 45 | /* 页面容器 */ 46 | .base-container { 47 | flex: 1; 48 | width: 100%; 49 | height: 100%; 50 | overflow: hidden; 51 | position: relative; 52 | /* 确保容器高度正确计算 */ 53 | min-height: 0; 54 | box-sizing: border-box; 55 | 56 | > section { 57 | position: relative; 58 | flex: 1 1 100%; 59 | width: 100%; 60 | height: 100%; 61 | overflow: auto; 62 | padding: calc(var(--cv-spacing-unit) * 2.5) 0; 63 | /* 防止内容抖动 */ 64 | min-height: 0; 65 | 66 | /* 内容区域 */ 67 | .base-content { 68 | width: calc(100% - var(--cv-spacing-unit) * 4); 69 | margin: 0 auto; 70 | max-width: var(--cv-content-max-width); 71 | 72 | @media (min-width: 600px) { 73 | width: calc(100% - var(--cv-spacing-unit) * 6); 74 | } 75 | 76 | @media (min-width: 900px) { 77 | width: calc(100% - var(--cv-spacing-unit) * 8); 78 | } 79 | } 80 | } 81 | 82 | /* 无内边距模式 */ 83 | &.no-padding { 84 | > section { 85 | padding: 0; 86 | overflow: hidden; 87 | 88 | .base-content { 89 | width: 100%; 90 | max-width: none; 91 | margin: 0; 92 | } 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/components/base/base-button.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | IconButton, 4 | Tooltip, 5 | Typography, 6 | type IconButtonProps, 7 | } from "@mui/material"; 8 | import { ReactNode } from "react"; 9 | 10 | import { getIconButtonStyles } from "./theme-tokens"; 11 | 12 | interface BaseIconButtonProps extends Omit { 13 | icon: ReactNode; 14 | tooltip?: string; 15 | size?: "small" | "medium"; 16 | } 17 | 18 | /** 19 | * 图标按钮组件 20 | */ 21 | export const BaseIconButton = ({ 22 | icon, 23 | tooltip, 24 | size = "small", 25 | sx, 26 | ...props 27 | }: BaseIconButtonProps) => { 28 | const button = ( 29 | 37 | {icon} 38 | 39 | ); 40 | 41 | if (tooltip) { 42 | return ( 43 | 44 | {button} 45 | 46 | ); 47 | } 48 | 49 | return button; 50 | }; 51 | 52 | interface ToolbarButtonGroupProps { 53 | label?: string; 54 | children: ReactNode; 55 | } 56 | 57 | /** 58 | * 工具栏按钮组 - 使用 MUI 组件确保样式一致 59 | */ 60 | export const ToolbarButtonGroup = ({ 61 | label, 62 | children, 63 | }: ToolbarButtonGroupProps) => { 64 | return ( 65 | 66 | {label && ( 67 | 76 | {label} 77 | 78 | )} 79 | {children} 80 | 81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /src/components/base/base-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingButton } from "@mui/lab"; 2 | import { 3 | Button, 4 | Dialog, 5 | DialogActions, 6 | DialogContent, 7 | DialogTitle, 8 | type SxProps, 9 | type Theme, 10 | } from "@mui/material"; 11 | import { ReactNode } from "react"; 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 = ({ 35 | open, 36 | title, 37 | children, 38 | okBtn, 39 | cancelBtn, 40 | contentSx, 41 | disableCancel, 42 | disableOk, 43 | disableFooter, 44 | loading, 45 | onOk, 46 | onCancel, 47 | onClose, 48 | }) => { 49 | return ( 50 | 73 | {title} 74 | 75 | {children} 76 | 77 | {!disableFooter && ( 78 | 79 | {!disableCancel && ( 80 | 83 | )} 84 | {!disableOk && ( 85 | 86 | {okBtn} 87 | 88 | )} 89 | 90 | )} 91 | 92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /src/components/base/base-empty.tsx: -------------------------------------------------------------------------------- 1 | import { InboxRounded } from "@mui/icons-material"; 2 | import { alpha, Box, Typography } from "@mui/material"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | interface Props { 6 | text?: React.ReactNode; 7 | extra?: React.ReactNode; 8 | } 9 | 10 | export const BaseEmpty = (props: Props) => { 11 | const { text = "Empty", extra } = props; 12 | const { t } = useTranslation(); 13 | 14 | return ( 15 | ({ 17 | width: "100%", 18 | height: "100%", 19 | display: "flex", 20 | flexDirection: "column", 21 | alignItems: "center", 22 | justifyContent: "center", 23 | color: alpha(palette.text.secondary, 0.75), 24 | })} 25 | > 26 | 27 | {t(`${text}`)} 28 | {extra} 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /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 = ({ children }: Props) => { 24 | return ( 25 | {children} 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/base/base-fieldset.tsx: -------------------------------------------------------------------------------- 1 | import { Box, styled } from "@mui/material"; 2 | import React from "react"; 3 | 4 | type Props = { 5 | label: string; 6 | fontSize?: string; 7 | width?: string; 8 | padding?: string; 9 | children?: React.ReactNode; 10 | }; 11 | 12 | export const BaseFieldset: React.FC = ({ 13 | label, 14 | fontSize, 15 | width, 16 | padding, 17 | children, 18 | }: Props) => { 19 | const Fieldset = styled(Box)<{ component?: string }>(() => ({ 20 | position: "relative", 21 | border: "1px solid #bbb", 22 | width: width ?? "auto", 23 | padding: padding ?? "15px", 24 | })); 25 | 26 | const Label = styled("legend")(({ theme }) => ({ 27 | position: "absolute", 28 | top: "-10px", 29 | left: padding ?? "15px", 30 | backgroundColor: theme.palette.background.paper, 31 | backgroundImage: 32 | "linear-gradient(rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.16))", 33 | color: theme.palette.text.primary, 34 | fontSize: fontSize ?? "1em", 35 | })); 36 | 37 | return ( 38 |
39 | 40 | {children} 41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/components/base/base-loading-overlay.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress } from "@mui/material"; 2 | import React from "react"; 3 | 4 | interface BaseLoadingOverlayProps { 5 | isLoading: boolean; 6 | } 7 | 8 | export const BaseLoadingOverlay: React.FC = ({ 9 | isLoading, 10 | }) => { 11 | if (!isLoading) return null; 12 | 13 | return ( 14 | 28 | 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /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 | animation: loading 0.7s -0.15s infinite linear; 17 | } 18 | 19 | & > div:nth-child(2n-1) { 20 | animation-delay: -0.5s; 21 | } 22 | 23 | @keyframes loading { 24 | 50% { 25 | opacity: 0.2; 26 | transform: scale(0.75); 27 | } 28 | 100% { 29 | opacity: 1; 30 | transform: scale(1); 31 | } 32 | } 33 | `; 34 | 35 | const LoadingItem = styled("div")(({ theme }) => ({ 36 | background: theme.palette.text.secondary, 37 | borderRadius: "50%", 38 | })); 39 | 40 | export const BaseLoading = () => { 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/components/base/base-page.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from "@mui/material"; 2 | import { useTheme } from "@mui/material/styles"; 3 | import React, { ReactNode } from "react"; 4 | 5 | import { useVerge } from "@/hooks/use-verge"; 6 | 7 | import { BaseErrorBoundary } from "./base-error-boundary"; 8 | 9 | interface Props { 10 | title?: React.ReactNode; // the page title 11 | header?: React.ReactNode; // something behind title 12 | contentStyle?: React.CSSProperties; 13 | children?: ReactNode; 14 | full?: boolean; 15 | } 16 | 17 | export const BasePage: React.FC = (props) => { 18 | const { title, header, contentStyle, full, children } = props; 19 | const theme = useTheme(); 20 | const { verge } = useVerge(); 21 | 22 | const isDark = theme.palette.mode === "dark"; 23 | const backgroundType = verge?.theme_setting?.background_type || "none"; 24 | const hasCustomBackground = backgroundType !== "none"; 25 | 26 | return ( 27 | 28 |
29 |
30 | 38 | {title} 39 | 40 | 41 | {header} 42 |
43 | 44 |
54 |
63 |
64 | {children} 65 |
66 |
67 |
68 |
69 |
70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /src/components/base/base-styled-text-field.tsx: -------------------------------------------------------------------------------- 1 | import { TextField, type TextFieldProps, styled } from "@mui/material"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | export const BaseStyledTextField = styled((props: TextFieldProps) => { 5 | const { t } = useTranslation(); 6 | 7 | return ( 8 | 19 | ); 20 | })(({ theme }) => ({ 21 | })); 22 | -------------------------------------------------------------------------------- /src/components/base/base-switch.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material/styles"; 2 | import { default as MuiSwitch, SwitchProps } from "@mui/material/Switch"; 3 | 4 | export const Switch = styled((props: SwitchProps) => ( 5 | 10 | ))(({ theme }) => ({ 11 | width: 42, 12 | height: 26, 13 | padding: 0, 14 | marginRight: 1, 15 | "& .MuiSwitch-switchBase": { 16 | padding: 0, 17 | margin: 2, 18 | transitionDuration: "300ms", 19 | "&.Mui-checked": { 20 | transform: "translateX(16px)", 21 | color: "#fff", 22 | "& + .MuiSwitch-track": { 23 | backgroundColor: theme.palette.primary.main, 24 | opacity: 1, 25 | border: 0, 26 | }, 27 | "&.Mui-disabled + .MuiSwitch-track": { 28 | opacity: 0.5, 29 | }, 30 | }, 31 | "&.Mui-focusVisible .MuiSwitch-thumb": { 32 | color: "#33cf4d", 33 | border: "6px solid #fff", 34 | }, 35 | "&.Mui-disabled .MuiSwitch-thumb": { 36 | color: 37 | theme.palette.mode === "light" 38 | ? theme.palette.grey[100] 39 | : theme.palette.grey[600], 40 | }, 41 | "&.Mui-disabled + .MuiSwitch-track": { 42 | opacity: theme.palette.mode === "light" ? 0.7 : 0.3, 43 | }, 44 | }, 45 | "& .MuiSwitch-thumb": { 46 | boxSizing: "border-box", 47 | width: 22, 48 | height: 22, 49 | borderRadius: "50%", 50 | }, 51 | "& .MuiSwitch-track": { 52 | borderRadius: "var(--cv-border-radius-lg)", 53 | backgroundColor: theme.palette.mode === "light" ? "#BBBBBB" : "#39393d36", 54 | opacity: 1, 55 | transition: theme.transitions.create(["background-color"], { 56 | duration: 500, 57 | }), 58 | }, 59 | })); 60 | -------------------------------------------------------------------------------- /src/components/base/base-tooltip-icon.tsx: -------------------------------------------------------------------------------- 1 | import { InfoRounded } from "@mui/icons-material"; 2 | import { 3 | Tooltip, 4 | IconButton, 5 | IconButtonProps, 6 | SvgIconProps, 7 | } from "@mui/material"; 8 | 9 | interface Props extends IconButtonProps { 10 | title?: string; 11 | icon?: React.ElementType; 12 | } 13 | 14 | export const TooltipIcon: React.FC = (props: Props) => { 15 | const { title = "", icon: Icon = InfoRounded, ...restProps } = props; 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /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 { Switch } from "./base-switch"; 7 | export { BaseLoadingOverlay } from "./base-loading-overlay"; 8 | export { NoticeManager } from "./NoticeManager"; 9 | export { getSelectMenuProps } from "./select-menu-props"; 10 | 11 | // 新增公共组件 12 | export { BaseCard, BaseSection } from "./base-card"; 13 | export { BaseModeSelector, CompactModeSelector } from "./base-mode-selector"; 14 | export { GlassMenu, GlassSelect, getContextMenuProps } from "./base-menu"; 15 | export { BaseIconButton, ToolbarButtonGroup } from "./base-button"; 16 | 17 | // 主题系统 18 | export * from "./theme-tokens"; 19 | -------------------------------------------------------------------------------- /src/components/base/select-menu-props.ts: -------------------------------------------------------------------------------- 1 | import { MenuProps } from "@mui/material"; 2 | 3 | /** 4 | * 标准的 Select MenuProps 配置,确保下拉菜单正确浮动显示 5 | */ 6 | export const getSelectMenuProps = (maxHeight?: number): Partial => ({ 7 | anchorOrigin: { 8 | vertical: "bottom", 9 | horizontal: "left", 10 | }, 11 | transformOrigin: { 12 | vertical: "top", 13 | horizontal: "left", 14 | }, 15 | PaperProps: { 16 | sx: { 17 | position: "fixed", 18 | backgroundColor: (theme) => 19 | theme.palette.mode === "light" 20 | ? "rgba(255, 255, 255, 0.95)" 21 | : "rgba(50, 50, 50, 0.95)", 22 | backdropFilter: "blur(20px) saturate(180%)", 23 | WebkitBackdropFilter: "blur(20px) saturate(180%)", 24 | border: (theme) => 25 | `1px solid ${theme.palette.mode === "light" ? "#E2E8F0" : "rgba(255, 255, 255, 0.1)"}`, 26 | maxHeight: maxHeight || 300, 27 | zIndex: 1400, 28 | "& .MuiList-root": { 29 | padding: "4px", 30 | }, 31 | }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /src/components/center.tsx: -------------------------------------------------------------------------------- 1 | import { Box, BoxProps } from "@mui/material"; 2 | import React from "react"; 3 | 4 | interface CenterProps extends BoxProps { 5 | children: React.ReactNode; 6 | } 7 | 8 | export const Center: React.FC = ({ children, ...props }) => { 9 | return ( 10 | 18 | {children} 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/common/with-traffic-error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { ErrorInfo } from "react"; 3 | 4 | import { 5 | TrafficErrorBoundary, 6 | LightweightTrafficErrorBoundary, 7 | } from "./traffic-error-boundary"; 8 | 9 | interface WithTrafficErrorBoundaryOptions { 10 | lightweight?: boolean; 11 | onError?: (error: Error, errorInfo: ErrorInfo) => void; 12 | } 13 | 14 | /** 15 | * HOC:为任何组件添加流量错误边界 16 | */ 17 | export function withTrafficErrorBoundary

( 18 | WrappedComponent: React.ComponentType

, 19 | options?: WithTrafficErrorBoundaryOptions, 20 | ) { 21 | const WithErrorBoundaryComponent = (props: P) => { 22 | const ErrorBoundaryComponent = options?.lightweight 23 | ? LightweightTrafficErrorBoundary 24 | : TrafficErrorBoundary; 25 | 26 | return ( 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | WithErrorBoundaryComponent.displayName = `withTrafficErrorBoundary(${WrappedComponent.displayName || WrappedComponent.name})`; 34 | 35 | return WithErrorBoundaryComponent; 36 | } 37 | -------------------------------------------------------------------------------- /src/components/connection/connection-item.tsx: -------------------------------------------------------------------------------- 1 | import { CloseRounded } from "@mui/icons-material"; 2 | import { 3 | styled, 4 | ListItem, 5 | IconButton, 6 | ListItemText, 7 | Box, 8 | alpha, 9 | } from "@mui/material"; 10 | import { useLockFn } from "ahooks"; 11 | import dayjs from "dayjs"; 12 | import { closeConnections } from "tauri-plugin-mihomo-api"; 13 | 14 | import parseTraffic from "@/utils/parse-traffic"; 15 | 16 | const Tag = styled("span")(({ theme }) => ({ 17 | fontSize: "10px", 18 | padding: "0 4px", 19 | lineHeight: 1.375, 20 | border: "1px solid", 21 | borderColor: alpha(theme.palette.text.secondary, 0.35), 22 | marginTop: "4px", 23 | marginRight: "4px", 24 | })); 25 | 26 | interface Props { 27 | value: IConnectionsItem; 28 | onShowDetail?: () => void; 29 | } 30 | 31 | export const ConnectionItem = (props: Props) => { 32 | const { value, onShowDetail } = props; 33 | 34 | const { id, metadata, chains, start, curUpload, curDownload } = value; 35 | 36 | const onDelete = useLockFn(async () => closeConnections(id)); 37 | const showTraffic = curUpload! >= 100 || curDownload! >= 100; 38 | 39 | return ( 40 | 54 | 55 | 56 | } 57 | > 58 | 64 | 65 | {metadata.network} 66 | 67 | 68 | {metadata.type} 69 | 70 | {!!metadata.process && {metadata.process}} 71 | 72 | {chains?.length > 0 && ( 73 | {[...chains].reverse().join(" / ")} 74 | )} 75 | 76 | {dayjs(start).fromNow()} 77 | 78 | {showTraffic && ( 79 | 80 | {parseTraffic(curUpload!)} / {parseTraffic(curDownload!)} 81 | 82 | )} 83 | 84 | } 85 | /> 86 | 87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /src/components/layout/scroll-top-button.tsx: -------------------------------------------------------------------------------- 1 | import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; 2 | import { IconButton, Fade, SxProps, Theme } from "@mui/material"; 3 | 4 | interface Props { 5 | onClick: () => void; 6 | show: boolean; 7 | sx?: SxProps; 8 | } 9 | 10 | export const ScrollTopButton = ({ onClick, show, sx }: Props) => { 11 | return ( 12 | 13 | 20 | theme.palette.mode === "dark" 21 | ? "rgba(255,255,255,0.1)" 22 | : "rgba(0,0,0,0.1)", 23 | "&:hover": { 24 | backgroundColor: (theme) => 25 | theme.palette.mode === "dark" 26 | ? "rgba(255,255,255,0.2)" 27 | : "rgba(0,0,0,0.2)", 28 | }, 29 | visibility: show ? "visible" : "hidden", 30 | ...sx, 31 | }} 32 | > 33 | 34 | 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/layout/update-button.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | className?: string; 3 | } 4 | 5 | export const UpdateButton = (props: Props) => { 6 | return null; 7 | }; 8 | -------------------------------------------------------------------------------- /src/components/profile/confirm-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Dialog, 4 | DialogActions, 5 | DialogContent, 6 | DialogTitle, 7 | } from "@mui/material"; 8 | import { useEffect } from "react"; 9 | import { useTranslation } from "react-i18next"; 10 | 11 | interface Props { 12 | open: boolean; 13 | title: string; 14 | message: string; 15 | onClose: () => void; 16 | onConfirm: () => void; 17 | } 18 | 19 | export const ConfirmViewer = (props: Props) => { 20 | const { open, title, message, onClose, onConfirm } = props; 21 | 22 | const { t } = useTranslation(); 23 | 24 | useEffect(() => { 25 | if (!open) return; 26 | }, [open]); 27 | 28 | return ( 29 |

30 | {title} 31 | 32 | 33 | {message} 34 | 35 | 36 | 37 | 40 | 43 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/components/profile/file-input.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Typography } from "@mui/material"; 2 | import { useLockFn } from "ahooks"; 3 | import { useRef, useState } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | interface Props { 7 | onChange: (file: File, 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(undefined); 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(file, 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 { 2 | Button, 3 | Chip, 4 | Dialog, 5 | DialogActions, 6 | DialogContent, 7 | DialogTitle, 8 | Divider, 9 | Typography, 10 | } from "@mui/material"; 11 | import { Fragment } from "react"; 12 | import { useTranslation } from "react-i18next"; 13 | 14 | import { BaseEmpty } from "@/components/base"; 15 | 16 | interface Props { 17 | open: boolean; 18 | logInfo: [string, string][]; 19 | onClose: () => void; 20 | } 21 | 22 | export const LogViewer = (props: Props) => { 23 | const { open, logInfo, onClose } = props; 24 | 25 | const { t } = useTranslation(); 26 | 27 | return ( 28 | 29 | {t("Script Console")} 30 | 31 | 40 | {logInfo.map(([level, log]) => ( 41 | 42 | 43 | 54 | {log} 55 | 56 | 57 | 58 | ))} 59 | 60 | {logInfo.length === 0 && } 61 | 62 | 63 | 64 | 67 | 68 | 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /src/components/profile/profile-box.tsx: -------------------------------------------------------------------------------- 1 | import { alpha, Box, styled } from "@mui/material"; 2 | 3 | export const ProfileBox = styled(Box)(({ 4 | theme, 5 | "aria-selected": selected, 6 | }) => { 7 | const { mode, primary, text } = theme.palette; 8 | const key = `${mode}-${!!selected}`; 9 | 10 | const backgroundColor = mode === "light" ? "#ffffff" : "#282A36"; 11 | 12 | const color = { 13 | "light-true": text.secondary, 14 | "light-false": text.secondary, 15 | "dark-true": alpha(text.secondary, 0.65), 16 | "dark-false": alpha(text.secondary, 0.65), 17 | }[key]!; 18 | 19 | const h2color = { 20 | "light-true": primary.main, 21 | "light-false": text.primary, 22 | "dark-true": primary.main, 23 | "dark-false": text.primary, 24 | }[key]!; 25 | 26 | const borderSelect = { 27 | "light-true": { 28 | borderLeft: `3px solid ${primary.main}`, 29 | }, 30 | "light-false": {}, 31 | "dark-true": { 32 | borderLeft: `3px solid ${primary.main}`, 33 | }, 34 | "dark-false": {}, 35 | }[key]; 36 | 37 | return { 38 | position: "relative", 39 | display: "block", 40 | cursor: "pointer", 41 | textAlign: "left", 42 | padding: "10px 14px", 43 | boxSizing: "border-box", 44 | width: "100%", 45 | backgroundColor: 46 | mode === "light" 47 | ? "#ffffff" 48 | : alpha(theme.palette.background.paper, 0.7) /* 暗色模式半透明 */, 49 | ...borderSelect, 50 | border: `1px solid ${mode === "light" ? "#E2E8F0" : "rgba(51, 65, 85, 0.5)"}` /* 添加边框 */, 51 | borderRadius: "var(--cv-border-radius-md)" /* 添加圆角 */, 52 | color, 53 | transition: "all 0.2s ease", 54 | "&:hover": { 55 | backgroundColor: 56 | mode === "light" 57 | ? "#FAFAFA" 58 | : alpha(theme.palette.background.paper, 0.9), 59 | borderColor: mode === "light" ? primary.main : alpha(primary.main, 0.5), 60 | }, 61 | "& h2": { color: h2color }, 62 | }; 63 | }); 64 | -------------------------------------------------------------------------------- /src/components/proxy/proxy-group-navigator.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Tooltip } from "@mui/material"; 2 | import { useCallback, useMemo } from "react"; 3 | 4 | interface ProxyGroupNavigatorProps { 5 | proxyGroupNames: string[]; 6 | onGroupLocation: (groupName: string) => void; 7 | } 8 | 9 | // 提取代理组名的第一个字符 10 | const getGroupDisplayChar = (groupName: string): string => { 11 | if (!groupName) return "?"; 12 | 13 | // 直接返回第一个字符,支持表情符号 14 | const firstChar = Array.from(groupName)[0]; 15 | return firstChar || "?"; 16 | }; 17 | 18 | export const ProxyGroupNavigator = ({ 19 | proxyGroupNames, 20 | onGroupLocation, 21 | }: ProxyGroupNavigatorProps) => { 22 | const handleGroupClick = useCallback( 23 | (groupName: string) => { 24 | onGroupLocation(groupName); 25 | }, 26 | [onGroupLocation], 27 | ); 28 | 29 | // 处理代理组数据,去重和排序 30 | const processedGroups = useMemo(() => { 31 | return proxyGroupNames 32 | .filter((name) => name && name.trim()) 33 | .map((name) => ({ 34 | name, 35 | displayChar: getGroupDisplayChar(name), 36 | })); 37 | }, [proxyGroupNames]); 38 | 39 | if (processedGroups.length === 0) { 40 | return null; 41 | } 42 | 43 | return ( 44 | 62 | {processedGroups.map(({ name, displayChar }) => ( 63 | 64 | 88 | 89 | ))} 90 | 91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /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, alpha, ListItem, ListItemText } from "@mui/material"; 2 | 3 | const Tag = styled("span")(({ theme }) => ({ 4 | fontSize: "10px", 5 | padding: "0 4px", 6 | lineHeight: 1.375, 7 | border: "1px solid", 8 | borderColor: alpha(theme.palette.text.secondary, 0.35), 9 | marginTop: "4px", 10 | marginRight: "4px", 11 | })); 12 | 13 | const COLOR = [ 14 | "primary.main", 15 | "secondary.main", 16 | "info.main", 17 | "warning.main", 18 | "success.main", 19 | ]; 20 | 21 | interface Props { 22 | index: number; 23 | value: IRuleItem; 24 | } 25 | 26 | const parseColor = (text: string) => { 27 | if (text === "REJECT" || text === "REJECT-DROP") return "error.main"; 28 | if (text === "DIRECT") return "text.primary"; 29 | 30 | let sum = 0; 31 | for (let i = 0; i < text.length; i++) { 32 | sum += text.charCodeAt(i); 33 | } 34 | return COLOR[sum % COLOR.length]; 35 | }; 36 | 37 | const RuleItem = (props: Props) => { 38 | const { index, value } = props; 39 | 40 | return ( 41 | 42 | 47 | #{index} 48 | {value.type} 49 | 54 | {value.proxy} 55 | 56 | 57 | } 58 | /> 59 | 60 | ); 61 | }; 62 | 63 | export default RuleItem; 64 | -------------------------------------------------------------------------------- /src/components/setting/mods/config-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Chip } from "@mui/material"; 2 | import { forwardRef, useImperativeHandle, useState } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | import { DialogRef } from "@/components/base"; 6 | import { EditorViewer } from "@/components/profile/editor-viewer"; 7 | import { getRuntimeYaml } from "@/services/cmds"; 8 | 9 | export const ConfigViewer = forwardRef((_, ref) => { 10 | const { t } = useTranslation(); 11 | const [open, setOpen] = useState(false); 12 | const [runtimeConfig, setRuntimeConfig] = useState(""); 13 | 14 | useImperativeHandle(ref, () => ({ 15 | open: () => { 16 | getRuntimeYaml().then((data) => { 17 | setRuntimeConfig(data ?? "# Error getting runtime yaml\n"); 18 | setOpen(true); 19 | }); 20 | }, 21 | close: () => setOpen(false), 22 | })); 23 | 24 | if (!open) return null; 25 | return ( 26 | 30 | {t("Runtime Config")} 31 | 32 | 33 | } 34 | initialData={Promise.resolve(runtimeConfig)} 35 | readOnly 36 | language="yaml" 37 | schema="clash" 38 | onClose={() => setOpen(false)} 39 | /> 40 | ); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/setting/mods/guard-state.tsx: -------------------------------------------------------------------------------- 1 | import { createElement, isValidElement, ReactNode, useRef } from "react"; 2 | 3 | import noop from "@/utils/noop"; 4 | 5 | interface Props { 6 | value?: Value; 7 | valueProps?: string; 8 | onChangeProps?: string; 9 | waitTime?: number; 10 | onChange?: (value: Value) => void; 11 | onFormat?: (...args: any[]) => Value; 12 | onGuard?: (value: Value, oldValue: Value) => Promise; 13 | onCatch?: (error: Error) => void; 14 | children: ReactNode; 15 | } 16 | 17 | export function GuardState(props: Props) { 18 | const { 19 | value, 20 | children, 21 | valueProps = "value", 22 | onChangeProps = "onChange", 23 | waitTime = 0, // debounce wait time default 0 24 | onGuard = noop, 25 | onCatch = noop, 26 | onChange = noop, 27 | onFormat, 28 | } = props; 29 | 30 | const lockRef = useRef(false); 31 | const saveRef = useRef(value); 32 | const lastRef = useRef(0); 33 | const timeRef = useRef(undefined); 34 | 35 | if (!isValidElement(children)) { 36 | return children as any; 37 | } 38 | 39 | const childProps = { ...(children.props as Record) }; 40 | 41 | childProps[valueProps] = value; 42 | childProps[onChangeProps] = async (...args: any[]) => { 43 | // 多次操作无效 44 | if (lockRef.current) return; 45 | lockRef.current = true; 46 | 47 | try { 48 | const newValue = onFormat ? (onFormat as any)(...args) : (args[0] as T); 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 | const { children: nestedChildren, ...restProps } = childProps; 85 | 86 | return createElement(children.type, restProps, nestedChildren); 87 | } 88 | -------------------------------------------------------------------------------- /src/components/setting/mods/local-backup-actions.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Grid, Stack, Typography } from "@mui/material"; 2 | import { useLockFn } from "ahooks"; 3 | import { memo } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | import { createLocalBackup } from "@/services/cmds"; 7 | import { showNotice } from "@/services/noticeService"; 8 | 9 | interface LocalBackupActionsProps { 10 | onBackupSuccess: () => Promise; 11 | onRefresh: () => Promise; 12 | setLoading: (loading: boolean) => void; 13 | } 14 | 15 | export const LocalBackupActions = memo( 16 | ({ onBackupSuccess, onRefresh, setLoading }: LocalBackupActionsProps) => { 17 | const { t } = useTranslation(); 18 | 19 | const handleBackup = useLockFn(async () => { 20 | try { 21 | setLoading(true); 22 | await createLocalBackup(); 23 | showNotice("success", t("Local Backup Created")); 24 | await onBackupSuccess(); 25 | } catch (error) { 26 | console.error(error); 27 | showNotice("error", t("Local Backup Failed")); 28 | } finally { 29 | setLoading(false); 30 | } 31 | }); 32 | 33 | const handleRefresh = useLockFn(async () => { 34 | setLoading(true); 35 | try { 36 | await onRefresh(); 37 | } finally { 38 | setLoading(false); 39 | } 40 | }); 41 | 42 | return ( 43 | 44 | 45 | 46 | {t("Local Backup Info")} 47 | 48 | 49 | 50 | 56 | 65 | 73 | 74 | 75 | 76 | ); 77 | }, 78 | ); 79 | -------------------------------------------------------------------------------- /src/components/setting/mods/password-input.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Dialog, 4 | DialogActions, 5 | DialogContent, 6 | DialogTitle, 7 | TextField, 8 | } from "@mui/material"; 9 | import { useState } from "react"; 10 | import { useTranslation } from "react-i18next"; 11 | 12 | interface Props { 13 | onConfirm: (passwd: string) => Promise; 14 | } 15 | 16 | export const PasswordInput = (props: Props) => { 17 | const { onConfirm } = props; 18 | 19 | const { t } = useTranslation(); 20 | const [passwd, setPasswd] = useState(""); 21 | 22 | return ( 23 | 24 | {t("Please enter your root password")} 25 | 26 | 27 | e.key === "Enter" && onConfirm(passwd)} 36 | onChange={(e) => setPasswd(e.target.value)} 37 | > 38 | 39 | 40 | 41 | 47 | 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/setting/mods/stack-mode-switch.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonGroup } from "@mui/material"; 2 | 3 | interface Props { 4 | value?: string; 5 | onChange?: (value: string) => void; 6 | } 7 | 8 | export const StackModeSwitch = (props: Props) => { 9 | const { value, onChange } = props; 10 | 11 | return ( 12 | 13 | 20 | 27 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/setting/mods/theme-mode-switch.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonGroup } from "@mui/material"; 2 | import { useTranslation } from "react-i18next"; 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-types.ts: -------------------------------------------------------------------------------- 1 | // 主题预设接口 2 | export interface ThemePreset { 3 | name: string; 4 | primary_color: string; 5 | secondary_color: string; 6 | info_color: string; 7 | success_color: string; 8 | warning_color: string; 9 | error_color: string; 10 | primary_text: string; 11 | secondary_text: string; 12 | isCustom?: boolean; 13 | } 14 | 15 | // 主题模式 16 | export type ThemeMode = "light" | "dark"; 17 | 18 | // 主题设置 19 | export interface ThemeSetting { 20 | primary_color?: string; 21 | secondary_color?: string; 22 | info_color?: string; 23 | success_color?: string; 24 | warning_color?: string; 25 | error_color?: string; 26 | primary_text?: string; 27 | secondary_text?: string; 28 | font_family?: string; 29 | css_injection?: string; 30 | // 窗口背景设置 31 | background_type?: "color" | "image" | "video" | "none"; 32 | background_color?: string; 33 | background_image?: string; 34 | background_video?: string; 35 | background_opacity?: number; 36 | background_blur?: number; 37 | background_brightness?: number; 38 | background_blend_mode?: string; 39 | background_size?: string; 40 | background_position?: string; 41 | background_repeat?: string; 42 | background_scale?: number; 43 | // 导航栏样式设置 44 | sidebar_background_color?: string; 45 | sidebar_opacity?: number; 46 | sidebar_blur?: number; 47 | // Header样式设置 48 | header_background_color?: string; 49 | header_opacity?: number; 50 | header_blur?: number; 51 | // 设置页面样式 52 | settings_background_blur?: boolean; 53 | settings_background_opacity?: number; 54 | // 连接表格样式设置 55 | connection_table_blur?: number; 56 | connection_table_opacity?: number; 57 | // 组件微调设置 58 | component_styles?: { 59 | global?: IComponentStyle; 60 | select?: IComponentStyle; 61 | profile_card?: IComponentStyle; 62 | proxy_card?: IComponentStyle; 63 | textfield?: IComponentStyle; 64 | analytics_chart?: IComponentStyle; 65 | analytics_header?: IComponentStyle; 66 | dialog?: IComponentStyle; 67 | }; 68 | } 69 | 70 | // 组件样式接口 71 | export interface IComponentStyle { 72 | background_color?: string; 73 | blur?: number; 74 | opacity?: number; 75 | } 76 | 77 | // 组件键类型 78 | export type ComponentKey = 79 | | "select" 80 | | "profile_card" 81 | | "proxy_card" 82 | | "textfield" 83 | | "analytics_chart" 84 | | "analytics_header" 85 | | "dialog"; 86 | 87 | // 自定义主题集合 88 | export interface CustomThemes { 89 | light: ThemePreset[]; 90 | dark: ThemePreset[]; 91 | } 92 | -------------------------------------------------------------------------------- /src/components/setting/mods/theme-utils.ts: -------------------------------------------------------------------------------- 1 | import type { ThemePreset, ThemeSetting } from "./theme-types"; 2 | 3 | // 自定义主题本地存储key 4 | export const CUSTOM_THEMES_KEY = "custom_themes"; 5 | 6 | // 检查主题是否完全匹配(所有颜色字段) 7 | export const isThemeActive = ( 8 | theme: ThemeSetting, 9 | preset: ThemePreset, 10 | ): boolean => { 11 | return ( 12 | theme.primary_color === preset.primary_color && 13 | theme.secondary_color === preset.secondary_color && 14 | theme.info_color === preset.info_color && 15 | theme.success_color === preset.success_color && 16 | theme.warning_color === preset.warning_color && 17 | theme.error_color === preset.error_color && 18 | theme.primary_text === preset.primary_text && 19 | theme.secondary_text === preset.secondary_text 20 | ); 21 | }; 22 | 23 | // 从预制主题创建主题设置 24 | export const createThemeFromPreset = ( 25 | currentTheme: ThemeSetting, 26 | preset: ThemePreset, 27 | ): ThemeSetting => { 28 | return { 29 | ...currentTheme, 30 | primary_color: preset.primary_color, 31 | secondary_color: preset.secondary_color, 32 | info_color: preset.info_color, 33 | success_color: preset.success_color, 34 | warning_color: preset.warning_color, 35 | error_color: preset.error_color, 36 | primary_text: preset.primary_text, 37 | secondary_text: preset.secondary_text, 38 | }; 39 | }; 40 | 41 | // 从当前主题设置创建预制主题 42 | export const createPresetFromTheme = ( 43 | name: string, 44 | theme: ThemeSetting, 45 | ): ThemePreset => { 46 | return { 47 | name, 48 | primary_color: theme.primary_color || "", 49 | secondary_color: theme.secondary_color || "", 50 | info_color: theme.info_color || "", 51 | success_color: theme.success_color || "", 52 | warning_color: theme.warning_color || "", 53 | error_color: theme.error_color || "", 54 | primary_text: theme.primary_text || "", 55 | secondary_text: theme.secondary_text || "", 56 | isCustom: true, 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /src/components/setting/setting-system-styles.ts: -------------------------------------------------------------------------------- 1 | import { alpha, type SxProps, type Theme } from "@mui/material"; 2 | 3 | /** 4 | * 服务按钮的公共样式配置 5 | */ 6 | export const serviceButtonBaseStyles: SxProps = { 7 | width: "100%", 8 | py: 0.75, 9 | px: 1.5, 10 | fontSize: "12px", 11 | fontWeight: 500, 12 | textTransform: "none", 13 | transition: "all 0.2s", 14 | "& .MuiButton-startIcon": { 15 | marginRight: "8px", 16 | }, 17 | }; 18 | 19 | /** 20 | * 生成按钮样式的辅助函数 21 | */ 22 | export const createButtonStyles = ( 23 | color: "primary" | "success" | "error", 24 | ): SxProps => ({ 25 | ...serviceButtonBaseStyles, 26 | color: `${color}.main`, 27 | backgroundColor: (theme) => alpha(theme.palette[color].main, 0.08), 28 | "&:hover": { 29 | backgroundColor: (theme) => alpha(theme.palette[color].main, 0.15), 30 | }, 31 | "&:disabled": { 32 | color: "text.disabled", 33 | backgroundColor: (theme) => alpha(theme.palette.action.disabled, 0.08), 34 | }, 35 | }); 36 | 37 | /** 38 | * 安装按钮样式 39 | */ 40 | export const installButtonStyles = createButtonStyles("primary"); 41 | 42 | /** 43 | * 重装按钮样式 44 | */ 45 | export const reinstallButtonStyles = createButtonStyles("success"); 46 | 47 | /** 48 | * 卸载按钮样式 49 | */ 50 | export const uninstallButtonStyles = createButtonStyles("error"); 51 | 52 | /** 53 | * 警告提示文本样式 54 | */ 55 | export const warningTextStyles: SxProps = { 56 | fontSize: "10px", 57 | color: "warning.main", 58 | display: "flex", 59 | alignItems: "center", 60 | gap: 0.5, 61 | }; 62 | 63 | /** 64 | * 服务管理容器样式 65 | */ 66 | export const serviceContainerStyles: SxProps = { 67 | py: 1.25, 68 | px: 2, 69 | mb: 0, 70 | borderBottom: (theme) => 71 | `1px solid ${theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.03)" : "rgba(0, 0, 0, 0.03)"}`, 72 | }; 73 | -------------------------------------------------------------------------------- /src/components/shared/TunStatusMonitor.tsx: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api/core"; 2 | import { useEffect, useRef } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | import { useSystemState } from "@/hooks/use-system-state"; 6 | import { useVerge } from "@/hooks/use-verge"; 7 | import { showNotice } from "@/services/noticeService"; 8 | 9 | /** 10 | * TUN 状态监控组件 11 | * 负责监控 TUN 模式状态并在必要时自动同步 12 | */ 13 | export const TunStatusMonitor = () => { 14 | const { t } = useTranslation(); 15 | const { verge } = useVerge(); 16 | const { isTunModeAvailable } = useSystemState(); 17 | 18 | const lastSyncTimeRef = useRef(0); 19 | const isSyncingRef = useRef(false); 20 | 21 | const enableTunMode = verge?.enable_tun_mode ?? false; 22 | 23 | useEffect(() => { 24 | // 仅在应用启动后 3 秒开始监控 25 | const startupDelay = setTimeout(() => { 26 | // 检查是否需要同步 27 | const now = Date.now(); 28 | const timeSinceLastSync = now - lastSyncTimeRef.current; 29 | 30 | // 至少间隔 10 秒才进行下一次同步 31 | if (timeSinceLastSync < 10000) { 32 | return; 33 | } 34 | 35 | // 避免重复同步 36 | if (isSyncingRef.current) { 37 | return; 38 | } 39 | 40 | // 如果 TUN 模式启用但服务不可用,同步状态 41 | if (enableTunMode && !isTunModeAvailable) { 42 | console.log("[TunStatusMonitor] 检测到 TUN 状态不一致,准备同步"); 43 | syncTunStatus(); 44 | } 45 | }, 3000); 46 | 47 | return () => clearTimeout(startupDelay); 48 | }, [enableTunMode, isTunModeAvailable]); 49 | 50 | const syncTunStatus = async () => { 51 | if (isSyncingRef.current) { 52 | return; 53 | } 54 | 55 | isSyncingRef.current = true; 56 | lastSyncTimeRef.current = Date.now(); 57 | 58 | try { 59 | console.log("[TunStatusMonitor] 正在同步 TUN 状态..."); 60 | await invoke("sync_tun_status"); 61 | console.log("[TunStatusMonitor] TUN 状态同步成功"); 62 | } catch (err) { 63 | console.error("[TunStatusMonitor] TUN 状态同步失败:", err); 64 | 65 | // 仅在关键错误时显示通知 66 | if ( 67 | (err as Error)?.message?.includes("Service") || 68 | (err as Error)?.message?.includes("Administrator") 69 | ) { 70 | showNotice( 71 | "info", 72 | t("TUN mode requires Service Mode or Administrator privileges"), 73 | ); 74 | } 75 | } finally { 76 | isSyncingRef.current = false; 77 | } 78 | }; 79 | 80 | // 不渲染任何 UI 81 | return null; 82 | }; 83 | -------------------------------------------------------------------------------- /src/components/shared/proxy-control-styles.ts: -------------------------------------------------------------------------------- 1 | import type { SxProps, Theme } from "@mui/material"; 2 | import { alpha } from "@mui/material"; 3 | 4 | /** 5 | * 代理控制组件样式常量 6 | */ 7 | 8 | /** 按钮样式生成器 */ 9 | const createButtonStyle = ( 10 | color: "primary" | "success" | "error", 11 | fontSize: string = "11px", 12 | ): SxProps => ({ 13 | py: 0.75, 14 | px: 1, 15 | fontSize, 16 | fontWeight: 500, 17 | textTransform: "none", 18 | borderRadius: "var(--cv-border-radius-sm)", 19 | color: `${color}.main`, 20 | backgroundColor: (theme) => alpha(theme.palette[color].main, 0.06), 21 | transition: "all 0.2s", 22 | "&:hover": { 23 | backgroundColor: (theme) => alpha(theme.palette[color].main, 0.12), 24 | }, 25 | }); 26 | 27 | /** 服务按钮样式 */ 28 | export const SERVICE_BUTTON_STYLES = { 29 | install: createButtonStyle("primary"), 30 | reinstall: { 31 | ...createButtonStyle("success", "10px"), 32 | px: 0.75, 33 | "& .MuiButton-startIcon": { 34 | marginRight: "4px", 35 | }, 36 | }, 37 | uninstall: { 38 | ...createButtonStyle("error", "10px"), 39 | px: 0.75, 40 | "& .MuiButton-startIcon": { 41 | marginRight: "4px", 42 | }, 43 | }, 44 | } as const; 45 | 46 | /** 开关行容器样式 */ 47 | export const SWITCH_ROW_CONTAINER: SxProps = { 48 | display: "flex", 49 | alignItems: "center", 50 | gap: 1.5, 51 | px: 0, 52 | py: 1, 53 | transition: "opacity 0.15s ease", 54 | }; 55 | 56 | /** 标签样式 */ 57 | export const LABEL_STYLE: SxProps = { 58 | fontSize: 12, 59 | fontWeight: 400, 60 | color: "text.secondary", 61 | flex: 1, 62 | minWidth: 0, 63 | overflow: "hidden", 64 | textOverflow: "ellipsis", 65 | whiteSpace: "nowrap", 66 | }; 67 | 68 | /** 提示文本样式 */ 69 | export const HINT_TEXT_STYLES = { 70 | caption: { 71 | fontSize: 10, 72 | lineHeight: 1.4, 73 | color: "text.disabled", 74 | display: "block", 75 | mt: 0.5, 76 | mb: 1, 77 | }, 78 | warning: { 79 | fontSize: 9, 80 | color: "warning.main", 81 | lineHeight: 1.3, 82 | }, 83 | } as const; 84 | 85 | /** 图标样式 */ 86 | export const ICON_STYLE: SxProps = { 87 | fontSize: 14, 88 | opacity: 0.4, 89 | flexShrink: 0, 90 | }; 91 | 92 | 93 | -------------------------------------------------------------------------------- /src/components/test/test-box.tsx: -------------------------------------------------------------------------------- 1 | import { alpha, Box, styled } from "@mui/material"; 2 | 3 | export const TestBox = styled(Box)(({ theme, "aria-selected": selected }) => { 4 | const { mode, primary, text } = theme.palette; 5 | const key = `${mode}-${!!selected}`; 6 | 7 | const backgroundColor = 8 | mode === "light" ? alpha(primary.main, 0.05) : alpha(primary.main, 0.08); 9 | 10 | const color = { 11 | "light-true": text.secondary, 12 | "light-false": text.secondary, 13 | "dark-true": alpha(text.secondary, 0.65), 14 | "dark-false": alpha(text.secondary, 0.65), 15 | }[key]!; 16 | 17 | const h2color = { 18 | "light-true": primary.main, 19 | "light-false": text.primary, 20 | "dark-true": primary.main, 21 | "dark-false": text.primary, 22 | }[key]!; 23 | 24 | return { 25 | position: "relative", 26 | width: "100%", 27 | display: "block", 28 | cursor: "pointer", 29 | textAlign: "left", 30 | boxShadow: theme.shadows[1], 31 | borderRadius: "var(--cv-border-radius-sm)", 32 | padding: "8px 16px", 33 | boxSizing: "border-box", 34 | backgroundColor, 35 | color, 36 | "& h2": { color: h2color }, 37 | transition: "background-color 0.3s, box-shadow 0.3s", 38 | "&:hover": { 39 | backgroundColor: 40 | mode === "light" ? alpha(primary.main, 0.1) : alpha(primary.main, 0.15), 41 | boxShadow: theme.shadows[2], 42 | }, 43 | }; 44 | }); 45 | -------------------------------------------------------------------------------- /src/hooks/use-clash.ts: -------------------------------------------------------------------------------- 1 | import { useLockFn } from "ahooks"; 2 | import useSWR, { mutate } from "swr"; 3 | import { getVersion } from "tauri-plugin-mihomo-api"; 4 | 5 | import { 6 | getClashInfo, 7 | patchClashConfig, 8 | getRuntimeConfig, 9 | } from "@/services/cmds"; 10 | import { validatePorts } from "@/utils/port-validator"; 11 | 12 | export const useClash = () => { 13 | const { data: clash, mutate: mutateClash } = useSWR( 14 | "getRuntimeConfig", 15 | getRuntimeConfig, 16 | ); 17 | 18 | const { data: versionData, mutate: mutateVersion } = useSWR( 19 | "getVersion", 20 | getVersion, 21 | ); 22 | 23 | const patchClash = useLockFn(async (patch: Partial) => { 24 | await patchClashConfig(patch); 25 | mutateClash(); 26 | }); 27 | 28 | const version = versionData?.meta 29 | ? `${versionData.version} Mihomo` 30 | : versionData?.version || "-"; 31 | 32 | return { 33 | clash, 34 | version, 35 | mutateClash, 36 | mutateVersion, 37 | patchClash, 38 | }; 39 | }; 40 | 41 | export const useClashInfo = () => { 42 | const { data: clashInfo, mutate: mutateInfo } = useSWR( 43 | "getClashInfo", 44 | getClashInfo, 45 | ); 46 | 47 | const patchInfo = async ( 48 | patch: Partial< 49 | Pick< 50 | IConfigData, 51 | | "port" 52 | | "socks-port" 53 | | "mixed-port" 54 | | "redir-port" 55 | | "tproxy-port" 56 | | "external-controller" 57 | | "secret" 58 | > 59 | >, 60 | ) => { 61 | const hasInfo = 62 | patch["redir-port"] != null || 63 | patch["tproxy-port"] != null || 64 | patch["mixed-port"] != null || 65 | patch["socks-port"] != null || 66 | patch["port"] != null || 67 | patch["external-controller"] != null || 68 | patch.secret != null; 69 | 70 | if (!hasInfo) return; 71 | 72 | // 使用端口验证 73 | validatePorts(patch); 74 | 75 | await patchClashConfig(patch); 76 | mutateInfo(); 77 | mutate("getClashConfig"); 78 | }; 79 | 80 | return { 81 | clashInfo, 82 | mutateInfo, 83 | patchInfo, 84 | }; 85 | }; 86 | -------------------------------------------------------------------------------- /src/hooks/use-current-proxy.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | import { useAppData } from "@/providers/app-data-context"; 4 | 5 | // 定义代理组类型 6 | interface ProxyGroup { 7 | name: string; 8 | now: string; 9 | } 10 | 11 | // 获取当前代理节点信息的自定义Hook 12 | export const useCurrentProxy = () => { 13 | // 从AppDataProvider获取数据 14 | const { proxies, clashConfig, refreshProxy } = useAppData(); 15 | 16 | // 获取当前模式 17 | const currentMode = clashConfig?.mode?.toLowerCase() || "rule"; 18 | 19 | // 获取当前代理节点信息 20 | const currentProxyInfo = useMemo(() => { 21 | if (!proxies) return { currentProxy: null, primaryGroupName: null }; 22 | 23 | const { global, groups, records } = proxies; 24 | 25 | // 默认信息 26 | let primaryGroupName = "GLOBAL"; 27 | let currentName = global?.now; 28 | 29 | // 在规则模式下,寻找主要代理组(通常是第一个或者名字包含特定关键词的组) 30 | if (currentMode === "rule" && groups.length > 0) { 31 | // 查找主要的代理组(优先级:包含关键词 > 第一个非GLOBAL组) 32 | const primaryKeywords = [ 33 | "auto", 34 | "select", 35 | "proxy", 36 | "节点选择", 37 | "自动选择", 38 | ]; 39 | const primaryGroup = 40 | groups.find((group: ProxyGroup) => 41 | primaryKeywords.some((keyword) => 42 | group.name.toLowerCase().includes(keyword.toLowerCase()), 43 | ), 44 | ) || groups.filter((g: ProxyGroup) => g.name !== "GLOBAL")[0]; 45 | 46 | if (primaryGroup) { 47 | primaryGroupName = primaryGroup.name; 48 | currentName = primaryGroup.now; 49 | } 50 | } 51 | 52 | // 如果找不到当前节点,返回null 53 | if (!currentName) return { currentProxy: null, primaryGroupName }; 54 | 55 | // 获取完整的节点信息 56 | const currentProxy = records[currentName] || { 57 | name: currentName, 58 | type: "Unknown", 59 | udp: false, 60 | xudp: false, 61 | tfo: false, 62 | mptcp: false, 63 | smux: false, 64 | history: [], 65 | }; 66 | 67 | return { currentProxy, primaryGroupName }; 68 | }, [proxies, currentMode]); 69 | 70 | return { 71 | currentProxy: currentProxyInfo.currentProxy, 72 | primaryGroupName: currentProxyInfo.primaryGroupName, 73 | mode: currentMode, 74 | refreshProxy, 75 | }; 76 | }; 77 | -------------------------------------------------------------------------------- /src/hooks/use-i18n.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | import { changeLanguage, supportedLanguages } from "@/services/i18n"; 5 | 6 | import { useVerge } from "./use-verge"; 7 | 8 | export const useI18n = () => { 9 | const { i18n, t } = useTranslation(); 10 | const { patchVerge } = useVerge(); 11 | const [isLoading, setIsLoading] = useState(false); 12 | 13 | const switchLanguage = useCallback( 14 | async (language: string) => { 15 | if (!supportedLanguages.includes(language)) { 16 | console.warn(`Unsupported language: ${language}`); 17 | return; 18 | } 19 | 20 | if (i18n.language === language) { 21 | return; 22 | } 23 | 24 | setIsLoading(true); 25 | try { 26 | await changeLanguage(language); 27 | 28 | if (patchVerge) { 29 | await patchVerge({ language }); 30 | } 31 | } catch (error) { 32 | console.error("Failed to change language:", error); 33 | } finally { 34 | setIsLoading(false); 35 | } 36 | }, 37 | [i18n.language, patchVerge], 38 | ); 39 | 40 | return { 41 | currentLanguage: i18n.language, 42 | supportedLanguages, 43 | switchLanguage, 44 | isLoading, 45 | t, 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/hooks/use-listen.ts: -------------------------------------------------------------------------------- 1 | import { event } from "@tauri-apps/api"; 2 | import { listen, UnlistenFn, EventCallback } from "@tauri-apps/api/event"; 3 | import { useEffect, useRef } from "react"; 4 | 5 | export const useListen = () => { 6 | const unlistenFns = useRef([]); 7 | 8 | const addListener = async ( 9 | eventName: string, 10 | handler: EventCallback, 11 | ) => { 12 | const unlisten = await listen(eventName, handler); 13 | unlistenFns.current.push(unlisten); 14 | return unlisten; 15 | }; 16 | 17 | const removeAllListeners = () => { 18 | unlistenFns.current.forEach((unlisten) => unlisten()); 19 | unlistenFns.current = []; 20 | }; 21 | 22 | const setupCloseListener = async function () { 23 | await event.once("tauri://close-requested", async () => { 24 | removeAllListeners(); 25 | }); 26 | }; 27 | 28 | // 组件卸载时清理所有监听器 29 | useEffect(() => { 30 | return () => { 31 | removeAllListeners(); 32 | }; 33 | }, []); 34 | 35 | return { 36 | addListener, 37 | setupCloseListener, 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/hooks/use-log-data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useGlobalLogData, 3 | clearGlobalLogs, 4 | // LogLevel, 5 | } from "@/services/global-log-service"; 6 | 7 | // 为了向后兼容,导出相同的类型 8 | // export type { LogLevel }; 9 | 10 | export const useLogData = useGlobalLogData; 11 | 12 | export const clearLogs = clearGlobalLogs; 13 | -------------------------------------------------------------------------------- /src/hooks/use-system-proxy-state.ts: -------------------------------------------------------------------------------- 1 | import useSWR, { mutate } from "swr"; 2 | 3 | import { systemProxySWRConfig } from "@/config/swr-config"; 4 | import { closeAllConnections } from "tauri-plugin-mihomo-api"; 5 | 6 | import { useVerge } from "@/hooks/use-verge"; 7 | import { useAppData } from "@/providers/app-data-context"; 8 | import { getAutotemProxy } from "@/services/cmds"; 9 | import { getLogger } from "@/utils/logger"; 10 | 11 | const logger = getLogger("useSystemProxyState"); 12 | 13 | export const useSystemProxyState = () => { 14 | const { verge, mutateVerge, patchVerge } = useVerge(); 15 | const { sysproxy } = useAppData(); 16 | const { data: autoproxy } = useSWR( 17 | "getAutotemProxy", 18 | getAutotemProxy, 19 | systemProxySWRConfig, 20 | ); 21 | 22 | const { enable_system_proxy, proxy_auto_config } = verge ?? {}; 23 | 24 | const getSystemProxyActualState = () => { 25 | const userEnabled = enable_system_proxy ?? false; 26 | 27 | // 用户配置状态应该与系统实际状态一致 28 | // 如果用户启用了系统代理,检查实际的系统状态 29 | if (userEnabled) { 30 | if (proxy_auto_config) { 31 | return autoproxy?.enable ?? false; 32 | } else { 33 | return sysproxy?.enable ?? false; 34 | } 35 | } 36 | 37 | // 用户没有启用时,返回 false 38 | return false; 39 | }; 40 | 41 | const getSystemProxyIndicator = () => { 42 | if (proxy_auto_config) { 43 | return autoproxy?.enable ?? false; 44 | } else { 45 | return sysproxy?.enable ?? false; 46 | } 47 | }; 48 | 49 | const updateProxyStatus = async () => { 50 | await new Promise((resolve) => setTimeout(resolve, 100)); 51 | await mutate("getSystemProxy"); 52 | await mutate("getAutotemProxy"); 53 | }; 54 | 55 | const toggleSystemProxy = async (enabled: boolean) => { 56 | // 乐观更新 57 | mutateVerge({ ...verge, enable_system_proxy: enabled }, false); 58 | 59 | try { 60 | if (!enabled && verge?.auto_close_connection) { 61 | closeAllConnections(); 62 | } 63 | await patchVerge({ enable_system_proxy: enabled }); 64 | await updateProxyStatus(); 65 | } catch (error) { 66 | console.warn("[useSystemProxyState] toggleSystemProxy failed:", error); 67 | // 回滚状态 68 | mutateVerge({ ...verge, enable_system_proxy: !enabled }, false); 69 | throw error; 70 | } 71 | }; 72 | 73 | return { 74 | actualState: getSystemProxyActualState(), 75 | indicator: getSystemProxyIndicator(), 76 | configState: enable_system_proxy ?? false, 77 | sysproxy, 78 | autoproxy, 79 | proxy_auto_config, 80 | toggleSystemProxy, 81 | }; 82 | }; 83 | -------------------------------------------------------------------------------- /src/hooks/use-traffic-quota-reminder.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | import { showNotice } from "@/services/noticeService"; 5 | 6 | import { useProfiles } from "./use-profiles"; 7 | import { useVerge } from "./use-verge"; 8 | 9 | const ONE_HOUR = 60 * 60 * 1000; // 1小时的毫秒数 10 | 11 | export const useTrafficQuotaReminder = () => { 12 | const { t } = useTranslation(); 13 | const { verge, patchVerge } = useVerge(); 14 | const { current } = useProfiles(); 15 | const lastCheckRef = useRef(0); 16 | 17 | useEffect(() => { 18 | // 检查是否启用了流量提醒 19 | const reminderConfig = verge?.traffic_quota_reminder; 20 | if (!reminderConfig?.enabled) { 21 | return; 22 | } 23 | 24 | // 检查是否有当前配置和流量信息 25 | if (!current?.extra?.total || !current?.extra) { 26 | return; 27 | } 28 | 29 | const usedTraffic = current.extra.upload + current.extra.download; 30 | const totalTraffic = current.extra.total; 31 | const threshold = reminderConfig.threshold || 80; // 默认80% 32 | 33 | // 计算使用百分比 34 | const usagePercentage = (usedTraffic / totalTraffic) * 100; 35 | 36 | // 如果超过阈值,且距离上次提醒超过1小时 37 | const now = Date.now(); 38 | const lastReminder = reminderConfig.last_reminder || 0; 39 | 40 | if (usagePercentage >= threshold) { 41 | // 防止频繁检查,只在每次渲染间隔超过5秒时检查 42 | if (now - lastCheckRef.current < 5000) { 43 | return; 44 | } 45 | lastCheckRef.current = now; 46 | 47 | // 如果距离上次提醒超过1小时,再次提醒 48 | if (now - lastReminder > ONE_HOUR) { 49 | const remainingPercentage = 100 - usagePercentage; 50 | 51 | showNotice( 52 | "error", 53 | t("Traffic Quota Warning", { 54 | usage: usagePercentage.toFixed(1), 55 | remaining: remainingPercentage.toFixed(1), 56 | }), 57 | 8000, 58 | ); 59 | 60 | // 更新最后提醒时间 61 | patchVerge({ 62 | traffic_quota_reminder: { 63 | ...reminderConfig, 64 | last_reminder: now, 65 | }, 66 | }); 67 | } 68 | } 69 | }, [current, verge, patchVerge, t]); 70 | }; 71 | -------------------------------------------------------------------------------- /src/hooks/use-verge.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import useSWR from "swr"; 4 | 5 | import { useSystemState } from "@/hooks/use-system-state"; 6 | import { getVergeConfig, patchVergeConfig } from "@/services/cmds"; 7 | import { showNotice } from "@/services/noticeService"; 8 | import { getLogger } from "@/utils/logger"; 9 | 10 | const logger = getLogger("useVerge"); 11 | 12 | export const useVerge = () => { 13 | const { t } = useTranslation(); 14 | const { isTunModeAvailable, isLoading } = useSystemState(); 15 | 16 | const isProcessingRef = useRef(false); 17 | const hasNotifiedRef = useRef(false); 18 | const initTimeRef = useRef(Date.now()); 19 | 20 | const { data: verge, mutate: mutateVerge } = useSWR( 21 | "getVergeConfig", 22 | async () => { 23 | const config = await getVergeConfig(); 24 | return config; 25 | }, 26 | ); 27 | 28 | const patchVerge = async (value: Partial) => { 29 | await patchVergeConfig(value); 30 | mutateVerge(); 31 | }; 32 | 33 | const { enable_tun_mode } = verge ?? {}; 34 | 35 | useEffect(() => { 36 | const timeSinceInit = Date.now() - initTimeRef.current; 37 | // 应用启动5秒内不处理,避免初始化时误判 38 | if (timeSinceInit < 5000) { 39 | return; 40 | } 41 | 42 | // 避免重复处理 43 | if (isProcessingRef.current || hasNotifiedRef.current) { 44 | return; 45 | } 46 | 47 | // TUN模式启用但不可用时自动关闭 48 | if (enable_tun_mode && !isTunModeAvailable && !isLoading) { 49 | logger.info("检测到服务不可用,自动关闭TUN模式"); 50 | 51 | isProcessingRef.current = true; 52 | 53 | patchVergeConfig({ enable_tun_mode: false }) 54 | .then(() => { 55 | mutateVerge(); 56 | if (!hasNotifiedRef.current) { 57 | showNotice( 58 | "info", 59 | t("TUN Mode automatically disabled due to service unavailable"), 60 | ); 61 | hasNotifiedRef.current = true; 62 | } 63 | }) 64 | .catch((err) => { 65 | logger.error("自动关闭TUN模式失败:", err); 66 | if (!hasNotifiedRef.current) { 67 | showNotice("error", t("Failed to disable TUN Mode automatically")); 68 | hasNotifiedRef.current = true; 69 | } 70 | }) 71 | .finally(() => { 72 | isProcessingRef.current = false; 73 | }); 74 | } 75 | }, [isTunModeAvailable, isLoading, enable_tun_mode, t, mutateVerge]); 76 | 77 | return { 78 | verge, 79 | mutateVerge, 80 | patchVerge, 81 | }; 82 | }; 83 | -------------------------------------------------------------------------------- /src/hooks/use-visibility.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useVisibility = () => { 4 | const [visible, setVisible] = useState(() => 5 | typeof document === "undefined" 6 | ? true 7 | : document.visibilityState === "visible", 8 | ); 9 | 10 | useEffect(() => { 11 | const handleVisibilityChange = () => { 12 | setVisible(document.visibilityState === "visible"); 13 | }; 14 | 15 | const handleFocus = () => setVisible(true); 16 | const handlePointerDown = () => setVisible(true); 17 | 18 | document.addEventListener("focus", handleFocus); 19 | document.addEventListener("pointerdown", handlePointerDown); 20 | document.addEventListener("visibilitychange", handleVisibilityChange); 21 | 22 | return () => { 23 | document.removeEventListener("focus", handleFocus); 24 | document.removeEventListener("pointerdown", handlePointerDown); 25 | document.removeEventListener("visibilitychange", handleVisibilityChange); 26 | }; 27 | }, []); 28 | 29 | return visible; 30 | }; 31 | -------------------------------------------------------------------------------- /src/hooks/use-window.ts: -------------------------------------------------------------------------------- 1 | import { use } from "react"; 2 | 3 | import { WindowContext, type WindowContextType } from "@/providers/window"; 4 | 5 | export const useWindow = () => { 6 | const context = use(WindowContext); 7 | if (context === undefined) { 8 | throw new Error("useWindow must be used within WindowProvider"); 9 | } 10 | return context; 11 | }; 12 | 13 | export const useWindowControls = () => { 14 | const { 15 | maximized, 16 | minimize, 17 | toggleMaximize, 18 | close, 19 | toggleFullscreen, 20 | currentWindow, 21 | } = useWindow(); 22 | return { 23 | maximized, 24 | minimize, 25 | toggleMaximize, 26 | close, 27 | toggleFullscreen, 28 | currentWindow, 29 | } satisfies Pick< 30 | WindowContextType, 31 | | "maximized" 32 | | "minimize" 33 | | "toggleMaximize" 34 | | "close" 35 | | "toggleFullscreen" 36 | | "currentWindow" 37 | >; 38 | }; 39 | 40 | export const useWindowDecorations = () => { 41 | const { decorated, toggleDecorations, refreshDecorated } = useWindow(); 42 | return { decorated, toggleDecorations, refreshDecorated } satisfies Pick< 43 | WindowContextType, 44 | "decorated" | "toggleDecorations" | "refreshDecorated" 45 | >; 46 | }; 47 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | NeedyClash 12 | 28 | 45 | 46 | 47 | 48 |
49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/pages/_theme.tsx: -------------------------------------------------------------------------------- 1 | import getSystem from "@/utils/get-system"; 2 | const OS = getSystem(); 3 | 4 | // 配色 5 | export const defaultTheme = { 6 | primary_color: "#7C3AED", 7 | secondary_color: "#EC4899", 8 | primary_text: "#0F172A", 9 | secondary_text: "#64748B", 10 | info_color: "#0EA5E9", 11 | error_color: "#F43F5E", 12 | warning_color: "#F59E0B", 13 | success_color: "#10B981", 14 | background_color: "#F8FAFC", 15 | font_family: `-apple-system, BlinkMacSystemFont,"Microsoft YaHei UI", "Microsoft YaHei", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji"${ 16 | OS === "windows" ? ", twemoji mozilla" : "" 17 | }`, 18 | }; 19 | 20 | // 深色模式 21 | export const defaultDarkTheme = { 22 | ...defaultTheme, 23 | primary_color: "#A78BFA", 24 | secondary_color: "#F472B6", 25 | primary_text: "#E2E8F0", 26 | background_color: "#282828", 27 | secondary_text: "#94A3B8", 28 | info_color: "#60A5FA", 29 | error_color: "#F87171", 30 | warning_color: "#FBBF24", 31 | success_color: "#34D399", 32 | }; 33 | -------------------------------------------------------------------------------- /src/polyfills/RegExp.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | if (typeof window.RegExp === "undefined") { 3 | return; 4 | } 5 | 6 | const originalRegExp = window.RegExp; 7 | 8 | window.RegExp = function (pattern, flags) { 9 | if (pattern instanceof originalRegExp && flags === undefined) { 10 | flags = pattern.flags; 11 | } 12 | 13 | if (flags) { 14 | if ( 15 | !Object.prototype.hasOwnProperty.call( 16 | originalRegExp.prototype, 17 | "unicodeSets", 18 | ) 19 | ) { 20 | if (flags.includes("v")) { 21 | flags = flags.replace("v", "u"); 22 | } 23 | } 24 | 25 | if ( 26 | !Object.prototype.hasOwnProperty.call( 27 | originalRegExp.prototype, 28 | "hasIndices", 29 | ) 30 | ) { 31 | if (flags.includes("d")) { 32 | flags = flags.replace("d", ""); 33 | } 34 | } 35 | } 36 | 37 | return new originalRegExp(pattern, flags); 38 | }; 39 | window.RegExp.prototype = originalRegExp.prototype; 40 | })(); 41 | -------------------------------------------------------------------------------- /src/polyfills/WeakRef.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | if (typeof window.WeakRef !== "undefined") { 3 | return; 4 | } 5 | 6 | window.WeakRef = (function (weakMap) { 7 | function WeakRef(target) { 8 | weakMap.set(this, target); 9 | } 10 | WeakRef.prototype.deref = function () { 11 | return weakMap.get(this); 12 | }; 13 | 14 | return WeakRef; 15 | })(new WeakMap()); 16 | })(); 17 | -------------------------------------------------------------------------------- /src/polyfills/matchMedia.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | if (window.matchMedia && window.matchMedia("all").addEventListener) { 3 | return; 4 | } 5 | 6 | const originalMatchMedia = window.matchMedia; 7 | 8 | window.matchMedia = function (query) { 9 | const mediaQueryList = originalMatchMedia(query); 10 | 11 | if (!mediaQueryList.addEventListener) { 12 | mediaQueryList.addEventListener = function (eventType, listener) { 13 | if (eventType !== "change" || typeof listener !== "function") { 14 | console.error("Invalid arguments for addEventListener:", arguments); 15 | return; 16 | } 17 | mediaQueryList.addListener(listener); 18 | }; 19 | } 20 | 21 | if (!mediaQueryList.removeEventListener) { 22 | mediaQueryList.removeEventListener = function (eventType, listener) { 23 | if (eventType !== "change" || typeof listener !== "function") { 24 | console.error( 25 | "Invalid arguments for removeEventListener:", 26 | arguments, 27 | ); 28 | return; 29 | } 30 | mediaQueryList.removeListener(listener); 31 | }; 32 | } 33 | 34 | return mediaQueryList; 35 | }; 36 | })(); 37 | -------------------------------------------------------------------------------- /src/providers/app-data-context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, use } from "react"; 2 | import { 3 | BaseConfig, 4 | ProxyProvider, 5 | Rule, 6 | RuleProvider, 7 | } from "tauri-plugin-mihomo-api"; 8 | 9 | export interface AppDataContextType { 10 | proxies: any; 11 | clashConfig: BaseConfig; 12 | rules: Rule[]; 13 | sysproxy: any; 14 | runningMode?: string; 15 | uptime: number; 16 | proxyProviders: Record; 17 | ruleProviders: Record; 18 | systemProxyAddress: string; 19 | 20 | refreshProxy: () => Promise; 21 | refreshClashConfig: () => Promise; 22 | refreshRules: () => Promise; 23 | refreshSysproxy: () => Promise; 24 | refreshProxyProviders: () => Promise; 25 | refreshRuleProviders: () => Promise; 26 | refreshAll: () => Promise; 27 | } 28 | 29 | export interface ConnectionWithSpeed extends IConnectionsItem { 30 | curUpload: number; 31 | curDownload: number; 32 | } 33 | 34 | export interface ConnectionSpeedData { 35 | id: string; 36 | upload: number; 37 | download: number; 38 | timestamp: number; 39 | } 40 | 41 | export const AppDataContext = createContext(null); 42 | 43 | export const useAppData = () => { 44 | const context = use(AppDataContext); 45 | 46 | if (!context) { 47 | throw new Error("useAppData必须在AppDataProvider内使用"); 48 | } 49 | 50 | return context; 51 | }; 52 | -------------------------------------------------------------------------------- /src/providers/chain-proxy-context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, use } from "react"; 2 | 3 | export interface ChainProxyContextType { 4 | isChainMode: boolean; 5 | setChainMode: (isChain: boolean) => void; 6 | chainConfigData: string | null; 7 | setChainConfigData: (data: string | null) => void; 8 | } 9 | 10 | export const ChainProxyContext = createContext( 11 | null, 12 | ); 13 | 14 | export const useChainProxy = () => { 15 | const context = use(ChainProxyContext); 16 | if (!context) { 17 | throw new Error("useChainProxy must be used within a ChainProxyProvider"); 18 | } 19 | return context; 20 | }; 21 | -------------------------------------------------------------------------------- /src/providers/chain-proxy-provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, useState } from "react"; 2 | 3 | import { ChainProxyContext } from "./chain-proxy-context"; 4 | 5 | export const ChainProxyProvider = ({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) => { 10 | const [isChainMode, setIsChainMode] = useState(false); 11 | const [chainConfigData, setChainConfigData] = useState(null); 12 | 13 | const setChainMode = useCallback((isChain: boolean) => { 14 | setIsChainMode(isChain); 15 | }, []); 16 | 17 | const setChainConfigDataCallback = useCallback((data: string | null) => { 18 | setChainConfigData(data); 19 | }, []); 20 | 21 | const contextValue = useMemo( 22 | () => ({ 23 | isChainMode, 24 | setChainMode, 25 | chainConfigData, 26 | setChainConfigData: setChainConfigDataCallback, 27 | }), 28 | [isChainMode, setChainMode, chainConfigData, setChainConfigDataCallback], 29 | ); 30 | 31 | return {children}; 32 | }; 33 | -------------------------------------------------------------------------------- /src/providers/window/WindowContext.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentWindow } from "@tauri-apps/api/window"; 2 | import { createContext } from "react"; 3 | 4 | export interface WindowContextType { 5 | decorated: boolean | null; 6 | maximized: boolean | null; 7 | toggleDecorations: () => Promise; 8 | refreshDecorated: () => Promise; 9 | minimize: () => void; 10 | close: () => void; 11 | toggleMaximize: () => Promise; 12 | toggleFullscreen: () => Promise; 13 | currentWindow: ReturnType; 14 | } 15 | 16 | export const WindowContext = createContext( 17 | undefined, 18 | ); 19 | -------------------------------------------------------------------------------- /src/providers/window/index.ts: -------------------------------------------------------------------------------- 1 | export { WindowContext } from "./WindowContext"; 2 | export type { WindowContextType } from "./WindowContext"; 3 | export { WindowProvider } from "./WindowProvider"; 4 | -------------------------------------------------------------------------------- /src/services/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import { initReactI18next } from "react-i18next"; 3 | 4 | export const supportedLanguages = [ 5 | "en", 6 | "ru", 7 | "zh", 8 | "fa", 9 | "tt", 10 | "id", 11 | "ar", 12 | "ko", 13 | "tr", 14 | "de", 15 | "es", 16 | "jp", 17 | "zhtw", 18 | ]; 19 | 20 | export const languages: Record = supportedLanguages.reduce( 21 | (acc, lang) => { 22 | acc[lang] = {}; 23 | return acc; 24 | }, 25 | {} as Record, 26 | ); 27 | 28 | export const loadLanguage = async (language: string) => { 29 | try { 30 | const module = await import(`@/locales/${language}.json`); 31 | return module.default; 32 | } catch (error) { 33 | console.warn( 34 | `Failed to load language ${language}, fallback to zh, ${error}`, 35 | ); 36 | const fallback = await import("@/locales/zh.json"); 37 | return fallback.default; 38 | } 39 | }; 40 | 41 | i18n.use(initReactI18next).init({ 42 | resources: {}, 43 | lng: "zh", 44 | fallbackLng: "zh", 45 | interpolation: { 46 | escapeValue: false, 47 | }, 48 | }); 49 | 50 | export const changeLanguage = async (language: string) => { 51 | if (!i18n.hasResourceBundle(language, "translation")) { 52 | const resources = await loadLanguage(language); 53 | i18n.addResourceBundle(language, "translation", resources); 54 | } 55 | 56 | await i18n.changeLanguage(language); 57 | }; 58 | 59 | export const initializeLanguage = async (initialLanguage: string = "zh") => { 60 | await changeLanguage(initialLanguage); 61 | }; 62 | -------------------------------------------------------------------------------- /src/services/ipc-log-service.ts: -------------------------------------------------------------------------------- 1 | // IPC-based log service using Tauri commands with streaming support 2 | 3 | import { clearLogs as clearLogsCmd } from "@/services/cmds"; 4 | 5 | type LogLevel = "debug" | "info" | "warning" | "error" | "all"; 6 | 7 | interface ILogItem { 8 | time?: string; 9 | type: string; 10 | payload: string; 11 | [key: string]: any; 12 | } 13 | 14 | // Start logs monitoring with specified level 15 | export const startLogsStreaming = async (logLevel: LogLevel = "info") => { 16 | try { 17 | // await startLogsMonitoring(logLevel === "all" ? undefined : logLevel); 18 | console.log( 19 | `[IPC-LogService] Started logs monitoring with level: ${logLevel}`, 20 | ); 21 | } catch (error) { 22 | console.error("[IPC-LogService] Failed to start logs monitoring:", error); 23 | } 24 | }; 25 | 26 | // Stop logs monitoring 27 | export const stopLogsStreaming = async () => { 28 | try { 29 | // await stopLogsMonitoring(); 30 | console.log("[IPC-LogService] Stopped logs monitoring"); 31 | } catch (error) { 32 | console.error("[IPC-LogService] Failed to stop logs monitoring:", error); 33 | } 34 | }; 35 | 36 | // Fetch logs using IPC command (now from streaming cache) 37 | export const fetchLogsViaIPC = async (): Promise => { 38 | try { 39 | // Server-side filtering handles the level via /logs?level={level} 40 | // We just fetch all cached logs regardless of the logLevel parameter 41 | // const response = await getClashLogs(); 42 | 43 | // // The response should be in the format expected by the frontend 44 | // // Transform the logs to match the expected format 45 | // if (Array.isArray(response)) { 46 | // return response.map((log: any) => ({ 47 | // ...log, 48 | // time: log.time || dayjs().format("HH:mm:ss"), 49 | // })); 50 | // } 51 | 52 | return []; 53 | } catch (error) { 54 | console.error("[IPC-LogService] Failed to fetch logs:", error); 55 | return []; 56 | } 57 | }; 58 | 59 | // Clear logs 60 | export const clearLogs = async () => { 61 | try { 62 | await clearLogsCmd(); 63 | console.log("[IPC-LogService] Logs cleared"); 64 | } catch (error) { 65 | console.error("[IPC-LogService] Failed to clear logs:", error); 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /src/services/states.ts: -------------------------------------------------------------------------------- 1 | import { createContextState } from "foxact/create-context-state"; 2 | import { useLocalStorage } from "foxact/use-local-storage"; 3 | import { LogLevel } from "tauri-plugin-mihomo-api"; 4 | 5 | const [ThemeModeProvider, useThemeMode, useSetThemeMode] = createContextState< 6 | "light" | "dark" 7 | >("light"); 8 | 9 | export type LogFilter = "all" | "debug" | "info" | "warn" | "err"; 10 | 11 | interface IClashLog { 12 | enable: boolean; 13 | logLevel: LogLevel; 14 | logFilter: LogFilter; 15 | } 16 | const defaultClashLog: IClashLog = { 17 | enable: true, 18 | logLevel: "info", 19 | logFilter: "all", 20 | }; 21 | export const useClashLog = () => 22 | useLocalStorage("clash-log", defaultClashLog, { 23 | serializer: JSON.stringify, 24 | deserializer: JSON.parse, 25 | }); 26 | 27 | // export const useEnableLog = () => useLocalStorage("enable-log", false); 28 | 29 | interface IConnectionSetting { 30 | layout: "table" | "list"; 31 | } 32 | 33 | const defaultConnectionSetting: IConnectionSetting = { layout: "table" }; 34 | 35 | export const useConnectionSetting = () => 36 | useLocalStorage( 37 | "connections-setting", 38 | defaultConnectionSetting, 39 | { 40 | serializer: JSON.stringify, 41 | deserializer: JSON.parse, 42 | }, 43 | ); 44 | 45 | // save the state of each profile item loading 46 | const [LoadingCacheProvider, useLoadingCache, useSetLoadingCache] = 47 | createContextState>({}); 48 | 49 | // save update state 50 | const [UpdateStateProvider, useUpdateState, useSetUpdateState] = 51 | createContextState(false); 52 | 53 | export { 54 | ThemeModeProvider, 55 | useThemeMode, 56 | useSetThemeMode, 57 | LoadingCacheProvider, 58 | useLoadingCache, 59 | useSetLoadingCache, 60 | UpdateStateProvider, 61 | useUpdateState, 62 | useSetUpdateState, 63 | }; 64 | -------------------------------------------------------------------------------- /src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | export default function debounce void>( 2 | func: T, 3 | wait: number, 4 | ): T { 5 | let timeout: ReturnType | null = null; 6 | return function (this: any, ...args: Parameters) { 7 | if (timeout !== null) { 8 | clearTimeout(timeout); 9 | } 10 | timeout = setTimeout(() => func.apply(this, args), wait); 11 | } as T; 12 | } 13 | -------------------------------------------------------------------------------- /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/helper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Validates if a string is a valid URL 3 | * @param url - The URL string to validate 4 | * @returns true if valid, false otherwise 5 | */ 6 | export const isValidUrl = (url: string): boolean => { 7 | if (!url || typeof url !== "string") { 8 | return false; 9 | } 10 | 11 | try { 12 | new URL(url); 13 | return true; 14 | } catch (error) { 15 | // Invalid URL format, return false silently as this is expected behavior 16 | return false; 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /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/is-async-function.ts: -------------------------------------------------------------------------------- 1 | export default function isAsyncFunction(fn: (...args: any[]) => any): boolean { 2 | return fn.constructor.name === "AsyncFunction"; 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/noop.ts: -------------------------------------------------------------------------------- 1 | export default function noop() {} 2 | -------------------------------------------------------------------------------- /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/port-validator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 端口验证范围常量 3 | * MIN: 1024 - 标准非特权端口起始值(1-1023为系统保留端口) 4 | * MAX: 65535 - 最大端口号 5 | */ 6 | export const PORT_RANGE = { 7 | MIN: 1024, 8 | MAX: 65535, 9 | } as const; 10 | 11 | /** 端口类型 */ 12 | export type PortType = 13 | | "port" 14 | | "socks-port" 15 | | "mixed-port" 16 | | "redir-port" 17 | | "tproxy-port"; 18 | 19 | /** 20 | * 验证单个端口是否在有效范围内 21 | */ 22 | export const validatePort = (port: number): boolean => { 23 | return port >= PORT_RANGE.MIN && port <= PORT_RANGE.MAX; 24 | }; 25 | 26 | /** 27 | * 验证端口配置并抛出详细错误 28 | */ 29 | export const validatePortConfig = ( 30 | port: number, 31 | portType: PortType = "port", 32 | ): void => { 33 | if (port < PORT_RANGE.MIN) { 34 | throw new Error( 35 | `The ${portType} should not be less than ${PORT_RANGE.MIN}`, 36 | ); 37 | } 38 | if (port > PORT_RANGE.MAX) { 39 | throw new Error( 40 | `The ${portType} should not be greater than ${PORT_RANGE.MAX}`, 41 | ); 42 | } 43 | }; 44 | 45 | /** 46 | * 批量验证端口配置对象 47 | */ 48 | export const validatePorts = ( 49 | config: Partial>, 50 | ): void => { 51 | const portEntries = Object.entries(config).filter( 52 | ([_, value]) => value != null, 53 | ) as [PortType, number][]; 54 | 55 | for (const [portType, port] of portEntries) { 56 | validatePortConfig(port, portType); 57 | } 58 | }; 59 | 60 | /** 61 | * 检测端口冲突 62 | */ 63 | export const detectPortConflicts = ( 64 | ports: (number | undefined)[], 65 | ): boolean => { 66 | const validPorts = ports.filter((p) => p != null && p > 0); 67 | return new Set(validPorts).size !== validPorts.length; 68 | }; 69 | 70 | /** 71 | * 正则验证端口格式(用于表单输入) 72 | */ 73 | export const portRegex = 74 | /^(?:[1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$/; 75 | 76 | export const validatePortFormat = (value: string): boolean => { 77 | return portRegex.test(value); 78 | }; 79 | 80 | -------------------------------------------------------------------------------- /src/utils/safe-storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 安全的 localStorage 操作工具 3 | * 提供错误处理和回退机制 4 | */ 5 | 6 | interface StorageOptions { 7 | key: string; 8 | defaultValue: T; 9 | onError?: (error: Error) => void; 10 | } 11 | 12 | /** 13 | * 安全地从 localStorage 获取数据 14 | */ 15 | export function safeGetStorage(options: StorageOptions): T { 16 | const { key, defaultValue, onError } = options; 17 | 18 | try { 19 | const item = localStorage.getItem(key); 20 | if (item === null) { 21 | return defaultValue; 22 | } 23 | 24 | const parsed = JSON.parse(item); 25 | return parsed as T; 26 | } catch (error) { 27 | const err = error instanceof Error ? error : new Error(String(error)); 28 | console.error( 29 | `[SafeStorage] Failed to get item from localStorage (key: ${key}):`, 30 | err, 31 | ); 32 | onError?.(err); 33 | return defaultValue; 34 | } 35 | } 36 | 37 | /** 38 | * 安全地保存数据到 localStorage 39 | */ 40 | export function safeSetStorage( 41 | key: string, 42 | value: T, 43 | onError?: (error: Error) => void, 44 | ): boolean { 45 | try { 46 | const serialized = JSON.stringify(value); 47 | localStorage.setItem(key, serialized); 48 | return true; 49 | } catch (error) { 50 | const err = error instanceof Error ? error : new Error(String(error)); 51 | console.error( 52 | `[SafeStorage] Failed to set item to localStorage (key: ${key}):`, 53 | err, 54 | ); 55 | onError?.(err); 56 | return false; 57 | } 58 | } 59 | 60 | /** 61 | * 安全地从 localStorage 删除数据 62 | */ 63 | export function safeRemoveStorage( 64 | key: string, 65 | onError?: (error: Error) => void, 66 | ): boolean { 67 | try { 68 | localStorage.removeItem(key); 69 | return true; 70 | } catch (error) { 71 | const err = error instanceof Error ? error : new Error(String(error)); 72 | console.error( 73 | `[SafeStorage] Failed to remove item from localStorage (key: ${key}):`, 74 | err, 75 | ); 76 | onError?.(err); 77 | return false; 78 | } 79 | } 80 | 81 | /** 82 | * 检查 localStorage 是否可用 83 | */ 84 | export function isStorageAvailable(): boolean { 85 | try { 86 | const testKey = "__storage_test__"; 87 | localStorage.setItem(testKey, "test"); 88 | localStorage.removeItem(testKey); 89 | return true; 90 | } catch { 91 | return false; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------