├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report_zh.yml │ ├── config.yml │ └── feature_request_zh.yml └── workflows │ ├── build.yml │ └── issues.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.yaml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── aur ├── sparkle-bin │ ├── PKGBUILD │ ├── sparkle.install │ └── sparkle.sh ├── sparkle-electron-bin │ ├── PKGBUILD │ ├── sparkle.desktop │ ├── sparkle.install │ └── sparkle.sh ├── sparkle-electron-git │ ├── PKGBUILD │ ├── sparkle.desktop │ ├── sparkle.install │ └── sparkle.sh ├── sparkle-electron │ ├── PKGBUILD │ ├── sparkle.desktop │ ├── sparkle.install │ └── sparkle.sh ├── sparkle-git │ ├── PKGBUILD │ ├── sparkle.install │ └── sparkle.sh └── sparkle │ ├── PKGBUILD │ ├── sparkle.install │ └── sparkle.sh ├── build ├── entitlements.mac.plist ├── icon.icns ├── icon.ico ├── icon.png ├── installerIcon.ico ├── linux │ └── postinst └── pkg-scripts │ ├── postinstall │ └── preinstall ├── changelog.md ├── electron-builder.yml ├── electron.vite.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── resources ├── icon.ico ├── icon.png ├── iconTemplate.png └── subStoreIcon.png ├── scripts ├── checksum.mjs ├── prepare.mjs ├── telegram.mjs └── updater.mjs ├── src ├── main │ ├── config │ │ ├── app.ts │ │ ├── controledMihomo.ts │ │ ├── index.ts │ │ ├── override.ts │ │ └── profile.ts │ ├── core │ │ ├── factory.ts │ │ ├── manager.ts │ │ ├── mihomoApi.ts │ │ ├── profileUpdater.ts │ │ └── subStoreApi.ts │ ├── index.ts │ ├── resolve │ │ ├── autoUpdater.ts │ │ ├── backup.ts │ │ ├── floatingWindow.ts │ │ ├── gistApi.ts │ │ ├── server.ts │ │ ├── shortcut.ts │ │ ├── theme.ts │ │ ├── trafficMonitor.ts │ │ └── tray.ts │ ├── sys │ │ ├── autoRun.ts │ │ ├── interface.ts │ │ ├── misc.ts │ │ ├── ssid.ts │ │ └── sysproxy.ts │ └── utils │ │ ├── calc.ts │ │ ├── dirs.ts │ │ ├── image.ts │ │ ├── init.ts │ │ ├── ipc.ts │ │ ├── merge.ts │ │ └── template.ts ├── preload │ ├── index.d.ts │ └── index.ts ├── renderer │ ├── floating.html │ ├── index.html │ └── src │ │ ├── App.tsx │ │ ├── FloatingApp.tsx │ │ ├── assets │ │ ├── floating.css │ │ └── main.css │ │ ├── components │ │ ├── base │ │ │ ├── base-confirm.tsx │ │ │ ├── base-editor.tsx │ │ │ ├── base-error-boundary.tsx │ │ │ ├── base-list-editor.tsx │ │ │ ├── base-page.tsx │ │ │ ├── base-setting-card.tsx │ │ │ ├── base-setting-item.tsx │ │ │ ├── border-switch.css │ │ │ ├── border-swtich.tsx │ │ │ ├── collapse-input.tsx │ │ │ ├── interface-select.tsx │ │ │ ├── mihomo-icon.tsx │ │ │ └── substore-icon.tsx │ │ ├── connections │ │ │ ├── connection-detail-modal.tsx │ │ │ └── connection-item.tsx │ │ ├── logs │ │ │ └── log-item.tsx │ │ ├── mihomo │ │ │ ├── advanced-settings.tsx │ │ │ ├── controller-setting.tsx │ │ │ ├── env-setting.tsx │ │ │ ├── interface-modal.tsx │ │ │ └── port-setting.tsx │ │ ├── override │ │ │ ├── edit-file-modal.tsx │ │ │ ├── edit-info-modal.tsx │ │ │ ├── exec-log-modal.tsx │ │ │ └── override-item.tsx │ │ ├── profiles │ │ │ ├── edit-file-modal.tsx │ │ │ ├── edit-info-modal.tsx │ │ │ └── profile-item.tsx │ │ ├── proxies │ │ │ └── proxy-item.tsx │ │ ├── resources │ │ │ ├── geo-data.tsx │ │ │ ├── proxy-provider.tsx │ │ │ ├── rule-provider.tsx │ │ │ └── viewer.tsx │ │ ├── rules │ │ │ └── rule-item.tsx │ │ ├── settings │ │ │ ├── actions.tsx │ │ │ ├── appearance-confis.tsx │ │ │ ├── css-editor-modal.tsx │ │ │ ├── general-config.tsx │ │ │ ├── mihomo-config.tsx │ │ │ ├── shortcut-config.tsx │ │ │ ├── sider-config.tsx │ │ │ ├── substore-config.tsx │ │ │ ├── webdav-config.tsx │ │ │ └── webdav-restore-modal.tsx │ │ ├── sider │ │ │ ├── config-viewer.tsx │ │ │ ├── conn-card.tsx │ │ │ ├── dns-card.tsx │ │ │ ├── log-card.tsx │ │ │ ├── mihomo-core-card.tsx │ │ │ ├── outbound-mode-switcher.tsx │ │ │ ├── override-card.tsx │ │ │ ├── profile-card.tsx │ │ │ ├── proxy-card.tsx │ │ │ ├── resource-card.tsx │ │ │ ├── rule-card.tsx │ │ │ ├── sniff-card.tsx │ │ │ ├── substore-card.tsx │ │ │ ├── sysproxy-switcher.tsx │ │ │ └── tun-switcher.tsx │ │ ├── sysproxy │ │ │ ├── bypass-editor-modal.tsx │ │ │ └── pac-editor-modal.tsx │ │ └── updater │ │ │ ├── updater-button.tsx │ │ │ └── updater-modal.tsx │ │ ├── floating.tsx │ │ ├── hooks │ │ ├── use-app-config.tsx │ │ ├── use-controled-mihomo-config.tsx │ │ ├── use-groups.tsx │ │ ├── use-override-config.tsx │ │ ├── use-profile-config.tsx │ │ └── use-rules.tsx │ │ ├── main.tsx │ │ ├── pages │ │ ├── connections.tsx │ │ ├── dns.tsx │ │ ├── logs.tsx │ │ ├── mihomo.tsx │ │ ├── override.tsx │ │ ├── profiles.tsx │ │ ├── proxies.tsx │ │ ├── resources.tsx │ │ ├── rules.tsx │ │ ├── settings.tsx │ │ ├── sniffer.tsx │ │ ├── substore.tsx │ │ ├── syspeoxy.tsx │ │ └── tun.tsx │ │ ├── routes │ │ └── index.tsx │ │ └── utils │ │ ├── calc.ts │ │ ├── debounce.ts │ │ ├── env.d.ts │ │ ├── hash.ts │ │ ├── includes.ts │ │ ├── init.ts │ │ └── ipc.ts └── shared │ └── types.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── tsconfig.web.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .gitignore 5 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:react/recommended', 5 | 'plugin:react/jsx-runtime', 6 | '@electron-toolkit/eslint-config-ts/recommended', 7 | '@electron-toolkit/eslint-config-prettier' 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report_zh.yml: -------------------------------------------------------------------------------- 1 | name: 错误反馈 2 | description: '提交 mihomo-party 漏洞' 3 | title: '[Bug] ' 4 | body: 5 | - type: checkboxes 6 | id: ensure 7 | attributes: 8 | label: Verify steps 9 | description: 在提交之前,请勾选以下所有选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。 10 | options: 11 | - label: 我已在标题简短的描述了我所遇到的问题 12 | - label: 我已在 [Issue Tracker](./?q=is%3Aissue) 中寻找过我要提出的问题,但未找到相同的问题 13 | - label: 我已在 [常见问题](https://mihomo.party/docs/issues/common) 中寻找过我要提出的问题,并没有找到答案 14 | - label: 这是 GUI 程序的问题,而不是内核程序的问题 15 | - label: 我已经关闭所有杀毒软件/代理软件后测试过,问题依旧存在 16 | - label: 我已经使用最新的测试版本测试过,问题依旧存在 17 | 18 | - type: dropdown 19 | attributes: 20 | label: 操作系统 21 | description: 请提供操作系统类型 22 | multiple: true 23 | options: 24 | - MacOS 25 | - Windows 26 | - Linux 27 | validations: 28 | required: true 29 | - type: input 30 | attributes: 31 | label: 系统版本 32 | description: 请提供出现问题的操作系统版本 33 | validations: 34 | required: true 35 | - type: input 36 | attributes: 37 | label: 发生问题 mihomo-party 版本 38 | validations: 39 | required: true 40 | - type: textarea 41 | attributes: 42 | label: 描述 43 | description: 请提供错误的详细描述。 44 | validations: 45 | required: true 46 | - type: textarea 47 | attributes: 48 | label: 重现方式 49 | description: 请提供重现错误的步骤 50 | validations: 51 | required: true 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: '常见问题' 4 | about: '提出问题前请先查看常见问题' 5 | url: 'https://mihomo.party/docs/issues/common' 6 | - name: '交流群组' 7 | about: '提问/讨论性质的问题请勿提交issue' 8 | url: 'https://t.me/mihomo_party_group' 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request_zh.yml: -------------------------------------------------------------------------------- 1 | name: 功能请求 2 | description: '请求 mihomo-party 功能' 3 | title: '[Feature] ' 4 | body: 5 | - type: checkboxes 6 | id: ensure 7 | attributes: 8 | label: Verify steps 9 | description: 在提交之前,请勾选以下所有选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。 10 | options: 11 | - label: 我已在标题简短的描述了我所需的功能 12 | - label: 我已在 [Issue Tracker](./?q=is%3Aissue) 中寻找过,但未找到我所需的功能 13 | - label: 这是向 GUI 程序提出的功能请求,而不是内核程序 14 | - label: 我未在最新的测试版本找到我所需的功能 15 | 16 | - type: dropdown 17 | attributes: 18 | label: 操作系统 19 | description: 请提供操作系统类型 20 | multiple: true 21 | options: 22 | - MacOS 23 | - Windows 24 | - Linux 25 | validations: 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: 描述 30 | description: 请提供所需功能的详细描述 31 | validations: 32 | required: true 33 | -------------------------------------------------------------------------------- /.github/workflows/issues.yml: -------------------------------------------------------------------------------- 1 | name: Review Issues 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | review: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Generate Token 12 | uses: tibdex/github-app-token@v2 13 | id: generate 14 | with: 15 | app_id: ${{ secrets.BOT_APP_ID }} 16 | private_key: ${{ secrets.BOT_PRIVATE_KEY }} 17 | - name: Review Issues 18 | uses: mihomo-party-org/universal-assistant@v1.0.3 19 | with: 20 | github_token: ${{ steps.generate.outputs.token }} 21 | openai_base_url: ${{ secrets.OPENAI_BASE_URL }} 22 | openai_api_key: ${{ secrets.OPENAI_API_KEY }} 23 | openai_model: ${{ vars.OPENAI_MODEL }} 24 | system_prompt: ${{ vars.SYSTEM_PROMPT }} 25 | available_tools: ${{ vars.AVAILABLE_TOOLS }} 26 | user_input: | 27 | 请审查如下 Issue: 28 | 标题:"${{ github.event.issue.title }}" 29 | 内容:"${{ github.event.issue.body }}" 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | resources/files 3 | resources/sidecar 4 | extra 5 | dist 6 | out 7 | .DS_Store 8 | *.log* 9 | .idea 10 | *.ttf 11 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | virtual-store-dir-max-length=80 3 | public-hoist-pattern[]=*@heroui/* 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | pnpm-lock.yaml 4 | LICENSE.md 5 | tsconfig.json 6 | tsconfig.*.json 7 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | semi: false 3 | printWidth: 100 4 | trailingComma: none 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" 12 | }, 13 | "runtimeArgs": ["--sourcemap"], 14 | "env": { 15 | "REMOTE_DEBUGGING_PORT": "9222" 16 | } 17 | }, 18 | { 19 | "name": "Debug Renderer Process", 20 | "port": 9222, 21 | "request": "attach", 22 | "type": "chrome", 23 | "webRoot": "${workspaceFolder}/src/renderer", 24 | "timeout": 60000, 25 | "presentation": { 26 | "hidden": true 27 | } 28 | } 29 | ], 30 | "compounds": [ 31 | { 32 | "name": "Debug All", 33 | "configurations": ["Debug Main Process", "Debug Renderer Process"], 34 | "presentation": { 35 | "order": 1 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[typescriptreact]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[javascript]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[json]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | }, 14 | "editor.formatOnSave": true 15 | } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sparkle 2 | 3 |

Another Mihomo GUI

4 | 5 |

6 | 7 | 8 | 9 | 10 | 11 | 12 |

13 | 14 | ### 特性 15 | 16 | - [x] 开箱即用,无需服务模式的 Tun 17 | - [x] 多种配色主题可选,UI 焕然一新 18 | - [x] 支持大部分 Mihomo 常用配置修改 19 | - [x] 内置稳定版和预览版 Mihomo 内核 20 | - [x] 通过 WebDAV 一键备份和恢复配置 21 | - [x] 强大的覆写功能,任意修订配置文件 22 | - [x] 深度集成 Sub-Store,轻松管理订阅 23 | -------------------------------------------------------------------------------- /aur/sparkle-bin/PKGBUILD: -------------------------------------------------------------------------------- 1 | pkgname=sparkle-bin 2 | _pkgname=sparkle 3 | pkgver=1.6.2 4 | pkgrel=1 5 | pkgdesc="Another Mihomo GUI." 6 | arch=('x86_64' 'aarch64') 7 | url="https://github.com/xishang0128/sparkle" 8 | license=('GPL3') 9 | conflicts=("$_pkgname" "$_pkgname-git" "$_pkgname-electron" "$_pkgname-electron-bin" "$_pkgname-electron-git") 10 | conflicts=("sparkle-git" 'sparkle') 11 | depends=('gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs' 'libsecret') 12 | optdepends=('libappindicator-gtk3: Allow sparkle to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).') 13 | install=$_pkgname.install 14 | source=("${_pkgname}.sh") 15 | source_x86_64=("${_pkgname}-${pkgver}-x86_64.deb::${url}/releases/download/${pkgver}/sparkle-linux-${pkgver}-amd64.deb") 16 | source_aarch64=("${_pkgname}-${pkgver}-aarch64.deb::${url}/releases/download/${pkgver}/sparkle-linux-${pkgver}-arm64.deb") 17 | sha256sums=('03eb601fe981716e90f9170eeb36a2e7938587f05a1bdaa09adadb1229c77a0a') 18 | sha256sums_x86_64=('b8d166f1134573336aaae1866d25262284b0cbabbf393684226aca0fd8d1bd83') 19 | sha256sums_aarch64=('8cd7398b8fc1cd70d41e386af9995cbddc1043d9018391c29f056f1435712a10') 20 | 21 | package() { 22 | bsdtar -xf data.tar.xz -C "${pkgdir}/" 23 | chmod +x ${pkgdir}/opt/sparkle/sparkle 24 | chmod +x ${pkgdir}/opt/sparkle/resources/files/sysproxy 25 | chmod +sx ${pkgdir}/opt/sparkle/resources/sidecar/mihomo 26 | chmod +sx ${pkgdir}/opt/sparkle/resources/sidecar/mihomo-alpha 27 | install -Dm755 "${srcdir}/${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}" 28 | sed -i '3s!/opt/sparkle/sparkle!sparkle!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop" 29 | 30 | chown -R root:root ${pkgdir} 31 | } 32 | -------------------------------------------------------------------------------- /aur/sparkle-bin/sparkle.install: -------------------------------------------------------------------------------- 1 | # Colored makepkg-like functions 2 | note() { 3 | printf "${_blue}==>${_yellow} NOTE:${_bold} %s${_all_off}\n" "$1" 4 | } 5 | 6 | _all_off="$(tput sgr0)" 7 | _bold="${_all_off}$(tput bold)" 8 | _blue="${_bold}$(tput setaf 4)" 9 | _yellow="${_bold}$(tput setaf 3)" 10 | 11 | post_install() { 12 | note "Custom flags should be put directly in: ~/.config/sparkle-flags.conf" 13 | note "The launcher is called: 'sparkle'" 14 | } -------------------------------------------------------------------------------- /aur/sparkle-bin/sparkle.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-~/.config} 4 | 5 | # Allow users to override command-line options 6 | if [[ -f "${XDG_CONFIG_HOME}/sparkle-flags.conf" ]]; then 7 | mapfile -t MIHOMO_PARTY_USER_FLAGS <<<"$(grep -v '^#' "${XDG_CONFIG_HOME}/sparkle-flags.conf")" 8 | echo "User flags:" ${MIHOMO_PARTY_USER_FLAGS[@]} 9 | fi 10 | 11 | # Launch 12 | exec /opt/sparkle/sparkle ${MIHOMO_PARTY_USER_FLAGS[@]} "$@" 13 | -------------------------------------------------------------------------------- /aur/sparkle-electron-bin/PKGBUILD: -------------------------------------------------------------------------------- 1 | pkgname=sparkle-electron-bin 2 | _pkgname=sparkle 3 | pkgver=1.6.2 4 | pkgrel=1 5 | pkgdesc="Another Mihomo GUI." 6 | arch=('x86_64' 'aarch64') 7 | url="https://github.com/xishang0128/sparkle" 8 | license=('GPL3') 9 | conflicts=("$_pkgname" "$_pkgname-git" "$_pkgname-bin" "$_pkgname-electron" "$_pkgname-electron-git") 10 | depends=('electron' 'gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs' 'libsecret') 11 | optdepends=('libappindicator-gtk3: Allow sparkle to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).') 12 | makedepends=('asar') 13 | install=$_pkgname.install 14 | source=("${_pkgname}.desktop" "${_pkgname}.sh") 15 | source_x86_64=("${_pkgname}-${pkgver}-x86_64.deb::${url}/releases/download/${pkgver}/sparkle-linux-${pkgver}-amd64.deb") 16 | source_aarch64=("${_pkgname}-${pkgver}-aarch64.deb::${url}/releases/download/${pkgver}/sparkle-linux-${pkgver}-arm64.deb") 17 | sha256sums=( 18 | "b17d85f6d862285a53a24d0f8dedd08f1f3c852ba6a901fabc487177598803cc" 19 | "ce855656fb0682d403685244c77dd2d90ec6efb207753fb7a6ddc1e9b6aa2c49" 20 | ) 21 | sha256sums_x86_64=("43f8b9a5818a722cdb8e5044d2a90993274860b0da96961e1a2652169539ce39") 22 | sha256sums_aarch64=("18574fdeb01877a629aa52ac0175335ce27c83103db4fcb2f1ad69e3e42ee10f") 23 | options=('!lto') 24 | 25 | package() { 26 | bsdtar -xf data.tar.xz -C $srcdir 27 | asar extract $srcdir/opt/sparkle/resources/app.asar ${pkgdir}/opt/sparkle 28 | cp -r $srcdir/opt/sparkle/resources/sidecar ${pkgdir}/opt/sparkle/resources/ 29 | cp -r $srcdir/opt/sparkle/resources/files ${pkgdir}/opt/sparkle/resources/ 30 | chmod +x ${pkgdir}/opt/sparkle/resources/files/sysproxy 31 | chmod +sx ${pkgdir}/opt/sparkle/resources/sidecar/mihomo 32 | chmod +sx ${pkgdir}/opt/sparkle/resources/sidecar/mihomo-alpha 33 | install -Dm755 "${srcdir}/${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}" 34 | install -Dm644 "${_pkgname}.desktop" "${pkgdir}/usr/share/applications/${_pkgname}.desktop" 35 | install -Dm644 "${pkgdir}/opt/sparkle/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png" 36 | 37 | chown -R root:root ${pkgdir} 38 | } 39 | -------------------------------------------------------------------------------- /aur/sparkle-electron-bin/sparkle.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Sparkle 3 | Exec=sparkle %U 4 | Terminal=false 5 | Type=Application 6 | Icon=sparkle 7 | StartupWMClass=sparkle 8 | MimeType=x-scheme-handler/clash;x-scheme-handler/mihomo; 9 | Comment=Sparkle 10 | Categories=Utility; 11 | -------------------------------------------------------------------------------- /aur/sparkle-electron-bin/sparkle.install: -------------------------------------------------------------------------------- 1 | # Colored makepkg-like functions 2 | note() { 3 | printf "${_blue}==>${_yellow} NOTE:${_bold} %s${_all_off}\n" "$1" 4 | } 5 | 6 | _all_off="$(tput sgr0)" 7 | _bold="${_all_off}$(tput bold)" 8 | _blue="${_bold}$(tput setaf 4)" 9 | _yellow="${_bold}$(tput setaf 3)" 10 | 11 | post_install() { 12 | note "Custom flags should be put directly in: ~/.config/sparkle-flags.conf" 13 | note "The launcher is called: 'sparkle'" 14 | } -------------------------------------------------------------------------------- /aur/sparkle-electron-bin/sparkle.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-~/.config} 4 | 5 | # Allow users to override command-line options 6 | if [[ -f "${XDG_CONFIG_HOME}/sparkle-flags.conf" ]]; then 7 | mapfile -t MIHOMO_PARTY_USER_FLAGS <<<"$(grep -v '^#' "${XDG_CONFIG_HOME}/sparkle-flags.conf")" 8 | echo "User flags:" ${MIHOMO_PARTY_USER_FLAGS[@]} 9 | fi 10 | 11 | # Launch 12 | exec electron /opt/sparkle ${MIHOMO_PARTY_USER_FLAGS[@]} "$@" 13 | -------------------------------------------------------------------------------- /aur/sparkle-electron-git/PKGBUILD: -------------------------------------------------------------------------------- 1 | pkgname=sparkle-electron-git 2 | _pkgname=${pkgname%-electron-git} 3 | pkgver=r737.e4a7e67 4 | pkgrel=1 5 | pkgdesc="Another Mihomo GUI." 6 | arch=('x86_64' 'aarch64') 7 | url="https://github.com/xishang0128/sparkle" 8 | license=('GPL3') 9 | conflicts=("$_pkgname" "$_pkgname-git" "$_pkgname-bin" "$_pkgname-electron" "$_pkgname-electron-bin") 10 | depends=('electron' 'gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs' 'libsecret') 11 | optdepends=('libappindicator-gtk3: Allow sparkle to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).') 12 | makedepends=('nodejs' 'pnpm' 'libxcrypt-compat' 'asar') 13 | install=$_pkgname.install 14 | source=( 15 | "${_pkgname}.desktop" 16 | "${_pkgname}.sh" 17 | "git+$url.git" 18 | ) 19 | sha256sums=( 20 | "b17d85f6d862285a53a24d0f8dedd08f1f3c852ba6a901fabc487177598803cc" 21 | "ce855656fb0682d403685244c77dd2d90ec6efb207753fb7a6ddc1e9b6aa2c49" 22 | "SKIP" 23 | ) 24 | options=('!lto') 25 | 26 | pkgver() { 27 | cd $srcdir/${_pkgname} 28 | ( set -o pipefail 29 | git describe --long 2>/dev/null | sed 's/\([^-]*-g\)/r\1/;s/-/./g' | tr -d 'v' || 30 | printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" 31 | ) 32 | } 33 | 34 | prepare(){ 35 | cd $srcdir/${_pkgname} 36 | sed -i "s/productName: Sparkle/productName: sparkle/" electron-builder.yml 37 | pnpm install 38 | } 39 | 40 | build(){ 41 | cd $srcdir/${_pkgname} 42 | pnpm build:linux deb 43 | } 44 | 45 | package() { 46 | asar extract $srcdir/${_pkgname}/dist/linux-unpacked/resources/app.asar ${pkgdir}/opt/sparkle 47 | cp -r $srcdir/${_pkgname}/extra/sidecar ${pkgdir}/opt/sparkle/resources/ 48 | cp -r $srcdir/${_pkgname}/extra/files ${pkgdir}/opt/sparkle/resources/ 49 | chmod +x ${pkgdir}/opt/sparkle/resources/files/sysproxy 50 | chmod +sx ${pkgdir}/opt/sparkle/resources/sidecar/mihomo 51 | chmod +sx ${pkgdir}/opt/sparkle/resources/sidecar/mihomo-alpha 52 | install -Dm755 "${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}" 53 | install -Dm644 "${_pkgname}.desktop" "${pkgdir}/usr/share/applications/${_pkgname}.desktop" 54 | install -Dm644 "${pkgdir}/opt/sparkle/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png" 55 | 56 | chown -R root:root ${pkgdir} 57 | } 58 | -------------------------------------------------------------------------------- /aur/sparkle-electron-git/sparkle.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Sparkle 3 | Exec=sparkle %U 4 | Terminal=false 5 | Type=Application 6 | Icon=sparkle 7 | StartupWMClass=sparkle 8 | MimeType=x-scheme-handler/clash;x-scheme-handler/mihomo; 9 | Comment=Sparkle 10 | Categories=Utility; 11 | -------------------------------------------------------------------------------- /aur/sparkle-electron-git/sparkle.install: -------------------------------------------------------------------------------- 1 | # Colored makepkg-like functions 2 | note() { 3 | printf "${_blue}==>${_yellow} NOTE:${_bold} %s${_all_off}\n" "$1" 4 | } 5 | 6 | _all_off="$(tput sgr0)" 7 | _bold="${_all_off}$(tput bold)" 8 | _blue="${_bold}$(tput setaf 4)" 9 | _yellow="${_bold}$(tput setaf 3)" 10 | 11 | post_install() { 12 | note "Custom flags should be put directly in: ~/.config/sparkle-flags.conf" 13 | note "The launcher is called: 'sparkle'" 14 | } -------------------------------------------------------------------------------- /aur/sparkle-electron-git/sparkle.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-~/.config} 4 | 5 | # Allow users to override command-line options 6 | if [[ -f "${XDG_CONFIG_HOME}/sparkle-flags.conf" ]]; then 7 | mapfile -t MIHOMO_PARTY_USER_FLAGS <<<"$(grep -v '^#' "${XDG_CONFIG_HOME}/sparkle-flags.conf")" 8 | echo "User flags:" ${MIHOMO_PARTY_USER_FLAGS[@]} 9 | fi 10 | 11 | # Launch 12 | exec electron /opt/sparkle ${MIHOMO_PARTY_USER_FLAGS[@]} "$@" 13 | -------------------------------------------------------------------------------- /aur/sparkle-electron/PKGBUILD: -------------------------------------------------------------------------------- 1 | pkgname=sparkle-electron 2 | _pkgname=sparkle 3 | pkgver=1.6.2 4 | pkgrel=1 5 | pkgdesc="Another Mihomo GUI." 6 | arch=('x86_64' 'aarch64') 7 | url=" " 8 | license=('GPL3') 9 | conflicts=("$_pkgname" "$_pkgname-git" "$_pkgname-bin" "$_pkgname-electron-bin" "$_pkgname-electron-git") 10 | depends=('electron' 'gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs' 'libsecret') 11 | optdepends=('libappindicator-gtk3: Allow sparkle to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).') 12 | makedepends=('nodejs' 'pnpm' 'libxcrypt-compat' 'asar') 13 | install=$_pkgname.install 14 | source=( 15 | "${_pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/${pkgver}.tar.gz" 16 | "${_pkgname}.desktop" 17 | "${_pkgname}.sh" 18 | ) 19 | sha256sums=("d2fe3633951f7e164bc2df4437decd86e880a516e318363601ea552989c0c73d" 20 | "b17d85f6d862285a53a24d0f8dedd08f1f3c852ba6a901fabc487177598803cc" 21 | "ce855656fb0682d403685244c77dd2d90ec6efb207753fb7a6ddc1e9b6aa2c49" 22 | ) 23 | options=('!lto') 24 | 25 | prepare(){ 26 | cd $srcdir/${_pkgname}-${pkgver} 27 | sed -i "s/productName: Sparkle/productName: sparkle/" electron-builder.yml 28 | pnpm install 29 | } 30 | 31 | build(){ 32 | cd $srcdir/${_pkgname}-${pkgver} 33 | pnpm build:linux deb 34 | } 35 | 36 | package() { 37 | asar extract $srcdir/${_pkgname}-${pkgver}/dist/linux-unpacked/resources/app.asar ${pkgdir}/opt/sparkle 38 | cp -r $srcdir/${_pkgname}-${pkgver}/extra/sidecar ${pkgdir}/opt/sparkle/resources/ 39 | cp -r $srcdir/${_pkgname}-${pkgver}/extra/files ${pkgdir}/opt/sparkle/resources/ 40 | chmod +x ${pkgdir}/opt/sparkle/resources/files/sysproxy 41 | chmod +sx ${pkgdir}/opt/sparkle/resources/sidecar/mihomo 42 | chmod +sx ${pkgdir}/opt/sparkle/resources/sidecar/mihomo-alpha 43 | install -Dm755 "${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}" 44 | install -Dm644 "${_pkgname}.desktop" "${pkgdir}/usr/share/applications/${_pkgname}.desktop" 45 | install -Dm644 "${pkgdir}/opt/sparkle/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png" 46 | 47 | chown -R root:root ${pkgdir} 48 | } 49 | -------------------------------------------------------------------------------- /aur/sparkle-electron/sparkle.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Sparkle 3 | Exec=sparkle %U 4 | Terminal=false 5 | Type=Application 6 | Icon=sparkle 7 | StartupWMClass=sparkle 8 | MimeType=x-scheme-handler/clash;x-scheme-handler/mihomo; 9 | Comment=Sparkle 10 | Categories=Utility; 11 | -------------------------------------------------------------------------------- /aur/sparkle-electron/sparkle.install: -------------------------------------------------------------------------------- 1 | # Colored makepkg-like functions 2 | note() { 3 | printf "${_blue}==>${_yellow} NOTE:${_bold} %s${_all_off}\n" "$1" 4 | } 5 | 6 | _all_off="$(tput sgr0)" 7 | _bold="${_all_off}$(tput bold)" 8 | _blue="${_bold}$(tput setaf 4)" 9 | _yellow="${_bold}$(tput setaf 3)" 10 | 11 | post_install() { 12 | note "Custom flags should be put directly in: ~/.config/sparkle-flags.conf" 13 | note "The launcher is called: 'sparkle'" 14 | } -------------------------------------------------------------------------------- /aur/sparkle-electron/sparkle.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-~/.config} 4 | 5 | # Allow users to override command-line options 6 | if [[ -f "${XDG_CONFIG_HOME}/sparkle-flags.conf" ]]; then 7 | mapfile -t MIHOMO_PARTY_USER_FLAGS <<<"$(grep -v '^#' "${XDG_CONFIG_HOME}/sparkle-flags.conf")" 8 | echo "User flags:" ${MIHOMO_PARTY_USER_FLAGS[@]} 9 | fi 10 | 11 | # Launch 12 | exec electron /opt/sparkle ${MIHOMO_PARTY_USER_FLAGS[@]} "$@" 13 | -------------------------------------------------------------------------------- /aur/sparkle-git/PKGBUILD: -------------------------------------------------------------------------------- 1 | pkgname=sparkle-git 2 | _pkgname=${pkgname%-git} 3 | pkgver=1.6.2.r1.db8c6a0 4 | pkgrel=1 5 | pkgdesc="Another Mihomo GUI." 6 | arch=('x86_64' 'aarch64') 7 | url="https://github.com/xishang0128/sparkle" 8 | license=('GPL3') 9 | conflicts=("$_pkgname" "$_pkgname-bin" "$_pkgname-electron" "$_pkgname-electron-bin" "$_pkgname-electron-git") 10 | depends=('gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs' 'libsecret') 11 | optdepends=('libappindicator-gtk3: Allow sparkle to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).') 12 | makedepends=('nodejs' 'pnpm' 'jq' 'libxcrypt-compat') 13 | install=$_pkgname.install 14 | source=("${_pkgname}.sh" "git+$url.git") 15 | sha256sums=("03eb601fe981716e90f9170eeb36a2e7938587f05a1bdaa09adadb1229c77a0a" "SKIP") 16 | options=('!lto') 17 | 18 | pkgver() { 19 | cd $srcdir/${_pkgname} 20 | ( set -o pipefail 21 | git describe --long 2>/dev/null | sed 's/\([^-]*-g\)/r\1/;s/-/./g' | tr -d 'v' || 22 | printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" 23 | ) 24 | } 25 | 26 | prepare(){ 27 | cd $srcdir/${_pkgname} 28 | sed -i "s/productName: Sparkle/productName: sparkle/" electron-builder.yml 29 | pnpm install 30 | } 31 | 32 | build(){ 33 | cd $srcdir/${_pkgname} 34 | pnpm build:linux deb 35 | } 36 | 37 | package() { 38 | cd $srcdir/${_pkgname}/dist 39 | bsdtar -xf sparkle-linux-$(jq '.version' $srcdir/${_pkgname}/package.json | tr -d 'v"')*.deb 40 | bsdtar -xf data.tar.xz -C "${pkgdir}/" 41 | chmod +x ${pkgdir}/opt/sparkle/sparkle 42 | chmod +x ${pkgdir}/opt/sparkle/resources/files/sysproxy 43 | chmod +sx ${pkgdir}/opt/sparkle/resources/sidecar/mihomo 44 | chmod +sx ${pkgdir}/opt/sparkle/resources/sidecar/mihomo-alpha 45 | install -Dm755 "${srcdir}/../${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}" 46 | sed -i '3s!/opt/sparkle/sparkle!sparkle!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop" 47 | 48 | chown -R root:root ${pkgdir} 49 | } 50 | -------------------------------------------------------------------------------- /aur/sparkle-git/sparkle.install: -------------------------------------------------------------------------------- 1 | # Colored makepkg-like functions 2 | note() { 3 | printf "${_blue}==>${_yellow} NOTE:${_bold} %s${_all_off}\n" "$1" 4 | } 5 | 6 | _all_off="$(tput sgr0)" 7 | _bold="${_all_off}$(tput bold)" 8 | _blue="${_bold}$(tput setaf 4)" 9 | _yellow="${_bold}$(tput setaf 3)" 10 | 11 | post_install() { 12 | note "Custom flags should be put directly in: ~/.config/sparkle-flags.conf" 13 | note "The launcher is called: 'sparkle'" 14 | } -------------------------------------------------------------------------------- /aur/sparkle-git/sparkle.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-~/.config} 4 | 5 | # Allow users to override command-line options 6 | if [[ -f "${XDG_CONFIG_HOME}/sparkle-flags.conf" ]]; then 7 | mapfile -t MIHOMO_PARTY_USER_FLAGS <<<"$(grep -v '^#' "${XDG_CONFIG_HOME}/sparkle-flags.conf")" 8 | echo "User flags:" ${MIHOMO_PARTY_USER_FLAGS[@]} 9 | fi 10 | 11 | # Launch 12 | exec /opt/sparkle/sparkle ${MIHOMO_PARTY_USER_FLAGS[@]} "$@" 13 | -------------------------------------------------------------------------------- /aur/sparkle/PKGBUILD: -------------------------------------------------------------------------------- 1 | pkgname=sparkle 2 | pkgver=1.6.2 3 | pkgrel=1 4 | pkgdesc="Another Mihomo GUI." 5 | arch=('x86_64' 'aarch64') 6 | url="https://github.com/xishang0128/sparkle" 7 | license=('GPL3') 8 | conflicts=("$pkgname-git" "$pkgname-bin" "$pkgname-electron" "$pkgname-electron-bin" "$_pkgname-electron-git") 9 | depends=('gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs' 'libsecret') 10 | optdepends=('libappindicator-gtk3: Allow sparkle to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).') 11 | makedepends=('nodejs' 'pnpm' 'libxcrypt-compat') 12 | install=$pkgname.install 13 | source=( 14 | "${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/${pkgver}.tar.gz" 15 | "${pkgname}.sh" 16 | ) 17 | sha256sums=("d2fe3633951f7e164bc2df4437decd86e880a516e318363601ea552989c0c73d" 18 | "03eb601fe981716e90f9170eeb36a2e7938587f05a1bdaa09adadb1229c77a0a") 19 | options=('!lto') 20 | 21 | prepare(){ 22 | cd $srcdir/${pkgname}-${pkgver} 23 | sed -i "s/productName: Sparkle/productName: sparkle/" electron-builder.yml 24 | pnpm install 25 | } 26 | 27 | build(){ 28 | cd $srcdir/${pkgname}-${pkgver} 29 | pnpm build:linux deb 30 | } 31 | 32 | package() { 33 | cd $srcdir/${pkgname}-${pkgver}/dist 34 | bsdtar -xf sparkle-linux-${pkgver}*.deb 35 | bsdtar -xf data.tar.xz -C "${pkgdir}/" 36 | chmod +x ${pkgdir}/opt/sparkle/sparkle 37 | chmod +x ${pkgdir}/opt/sparkle/resources/files/sysproxy 38 | chmod +sx ${pkgdir}/opt/sparkle/resources/sidecar/mihomo 39 | chmod +sx ${pkgdir}/opt/sparkle/resources/sidecar/mihomo-alpha 40 | install -Dm755 "${srcdir}/../${pkgname}.sh" "${pkgdir}/usr/bin/${pkgname}" 41 | sed -i '3s!/opt/sparkle/sparkle!sparkle!' "${pkgdir}/usr/share/applications/${pkgname}.desktop" 42 | 43 | chown -R root:root ${pkgdir} 44 | } 45 | -------------------------------------------------------------------------------- /aur/sparkle/sparkle.install: -------------------------------------------------------------------------------- 1 | # Colored makepkg-like functions 2 | note() { 3 | printf "${_blue}==>${_yellow} NOTE:${_bold} %s${_all_off}\n" "$1" 4 | } 5 | 6 | _all_off="$(tput sgr0)" 7 | _bold="${_all_off}$(tput bold)" 8 | _blue="${_bold}$(tput setaf 4)" 9 | _yellow="${_bold}$(tput setaf 3)" 10 | 11 | post_install() { 12 | note "Custom flags should be put directly in: ~/.config/sparkle-flags.conf" 13 | note "The launcher is called: 'sparkle'" 14 | } -------------------------------------------------------------------------------- /aur/sparkle/sparkle.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-~/.config} 4 | 5 | # Allow users to override command-line options 6 | if [[ -f "${XDG_CONFIG_HOME}/sparkle-flags.conf" ]]; then 7 | mapfile -t MIHOMO_PARTY_USER_FLAGS <<<"$(grep -v '^#' "${XDG_CONFIG_HOME}/sparkle-flags.conf")" 8 | echo "User flags:" ${MIHOMO_PARTY_USER_FLAGS[@]} 9 | fi 10 | 11 | # Launch 12 | exec /opt/sparkle/sparkle ${MIHOMO_PARTY_USER_FLAGS[@]} "$@" 13 | -------------------------------------------------------------------------------- /build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xishang0128/sparkle/61a263d09c3915353720b1162d97e18f701b82d9/build/icon.icns -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xishang0128/sparkle/61a263d09c3915353720b1162d97e18f701b82d9/build/icon.ico -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xishang0128/sparkle/61a263d09c3915353720b1162d97e18f701b82d9/build/icon.png -------------------------------------------------------------------------------- /build/installerIcon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xishang0128/sparkle/61a263d09c3915353720b1162d97e18f701b82d9/build/installerIcon.ico -------------------------------------------------------------------------------- /build/linux/postinst: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if type update-alternatives 2>/dev/null >&1; then 4 | # Remove previous link if it doesn't use update-alternatives 5 | if [ -L '/usr/bin/sparkle' -a -e '/usr/bin/sparkle' -a "`readlink '/usr/bin/sparkle'`" != '/etc/alternatives/sparkle' ]; then 6 | rm -f '/usr/bin/sparkle' 7 | fi 8 | update-alternatives --install '/usr/bin/sparkle' 'sparkle' '/opt/sparkle/sparkle' 100 || ln -sf '/opt/sparkle/sparkle' '/usr/bin/sparkle' 9 | else 10 | ln -sf '/opt/sparkle/sparkle' '/usr/bin/sparkle' 11 | fi 12 | 13 | sed -i 's/Name=sparkle/Name=Sparkle/' '/usr/share/applications/sparkle.desktop' 14 | 15 | chmod 4755 '/opt/sparkle/chrome-sandbox' || true 16 | chmod +x /opt/sparkle/resources/files/sysproxy 17 | chmod +sx /opt/sparkle/resources/sidecar/mihomo 18 | chmod +sx /opt/sparkle/resources/sidecar/mihomo-alpha 19 | 20 | if hash update-mime-database 2>/dev/null; then 21 | update-mime-database /usr/share/mime || true 22 | fi 23 | 24 | if hash update-desktop-database 2>/dev/null; then 25 | update-desktop-database /usr/share/applications || true 26 | fi 27 | -------------------------------------------------------------------------------- /build/pkg-scripts/postinstall: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | chown root:admin $2/Sparkle.app/Contents/Resources/sidecar/mihomo 3 | chown root:admin $2/Sparkle.app/Contents/Resources/sidecar/mihomo-alpha 4 | chmod +s $2/Sparkle.app/Contents/Resources/sidecar/mihomo 5 | chmod +s $2/Sparkle.app/Contents/Resources/sidecar/mihomo-alpha 6 | 7 | mkdir -p /Library/PrivilegedHelperTools 8 | cp $2/Sparkle.app/Contents/Resources/files/sysproxy /Library/PrivilegedHelperTools/sparkle.helper 9 | chown root:wheel /Library/PrivilegedHelperTools/sparkle.helper 10 | chmod 544 /Library/PrivilegedHelperTools/sparkle.helper 11 | cat << EOF > /Library/LaunchDaemons/sparkle.helper.plist 12 | 13 | 14 | 15 | 16 | Label 17 | sparkle.helper 18 | MachServices 19 | 20 | sparkle.helper 21 | 22 | 23 | KeepAlive 24 | 25 | Program 26 | /Library/PrivilegedHelperTools/sparkle.helper 27 | ProgramArguments 28 | 29 | /Library/PrivilegedHelperTools/sparkle.helper 30 | server 31 | 32 | StandardErrorPath 33 | /tmp/sparkle.helper.err 34 | StandardOutPath 35 | /tmp/sparkle.helper.log 36 | 37 | 38 | EOF 39 | chown root:wheel /Library/LaunchDaemons/sparkle.helper.plist 40 | chmod 644 /Library/LaunchDaemons/sparkle.helper.plist 41 | launchctl unload /Library/LaunchDaemons/sparkle.helper.plist 42 | launchctl load /Library/LaunchDaemons/sparkle.helper.plist 43 | launchctl start sparkle.helper 44 | exit 0 -------------------------------------------------------------------------------- /build/pkg-scripts/preinstall: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -e "/Library/LaunchDaemons/sparkle.helper.plist" ]; then 4 | launchctl unload /Library/LaunchDaemons/sparkle.helper.plist 5 | rm /Library/LaunchDaemons/sparkle.helper.plist 6 | fi 7 | 8 | if [ -e "/Library/PrivilegedHelperTools/sparkle.helper" ]; then 9 | rm /Library/PrivilegedHelperTools/sparkle.helper 10 | fi -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | ### Breaking Changes 2 | 3 | - 1.5.0 之后 macOS 改用 pkg 安装方式,不再支持 dmg 安装方式,因此本次更新需要手动下载安装包进行安装 4 | - electron33 已不再支持 macOS 10.15,故为 10.15 提供单独的安装包,需要的用户请自行下载安装,应用内更新时会自动检测系统版本,安装后后续可正常在应用内直接更新 5 | - 1.5.1 之后 Windows 下 `productName` 改为 `Mihomo Party`, 更新后若出现找不到文件报错,手动以管理员权限运行 `Mihomo Party.exe` 即可 6 | - 由于更改了应用名称,开机启动失效是正常现象,在设置中重新开关一下即可 7 | 8 | ### Features 9 | 10 | - 添加出站接口查看 11 | - 添加更多嗅探配置 12 | 13 | ### Bug Fixes 14 | 15 | - null 16 | -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | appId: sparkle.app 2 | productName: Sparkle 3 | directories: 4 | buildResources: build 5 | files: 6 | - '!**/.vscode/*' 7 | - '!src/*' 8 | - '!aur/*' 9 | - '!images/*' 10 | - '!scripts/*' 11 | - '!extra/*' 12 | - '!tailwind.config.js' 13 | - '!postcss.config.js' 14 | - '!electron.vite.config.{js,ts,mjs,cjs}' 15 | - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' 16 | - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' 17 | - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' 18 | extraResources: 19 | - from: './extra/' 20 | to: '' 21 | protocols: 22 | name: 'Sparkle URI Scheme' 23 | schemes: 24 | - 'clash' 25 | - 'mihomo' 26 | win: 27 | target: 28 | - nsis 29 | - 7z 30 | artifactName: ${name}-windows-${version}-${arch}-portable.${ext} 31 | nsis: 32 | artifactName: ${name}-windows-${version}-${arch}-setup.${ext} 33 | uninstallDisplayName: ${productName} 34 | allowToChangeInstallationDirectory: true 35 | oneClick: false 36 | perMachine: true 37 | createDesktopShortcut: true 38 | mac: 39 | target: 40 | - pkg 41 | entitlementsInherit: build/entitlements.mac.plist 42 | extendInfo: 43 | - NSCameraUsageDescription: Application requests access to the device's camera. 44 | - NSMicrophoneUsageDescription: Application requests access to the device's microphone. 45 | - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. 46 | - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. 47 | notarize: false 48 | artifactName: ${name}-macos-${version}-${arch}.${ext} 49 | pkg: 50 | allowAnywhere: false 51 | allowCurrentUserHome: false 52 | # background: 53 | # alignment: bottomleft 54 | # file: build/background.png 55 | linux: 56 | desktop: 57 | entry: 58 | Name: Sparkle 59 | MimeType: 'x-scheme-handler/clash;x-scheme-handler/mihomo' 60 | target: 61 | - deb 62 | - rpm 63 | - pacman 64 | maintainer: xishang0128 65 | category: Utility 66 | artifactName: ${name}-linux-${version}-${arch}.${ext} 67 | deb: 68 | afterInstall: 'build/linux/postinst' 69 | rpm: 70 | afterInstall: 'build/linux/postinst' 71 | pacman: 72 | afterInstall: 'build/linux/postinst' 73 | artifactName: ${name}-linux-${version}-${arch}.pkg.tar.xz 74 | npmRebuild: true 75 | publish: [] 76 | -------------------------------------------------------------------------------- /electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig, externalizeDepsPlugin } from 'electron-vite' 3 | import react from '@vitejs/plugin-react' 4 | // https://github.com/vdesjs/vite-plugin-monaco-editor/issues/21#issuecomment-1827562674 5 | import monacoEditorPluginModule from 'vite-plugin-monaco-editor' 6 | const isObjectWithDefaultFunction = ( 7 | module: unknown 8 | ): module is { default: typeof monacoEditorPluginModule } => 9 | module != null && 10 | typeof module === 'object' && 11 | 'default' in module && 12 | typeof module.default === 'function' 13 | const monacoEditorPlugin = isObjectWithDefaultFunction(monacoEditorPluginModule) 14 | ? monacoEditorPluginModule.default 15 | : monacoEditorPluginModule 16 | 17 | export default defineConfig({ 18 | main: { 19 | plugins: [externalizeDepsPlugin()] 20 | }, 21 | preload: { 22 | plugins: [externalizeDepsPlugin()] 23 | }, 24 | renderer: { 25 | build: { 26 | rollupOptions: { 27 | input: { 28 | index: resolve('src/renderer/index.html'), 29 | floating: resolve('src/renderer/floating.html') 30 | } 31 | } 32 | }, 33 | resolve: { 34 | alias: { 35 | '@renderer': resolve('src/renderer/src') 36 | } 37 | }, 38 | plugins: [ 39 | react(), 40 | monacoEditorPlugin({ 41 | languageWorkers: ['editorWorkerService', 'typescript', 'css'], 42 | customDistPath: (_, out) => `${out}/monacoeditorwork`, 43 | customWorkers: [ 44 | { 45 | label: 'yaml', 46 | entry: 'monaco-yaml/yaml.worker' 47 | } 48 | ] 49 | }) 50 | ] 51 | } 52 | }) 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sparkle", 3 | "version": "1.6.6", 4 | "description": "Sparkle", 5 | "main": "./out/main/index.js", 6 | "author": "xishang0128", 7 | "homepage": "https://github.com/xishang0128/sparkle", 8 | "scripts": { 9 | "format": "prettier --write .", 10 | "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", 11 | "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", 12 | "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", 13 | "typecheck": "npm run typecheck:node && npm run typecheck:web", 14 | "prepare": "node scripts/prepare.mjs", 15 | "updater": "node scripts/updater.mjs", 16 | "checksum": "node scripts/checksum.mjs", 17 | "telegram": "node scripts/telegram.mjs", 18 | "artifact": "node scripts/artifact.mjs", 19 | "dev": "electron-vite dev", 20 | "postinstall": "electron-builder install-app-deps", 21 | "build:win": "electron-vite build && electron-builder --publish never --win", 22 | "build:mac": "electron-vite build && electron-builder --publish never --mac", 23 | "build:linux": "electron-vite build && electron-builder --publish never --linux" 24 | }, 25 | "dependencies": { 26 | "@electron-toolkit/preload": "^3.0.2", 27 | "@electron-toolkit/utils": "^4.0.0", 28 | "@heroui/react": "^2.6.13", 29 | "@types/crypto-js": "^4.2.2", 30 | "adm-zip": "^0.5.16", 31 | "axios": "^1.9.0", 32 | "chokidar": "^4.0.1", 33 | "crypto-js": "^4.2.0", 34 | "dayjs": "^1.11.13", 35 | "express": "^5.1.0", 36 | "iconv-lite": "^0.6.3", 37 | "webdav": "^5.8.0", 38 | "ws": "^8.18.2", 39 | "yaml": "^2.8.0" 40 | }, 41 | "devDependencies": { 42 | "@dnd-kit/core": "^6.1.0", 43 | "@dnd-kit/sortable": "^10.0.0", 44 | "@dnd-kit/utilities": "^3.2.2", 45 | "@electron-toolkit/eslint-config-prettier": "^3.0.0", 46 | "@electron-toolkit/eslint-config-ts": "^3.1.0", 47 | "@electron-toolkit/tsconfig": "^1.0.1", 48 | "@types/adm-zip": "^0.5.6", 49 | "@types/express": "^5.0.2", 50 | "@types/node": "^22.15.24", 51 | "@types/pubsub-js": "^1.8.6", 52 | "@types/react": "^19.1.6", 53 | "@types/react-dom": "^19.1.5", 54 | "@types/ws": "^8.18.1", 55 | "@vitejs/plugin-react": "^4.5.0", 56 | "autoprefixer": "^10.4.21", 57 | "cron-validator": "^1.3.1", 58 | "driver.js": "^1.3.6", 59 | "electron": "^36.3.2", 60 | "electron-builder": "26.0.12", 61 | "electron-vite": "^3.1.0", 62 | "electron-window-state": "^5.0.3", 63 | "eslint": "9.26.0", 64 | "eslint-plugin-react": "^7.37.5", 65 | "form-data": "^4.0.2", 66 | "framer-motion": "12.11.0", 67 | "lodash": "^4.17.21", 68 | "meta-json-schema": "^1.19.9", 69 | "monaco-yaml": "^5.4.0", 70 | "nanoid": "^5.1.5", 71 | "next-themes": "^0.4.6", 72 | "postcss": "^8.5.4", 73 | "prettier": "^3.5.3", 74 | "pubsub-js": "^1.9.5", 75 | "react": "^19.1.0", 76 | "react-dom": "^19.1.0", 77 | "react-error-boundary": "^6.0.0", 78 | "react-icons": "^5.5.0", 79 | "react-markdown": "^10.1.0", 80 | "react-monaco-editor": "^0.58.0", 81 | "react-router-dom": "^7.6.1", 82 | "react-virtuoso": "^4.12.7", 83 | "recharts": "^2.15.3", 84 | "swr": "^2.3.3", 85 | "tailwindcss": "^3.4.14", 86 | "tar": "^7.4.3", 87 | "tsx": "^4.19.4", 88 | "types-pac": "^1.0.3", 89 | "typescript": "^5.8.3", 90 | "vite": "^6.3.5", 91 | "vite-plugin-monaco-editor": "^1.1.0" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /resources/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xishang0128/sparkle/61a263d09c3915353720b1162d97e18f701b82d9/resources/icon.ico -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xishang0128/sparkle/61a263d09c3915353720b1162d97e18f701b82d9/resources/icon.png -------------------------------------------------------------------------------- /resources/iconTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xishang0128/sparkle/61a263d09c3915353720b1162d97e18f701b82d9/resources/iconTemplate.png -------------------------------------------------------------------------------- /resources/subStoreIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xishang0128/sparkle/61a263d09c3915353720b1162d97e18f701b82d9/resources/subStoreIcon.png -------------------------------------------------------------------------------- /scripts/checksum.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, readdirSync, writeFileSync } from 'fs' 2 | import { createHash } from 'crypto' 3 | const files = readdirSync('dist') 4 | 5 | for (const file of files) { 6 | for (const ext of process.argv.slice(2)) { 7 | if (file.endsWith(ext)) { 8 | const content = readFileSync(`dist/${file}`) 9 | const checksum = createHash('sha256').update(content, 'utf8').digest('hex') 10 | writeFileSync(`dist/${file}.sha256`, checksum) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /scripts/telegram.mjs: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { readFileSync } from 'fs' 3 | 4 | const chat_id = '@MihomoPartyChannel' 5 | const pkg = readFileSync('package.json', 'utf-8') 6 | const changelog = readFileSync('changelog.md', 'utf-8') 7 | const { version } = JSON.parse(pkg) 8 | const downloadUrl = `https://github.com/mihomo-party-org/mihomo-party/releases/download/v${version}` 9 | let content = `🌟 Mihomo Party v${version} 正式发布\n\n` 10 | for (const line of changelog.split('\n')) { 11 | if (line.length === 0) { 12 | content += '\n' 13 | } else if (line.startsWith('### ')) { 14 | content += `${line.replace('### ', '')}\n` 15 | } else { 16 | content += `${line}\n` 17 | } 18 | } 19 | 20 | content += '\n下载地址:\nWindows10/11:\n' 21 | content += `安装版:64位 | 32位 | ARM64\n` 22 | content += `便携版:64位 | 32位 | ARM64\n` 23 | content += '\nWindows7/8:\n' 24 | content += `安装版:64位 | 32位\n` 25 | content += `便携版:64位 | 32位\n` 26 | content += '\nmacOS 11+:\n' 27 | content += `PKG:Intel | Apple Silicon\n` 29 | content += '\nmacOS 10.15+:\n' 30 | content += `PKG:Intel | Apple Silicon\n` 32 | content += '\nLinux:\n' 33 | content += `DEB:64位 | ARM64\n` 35 | content += `RPM:64位 | ARM64` 36 | 37 | await axios.post(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, { 38 | chat_id, 39 | text: content, 40 | link_preview_options: { 41 | is_disabled: false, 42 | url: 'https://github.com/mihomo-party-org/mihomo-party', 43 | prefer_large_media: true 44 | }, 45 | parse_mode: 'HTML' 46 | }) 47 | -------------------------------------------------------------------------------- /scripts/updater.mjs: -------------------------------------------------------------------------------- 1 | import yaml from 'yaml' 2 | import { readFileSync, writeFileSync } from 'fs' 3 | 4 | const pkg = readFileSync('package.json', 'utf-8') 5 | let changelog = readFileSync('changelog.md', 'utf-8') 6 | const { version } = JSON.parse(pkg) 7 | const downloadUrl = `https://github.com/xishang0128/sparkle/releases/download/${version}` 8 | const latest = { 9 | version, 10 | changelog 11 | } 12 | 13 | if (!version.includes('beta')) { 14 | changelog += '\n### 下载地址:\n\n#### Windows10/11:\n\n' 15 | changelog += `- 安装版:[64位](${downloadUrl}/sparkle-windows-${version}-x64-setup.exe) | [ARM64](${downloadUrl}/sparkle-windows-${version}-arm64-setup.exe)\n\n` 16 | changelog += '\n#### macOS 11+:\n\n' 17 | changelog += `- PKG:[Intel](${downloadUrl}/sparkle-macos-${version}-x64.pkg) | [Apple Silicon](${downloadUrl}/sparkle-macos-${version}-arm64.pkg)\n\n` 18 | changelog += '\n#### Linux:\n\n' 19 | changelog += `- DEB:[64位](${downloadUrl}/sparkle-linux-${version}-amd64.deb) | [ARM64](${downloadUrl}/sparkle-linux-${version}-arm64.deb)\n\n` 20 | changelog += `- RPM:[64位](${downloadUrl}/sparkle-linux-${version}-x86_64.rpm) | [ARM64](${downloadUrl}/sparkle-linux-${version}-aarch64.rpm)` 21 | } 22 | writeFileSync('latest.yml', yaml.stringify(latest)) 23 | writeFileSync('changelog.md', changelog) 24 | -------------------------------------------------------------------------------- /src/main/config/app.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from 'fs/promises' 2 | import { appConfigPath } from '../utils/dirs' 3 | import yaml from 'yaml' 4 | import { deepMerge } from '../utils/merge' 5 | import { defaultConfig } from '../utils/template' 6 | 7 | let appConfig: IAppConfig // config.yaml 8 | 9 | export async function getAppConfig(force = false): Promise { 10 | if (force || !appConfig) { 11 | const data = await readFile(appConfigPath(), 'utf-8') 12 | appConfig = yaml.parse(data, { merge: true }) || defaultConfig 13 | } 14 | if (typeof appConfig !== 'object') appConfig = defaultConfig 15 | return appConfig 16 | } 17 | 18 | export async function patchAppConfig(patch: Partial): Promise { 19 | appConfig = deepMerge(appConfig, patch) 20 | await writeFile(appConfigPath(), yaml.stringify(appConfig)) 21 | } 22 | -------------------------------------------------------------------------------- /src/main/config/controledMihomo.ts: -------------------------------------------------------------------------------- 1 | import { controledMihomoConfigPath } from '../utils/dirs' 2 | import { readFile, writeFile } from 'fs/promises' 3 | import yaml from 'yaml' 4 | import { generateProfile } from '../core/factory' 5 | import { getAppConfig } from './app' 6 | import { defaultControledMihomoConfig } from '../utils/template' 7 | import { deepMerge } from '../utils/merge' 8 | 9 | let controledMihomoConfig: Partial // mihomo.yaml 10 | 11 | export async function getControledMihomoConfig(force = false): Promise> { 12 | if (force || !controledMihomoConfig) { 13 | const data = await readFile(controledMihomoConfigPath(), 'utf-8') 14 | controledMihomoConfig = yaml.parse(data, { merge: true }) || defaultControledMihomoConfig 15 | } 16 | if (typeof controledMihomoConfig !== 'object') 17 | controledMihomoConfig = defaultControledMihomoConfig 18 | return controledMihomoConfig 19 | } 20 | 21 | export async function patchControledMihomoConfig(patch: Partial): Promise { 22 | const { controlDns = true, controlSniff = true } = await getAppConfig() 23 | if (!controlDns) { 24 | delete controledMihomoConfig.dns 25 | delete controledMihomoConfig.hosts 26 | } else { 27 | // 从不接管状态恢复 28 | if (controledMihomoConfig.dns?.ipv6 === undefined) { 29 | controledMihomoConfig.dns = defaultControledMihomoConfig.dns 30 | } 31 | } 32 | if (!controlSniff) { 33 | delete controledMihomoConfig.sniffer 34 | } else { 35 | // 从不接管状态恢复 36 | if (!controledMihomoConfig.sniffer) { 37 | controledMihomoConfig.sniffer = defaultControledMihomoConfig.sniffer 38 | } 39 | } 40 | if (patch.dns?.['nameserver-policy']) { 41 | controledMihomoConfig.dns = controledMihomoConfig.dns || {} 42 | controledMihomoConfig.dns['nameserver-policy'] = patch.dns['nameserver-policy'] 43 | } 44 | if (patch.dns?.['use-hosts']) { 45 | controledMihomoConfig.hosts = patch.hosts 46 | } 47 | controledMihomoConfig = deepMerge(controledMihomoConfig, patch) 48 | if (process.platform === 'darwin' && controledMihomoConfig.tun) { 49 | controledMihomoConfig.tun.device = undefined 50 | } 51 | await generateProfile() 52 | await writeFile(controledMihomoConfigPath(), yaml.stringify(controledMihomoConfig), 'utf-8') 53 | } 54 | -------------------------------------------------------------------------------- /src/main/config/index.ts: -------------------------------------------------------------------------------- 1 | export { getAppConfig, patchAppConfig } from './app' 2 | export { getControledMihomoConfig, patchControledMihomoConfig } from './controledMihomo' 3 | export { 4 | getProfile, 5 | getCurrentProfileItem, 6 | getProfileItem, 7 | getProfileConfig, 8 | getFileStr, 9 | setFileStr, 10 | setProfileConfig, 11 | addProfileItem, 12 | removeProfileItem, 13 | createProfile, 14 | getProfileStr, 15 | getProfileParseStr, 16 | setProfileStr, 17 | changeCurrentProfile, 18 | updateProfileItem 19 | } from './profile' 20 | export { 21 | getOverrideConfig, 22 | setOverrideConfig, 23 | getOverrideItem, 24 | addOverrideItem, 25 | removeOverrideItem, 26 | createOverride, 27 | getOverride, 28 | setOverride, 29 | updateOverrideItem 30 | } from './override' 31 | -------------------------------------------------------------------------------- /src/main/config/override.ts: -------------------------------------------------------------------------------- 1 | import { overrideConfigPath, overridePath } from '../utils/dirs' 2 | import { getControledMihomoConfig } from './controledMihomo' 3 | import { readFile, writeFile, rm } from 'fs/promises' 4 | import { existsSync } from 'fs' 5 | import axios from 'axios' 6 | import yaml from 'yaml' 7 | 8 | let overrideConfig: IOverrideConfig // override.yaml 9 | 10 | export async function getOverrideConfig(force = false): Promise { 11 | if (force || !overrideConfig) { 12 | const data = await readFile(overrideConfigPath(), 'utf-8') 13 | overrideConfig = yaml.parse(data, { merge: true }) || { items: [] } 14 | } 15 | if (typeof overrideConfig !== 'object') overrideConfig = { items: [] } 16 | return overrideConfig 17 | } 18 | 19 | export async function setOverrideConfig(config: IOverrideConfig): Promise { 20 | overrideConfig = config 21 | await writeFile(overrideConfigPath(), yaml.stringify(overrideConfig), 'utf-8') 22 | } 23 | 24 | export async function getOverrideItem(id: string | undefined): Promise { 25 | const { items } = await getOverrideConfig() 26 | return items.find((item) => item.id === id) 27 | } 28 | 29 | export async function updateOverrideItem(item: IOverrideItem): Promise { 30 | const config = await getOverrideConfig() 31 | const index = config.items.findIndex((i) => i.id === item.id) 32 | if (index === -1) { 33 | throw new Error('Override not found') 34 | } 35 | config.items[index] = item 36 | await setOverrideConfig(config) 37 | } 38 | 39 | export async function addOverrideItem(item: Partial): Promise { 40 | const config = await getOverrideConfig() 41 | const newItem = await createOverride(item) 42 | if (await getOverrideItem(item.id)) { 43 | updateOverrideItem(newItem) 44 | } else { 45 | config.items.push(newItem) 46 | } 47 | await setOverrideConfig(config) 48 | } 49 | 50 | export async function removeOverrideItem(id: string): Promise { 51 | const config = await getOverrideConfig() 52 | const item = await getOverrideItem(id) 53 | config.items = config.items?.filter((item) => item.id !== id) 54 | await setOverrideConfig(config) 55 | await rm(overridePath(id, item?.ext || 'js')) 56 | } 57 | 58 | export async function createOverride(item: Partial): Promise { 59 | const id = item.id || new Date().getTime().toString(16) 60 | const newItem = { 61 | id, 62 | name: item.name || (item.type === 'remote' ? 'Remote File' : 'Local File'), 63 | type: item.type, 64 | ext: item.ext || 'js', 65 | url: item.url, 66 | global: item.global || false, 67 | updated: new Date().getTime() 68 | } as IOverrideItem 69 | switch (newItem.type) { 70 | case 'remote': { 71 | const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig() 72 | if (!item.url) throw new Error('Empty URL') 73 | const res = await axios.get(item.url, { 74 | ...(mixedPort != 0 && { 75 | proxy: { 76 | protocol: 'http', 77 | host: '127.0.0.1', 78 | port: mixedPort 79 | } 80 | }), 81 | responseType: 'text' 82 | }) 83 | const data = res.data 84 | await setOverride(id, newItem.ext, data) 85 | break 86 | } 87 | case 'local': { 88 | const data = item.file || '' 89 | setOverride(id, newItem.ext, data) 90 | break 91 | } 92 | } 93 | 94 | return newItem 95 | } 96 | 97 | export async function getOverride(id: string, ext: 'js' | 'yaml' | 'log'): Promise { 98 | if (!existsSync(overridePath(id, ext))) { 99 | return '' 100 | } 101 | return await readFile(overridePath(id, ext), 'utf-8') 102 | } 103 | 104 | export async function setOverride(id: string, ext: 'js' | 'yaml', content: string): Promise { 105 | await writeFile(overridePath(id, ext), content, 'utf-8') 106 | } 107 | -------------------------------------------------------------------------------- /src/main/core/profileUpdater.ts: -------------------------------------------------------------------------------- 1 | import { addProfileItem, getCurrentProfileItem, getProfileConfig } from '../config' 2 | 3 | const intervalPool: Record = {} 4 | 5 | export async function initProfileUpdater(): Promise { 6 | const { items, current } = await getProfileConfig() 7 | const currentItem = await getCurrentProfileItem() 8 | for (const item of items.filter((i) => i.id !== current)) { 9 | if (item.type === 'remote' && item.interval) { 10 | intervalPool[item.id] = setTimeout( 11 | async () => { 12 | try { 13 | await addProfileItem(item) 14 | } catch (e) { 15 | /* ignore */ 16 | } 17 | }, 18 | item.interval * 60 * 1000 19 | ) 20 | try { 21 | await addProfileItem(item) 22 | } catch (e) { 23 | /* ignore */ 24 | } 25 | } 26 | } 27 | if (currentItem?.type === 'remote' && currentItem.interval) { 28 | intervalPool[currentItem.id] = setTimeout( 29 | async () => { 30 | try { 31 | await addProfileItem(currentItem) 32 | } catch (e) { 33 | /* ignore */ 34 | } 35 | }, 36 | currentItem.interval * 60 * 1000 + 10000 // +10s 37 | ) 38 | try { 39 | await addProfileItem(currentItem) 40 | } catch (e) { 41 | /* ignore */ 42 | } 43 | } 44 | } 45 | 46 | export async function addProfileUpdater(item: IProfileItem): Promise { 47 | if (item.type === 'remote' && item.interval) { 48 | if (intervalPool[item.id]) { 49 | clearTimeout(intervalPool[item.id]) 50 | } 51 | intervalPool[item.id] = setTimeout( 52 | async () => { 53 | try { 54 | await addProfileItem(item) 55 | } catch (e) { 56 | /* ignore */ 57 | } 58 | }, 59 | item.interval * 60 * 1000 60 | ) 61 | } 62 | } 63 | 64 | export async function delProfileUpdater(id: string): Promise { 65 | if (intervalPool[id]) { 66 | clearTimeout(intervalPool[id]) 67 | delete intervalPool[id] 68 | } 69 | } -------------------------------------------------------------------------------- /src/main/core/subStoreApi.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { subStorePort } from '../resolve/server' 3 | import { getAppConfig } from '../config' 4 | 5 | export async function subStoreSubs(): Promise { 6 | const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig() 7 | const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}` 8 | const res = await axios.get(`${baseUrl}/api/subs`, { responseType: 'json' }) 9 | return res.data.data as ISubStoreSub[] 10 | } 11 | 12 | export async function subStoreCollections(): Promise { 13 | const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig() 14 | const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}` 15 | const res = await axios.get(`${baseUrl}/api/collections`, { responseType: 'json' }) 16 | return res.data.data as ISubStoreSub[] 17 | } 18 | -------------------------------------------------------------------------------- /src/main/resolve/backup.ts: -------------------------------------------------------------------------------- 1 | import { getAppConfig } from '../config' 2 | import dayjs from 'dayjs' 3 | import AdmZip from 'adm-zip' 4 | import { 5 | appConfigPath, 6 | controledMihomoConfigPath, 7 | dataDir, 8 | overrideConfigPath, 9 | overrideDir, 10 | profileConfigPath, 11 | profilesDir, 12 | subStoreDir, 13 | themesDir 14 | } from '../utils/dirs' 15 | 16 | export async function webdavBackup(): Promise { 17 | const { createClient } = await import('webdav/dist/node/index.js') 18 | const { 19 | webdavUrl = '', 20 | webdavUsername = '', 21 | webdavPassword = '', 22 | webdavDir = 'sparkle' 23 | } = await getAppConfig() 24 | const zip = new AdmZip() 25 | 26 | zip.addLocalFile(appConfigPath()) 27 | zip.addLocalFile(controledMihomoConfigPath()) 28 | zip.addLocalFile(profileConfigPath()) 29 | zip.addLocalFile(overrideConfigPath()) 30 | zip.addLocalFolder(themesDir(), 'themes') 31 | zip.addLocalFolder(profilesDir(), 'profiles') 32 | zip.addLocalFolder(overrideDir(), 'override') 33 | zip.addLocalFolder(subStoreDir(), 'substore') 34 | const date = new Date() 35 | const zipFileName = `${process.platform}_${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip` 36 | 37 | const client = createClient(webdavUrl, { 38 | username: webdavUsername, 39 | password: webdavPassword 40 | }) 41 | try { 42 | await client.createDirectory(webdavDir) 43 | } catch { 44 | // ignore 45 | } 46 | 47 | return await client.putFileContents(`${webdavDir}/${zipFileName}`, zip.toBuffer()) 48 | } 49 | 50 | export async function webdavRestore(filename: string): Promise { 51 | const { createClient } = await import('webdav/dist/node/index.js') 52 | const { 53 | webdavUrl = '', 54 | webdavUsername = '', 55 | webdavPassword = '', 56 | webdavDir = 'sparkle' 57 | } = await getAppConfig() 58 | 59 | const client = createClient(webdavUrl, { 60 | username: webdavUsername, 61 | password: webdavPassword 62 | }) 63 | const zipData = await client.getFileContents(`${webdavDir}/${filename}`) 64 | const zip = new AdmZip(zipData as Buffer) 65 | zip.extractAllTo(dataDir(), true) 66 | } 67 | 68 | export async function listWebdavBackups(): Promise { 69 | const { createClient } = await import('webdav/dist/node/index.js') 70 | const { 71 | webdavUrl = '', 72 | webdavUsername = '', 73 | webdavPassword = '', 74 | webdavDir = 'sparkle' 75 | } = await getAppConfig() 76 | 77 | const client = createClient(webdavUrl, { 78 | username: webdavUsername, 79 | password: webdavPassword 80 | }) 81 | const files = await client.getDirectoryContents(webdavDir, { glob: '*.zip' }) 82 | if (Array.isArray(files)) { 83 | return files.map((file) => file.basename) 84 | } else { 85 | return files.data.map((file) => file.basename) 86 | } 87 | } 88 | 89 | export async function webdavDelete(filename: string): Promise { 90 | const { createClient } = await import('webdav/dist/node/index.js') 91 | const { 92 | webdavUrl = '', 93 | webdavUsername = '', 94 | webdavPassword = '', 95 | webdavDir = 'sparkle' 96 | } = await getAppConfig() 97 | 98 | const client = createClient(webdavUrl, { 99 | username: webdavUsername, 100 | password: webdavPassword 101 | }) 102 | await client.deleteFile(`${webdavDir}/${filename}`) 103 | } 104 | -------------------------------------------------------------------------------- /src/main/resolve/floatingWindow.ts: -------------------------------------------------------------------------------- 1 | import { is } from '@electron-toolkit/utils' 2 | import { BrowserWindow, ipcMain } from 'electron' 3 | import windowStateKeeper from 'electron-window-state' 4 | import { join } from 'path' 5 | import { getAppConfig, patchAppConfig } from '../config' 6 | import { applyTheme } from './theme' 7 | import { buildContextMenu, showTrayIcon } from './tray' 8 | 9 | export let floatingWindow: BrowserWindow | null = null 10 | 11 | async function preallocateGpuResources(): Promise { 12 | const preallocWin = new BrowserWindow({ 13 | width: 1, 14 | height: 1, 15 | show: false, 16 | frame: false, 17 | webPreferences: { 18 | offscreen: true, 19 | sandbox: true 20 | } 21 | }) 22 | await preallocWin.loadURL('about:blank') 23 | return new Promise((resolve) => { 24 | setTimeout(() => { 25 | if (!preallocWin.isDestroyed()) preallocWin.destroy() 26 | resolve() 27 | }, 300) 28 | }) 29 | } 30 | 31 | async function createFloatingWindow(): Promise { 32 | // 预分配 GPU 资源,防止在创建悬浮窗时卡死 33 | await preallocateGpuResources() 34 | 35 | const floatingWindowState = windowStateKeeper({ 36 | file: 'floating-window-state.json' 37 | }) 38 | const { customTheme = 'default.css' } = await getAppConfig() 39 | floatingWindow = new BrowserWindow({ 40 | width: 120, 41 | height: 42, 42 | x: floatingWindowState.x, 43 | y: floatingWindowState.y, 44 | show: false, 45 | frame: false, 46 | alwaysOnTop: true, 47 | resizable: false, 48 | transparent: true, 49 | skipTaskbar: true, 50 | minimizable: false, 51 | maximizable: false, 52 | fullscreenable: false, 53 | closable: false, 54 | webPreferences: { 55 | preload: join(__dirname, '../preload/index.js'), 56 | spellcheck: false, 57 | sandbox: false 58 | } 59 | }) 60 | floatingWindowState.manage(floatingWindow) 61 | floatingWindow.on('ready-to-show', () => { 62 | applyTheme(customTheme) 63 | floatingWindow?.show() 64 | floatingWindow?.setAlwaysOnTop(true, 'screen-saver') 65 | }) 66 | floatingWindow.on('moved', () => { 67 | if (floatingWindow) floatingWindowState.saveState(floatingWindow) 68 | }) 69 | ipcMain.on('updateFloatingWindow', () => { 70 | if (floatingWindow) { 71 | floatingWindow?.webContents.send('controledMihomoConfigUpdated') 72 | floatingWindow?.webContents.send('appConfigUpdated') 73 | } 74 | }) 75 | if (is.dev && process.env['ELECTRON_RENDERER_URL']) { 76 | floatingWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/floating.html`) 77 | } else { 78 | floatingWindow.loadFile(join(__dirname, '../renderer/floating.html')) 79 | } 80 | } 81 | 82 | export async function showFloatingWindow(): Promise { 83 | if (floatingWindow) { 84 | floatingWindow.show() 85 | } else { 86 | await createFloatingWindow() 87 | } 88 | } 89 | 90 | export async function triggerFloatingWindow(): Promise { 91 | if (floatingWindow?.isVisible()) { 92 | await patchAppConfig({ showFloatingWindow: false }) 93 | await closeFloatingWindow() 94 | } else { 95 | await patchAppConfig({ showFloatingWindow: true }) 96 | await showFloatingWindow() 97 | } 98 | } 99 | 100 | export async function closeFloatingWindow(): Promise { 101 | if (floatingWindow) { 102 | floatingWindow.close() 103 | floatingWindow.destroy() 104 | floatingWindow = null 105 | } 106 | await showTrayIcon() 107 | await patchAppConfig({ disableTray: false }) 108 | } 109 | 110 | export async function showContextMenu(): Promise { 111 | const menu = await buildContextMenu() 112 | menu.popup() 113 | } 114 | -------------------------------------------------------------------------------- /src/main/resolve/gistApi.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { getAppConfig, getControledMihomoConfig } from '../config' 3 | import { getRuntimeConfigStr } from '../core/factory' 4 | 5 | interface GistInfo { 6 | id: string 7 | description: string 8 | html_url: string 9 | } 10 | 11 | async function listGists(token: string): Promise { 12 | const { 'mixed-port': port = 7890 } = await getControledMihomoConfig() 13 | const res = await axios.get('https://api.github.com/gists', { 14 | headers: { 15 | Accept: 'application/vnd.github+json', 16 | Authorization: `Bearer ${token}`, 17 | 'X-GitHub-Api-Version': '2022-11-28' 18 | }, 19 | ...(port != 0 && { 20 | proxy: { 21 | protocol: 'http', 22 | host: '127.0.0.1', 23 | port 24 | } 25 | }), 26 | responseType: 'json' 27 | }) 28 | return res.data as GistInfo[] 29 | } 30 | 31 | async function createGist(token: string, content: string): Promise { 32 | const { 'mixed-port': port = 7890 } = await getControledMihomoConfig() 33 | return await axios.post( 34 | 'https://api.github.com/gists', 35 | { 36 | description: 'Auto Synced Sparkle Runtime Config', 37 | public: false, 38 | files: { 'sparkle.yaml': { content } } 39 | }, 40 | { 41 | headers: { 42 | Accept: 'application/vnd.github+json', 43 | Authorization: `Bearer ${token}`, 44 | 'X-GitHub-Api-Version': '2022-11-28' 45 | }, 46 | ...(port != 0 && { 47 | proxy: { 48 | protocol: 'http', 49 | host: '127.0.0.1', 50 | port 51 | } 52 | }) 53 | } 54 | ) 55 | } 56 | 57 | async function updateGist(token: string, id: string, content: string): Promise { 58 | const { 'mixed-port': port = 7890 } = await getControledMihomoConfig() 59 | return await axios.patch( 60 | `https://api.github.com/gists/${id}`, 61 | { 62 | description: 'Auto Synced Sparkle Runtime Config', 63 | files: { 'sparkle.yaml': { content } } 64 | }, 65 | { 66 | headers: { 67 | Accept: 'application/vnd.github+json', 68 | Authorization: `Bearer ${token}`, 69 | 'X-GitHub-Api-Version': '2022-11-28' 70 | }, 71 | ...(port != 0 && { 72 | proxy: { 73 | protocol: 'http', 74 | host: '127.0.0.1', 75 | port 76 | } 77 | }) 78 | } 79 | ) 80 | } 81 | 82 | export async function getGistUrl(): Promise { 83 | const { githubToken } = await getAppConfig() 84 | if (!githubToken) return '' 85 | const gists = await listGists(githubToken) 86 | const gist = gists.find((gist) => gist.description === 'Auto Synced Sparkle Runtime Config') 87 | if (gist) { 88 | return gist.html_url 89 | } else { 90 | await uploadRuntimeConfig() 91 | const gists = await listGists(githubToken) 92 | const gist = gists.find((gist) => gist.description === 'Auto Synced Sparkle Runtime Config') 93 | if (!gist) throw new Error('Gist not found') 94 | return gist.html_url 95 | } 96 | } 97 | 98 | export async function uploadRuntimeConfig(): Promise { 99 | const { githubToken } = await getAppConfig() 100 | if (!githubToken) return 101 | const gists = await listGists(githubToken) 102 | const gist = gists.find((gist) => gist.description === 'Auto Synced Sparkle Runtime Config') 103 | const config = await getRuntimeConfigStr() 104 | if (gist) { 105 | await updateGist(githubToken, gist.id, config) 106 | } else { 107 | await createGist(githubToken, config) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/main/resolve/theme.ts: -------------------------------------------------------------------------------- 1 | import { copyFile, readdir, readFile, writeFile } from 'fs/promises' 2 | import { themesDir } from '../utils/dirs' 3 | import path from 'path' 4 | import axios from 'axios' 5 | import AdmZip from 'adm-zip' 6 | import { getControledMihomoConfig } from '../config' 7 | import { existsSync } from 'fs' 8 | import { mainWindow } from '..' 9 | import { floatingWindow } from './floatingWindow' 10 | 11 | let insertedCSSKeyMain: string | undefined = undefined 12 | let insertedCSSKeyFloating: string | undefined = undefined 13 | 14 | export async function resolveThemes(): Promise<{ key: string; label: string }[]> { 15 | const files = await readdir(themesDir()) 16 | const themes = await Promise.all( 17 | files 18 | .filter((file) => file.endsWith('.css')) 19 | .map(async (file) => { 20 | const css = (await readFile(path.join(themesDir(), file), 'utf-8')) || '' 21 | let name = file 22 | if (css.startsWith('/*')) { 23 | name = css.split('\n')[0].replace('/*', '').replace('*/', '').trim() || file 24 | } 25 | return { key: file, label: name } 26 | }) 27 | ) 28 | if (themes.find((theme) => theme.key === 'default.css')) { 29 | return themes 30 | } else { 31 | return [{ key: 'default.css', label: '默认' }, ...themes] 32 | } 33 | } 34 | 35 | export async function fetchThemes(): Promise { 36 | const zipUrl = 'https://github.com/mihomo-party-org/theme-hub/releases/download/latest/themes.zip' 37 | const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig() 38 | const zipData = await axios.get(zipUrl, { 39 | responseType: 'arraybuffer', 40 | headers: { 'Content-Type': 'application/octet-stream' }, 41 | ...(mixedPort != 0 && { 42 | proxy: { 43 | protocol: 'http', 44 | host: '127.0.0.1', 45 | port: mixedPort 46 | } 47 | }) 48 | }) 49 | const zip = new AdmZip(zipData.data as Buffer) 50 | zip.extractAllTo(themesDir(), true) 51 | } 52 | 53 | export async function importThemes(files: string[]): Promise { 54 | for (const file of files) { 55 | if (existsSync(file)) 56 | await copyFile( 57 | file, 58 | path.join(themesDir(), `${new Date().getTime().toString(16)}-${path.basename(file)}`) 59 | ) 60 | } 61 | } 62 | 63 | export async function readTheme(theme: string): Promise { 64 | if (!existsSync(path.join(themesDir(), theme))) return '' 65 | return await readFile(path.join(themesDir(), theme), 'utf-8') 66 | } 67 | 68 | export async function writeTheme(theme: string, css: string): Promise { 69 | await writeFile(path.join(themesDir(), theme), css) 70 | } 71 | 72 | export async function applyTheme(theme: string): Promise { 73 | const css = await readTheme(theme) 74 | await mainWindow?.webContents.removeInsertedCSS(insertedCSSKeyMain || '') 75 | insertedCSSKeyMain = await mainWindow?.webContents.insertCSS(css) 76 | try { 77 | await floatingWindow?.webContents.removeInsertedCSS(insertedCSSKeyFloating || '') 78 | insertedCSSKeyFloating = await floatingWindow?.webContents.insertCSS(css) 79 | } catch { 80 | // ignore 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/main/resolve/trafficMonitor.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, spawn } from 'child_process' 2 | import { getAppConfig } from '../config' 3 | import { dataDir, resourcesFilesDir } from '../utils/dirs' 4 | import path from 'path' 5 | import { existsSync } from 'fs' 6 | import { readFile, rm, writeFile } from 'fs/promises' 7 | 8 | let child: ChildProcess 9 | 10 | export async function startMonitor(detached = false): Promise { 11 | if (process.platform !== 'win32') return 12 | if (existsSync(path.join(dataDir(), 'monitor.pid'))) { 13 | const pid = parseInt(await readFile(path.join(dataDir(), 'monitor.pid'), 'utf-8')) 14 | try { 15 | process.kill(pid, 'SIGINT') 16 | } catch { 17 | // ignore 18 | } finally { 19 | await rm(path.join(dataDir(), 'monitor.pid')) 20 | } 21 | } 22 | await stopMonitor() 23 | const { showTraffic = false } = await getAppConfig() 24 | if (!showTraffic) return 25 | child = spawn(path.join(resourcesFilesDir(), 'TrafficMonitor/TrafficMonitor.exe'), [], { 26 | cwd: path.join(resourcesFilesDir(), 'TrafficMonitor'), 27 | detached: detached, 28 | stdio: detached ? 'ignore' : undefined 29 | }) 30 | if (detached) { 31 | if (child && child.pid) { 32 | await writeFile(path.join(dataDir(), 'monitor.pid'), child.pid.toString()) 33 | } 34 | child.unref() 35 | } 36 | } 37 | 38 | async function stopMonitor(): Promise { 39 | if (child) { 40 | child.kill('SIGINT') 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/sys/interface.ts: -------------------------------------------------------------------------------- 1 | import os from 'os' 2 | 3 | export function getInterfaces(): NodeJS.Dict { 4 | return os.networkInterfaces() 5 | } 6 | -------------------------------------------------------------------------------- /src/main/sys/ssid.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process' 2 | import { promisify } from 'util' 3 | import { getAppConfig, patchControledMihomoConfig } from '../config' 4 | import { patchMihomoConfig } from '../core/mihomoApi' 5 | import { mainWindow } from '..' 6 | import { ipcMain, net } from 'electron' 7 | import { getDefaultDevice } from '../core/manager' 8 | 9 | export async function getCurrentSSID(): Promise { 10 | if (process.platform === 'win32') { 11 | try { 12 | return await getSSIDByNetsh() 13 | } catch { 14 | return undefined 15 | } 16 | } 17 | if (process.platform === 'linux') { 18 | try { 19 | return await getSSIDByIwconfig() 20 | } catch { 21 | return undefined 22 | } 23 | } 24 | if (process.platform === 'darwin') { 25 | try { 26 | return await getSSIDByAirport() 27 | } catch { 28 | return await getSSIDByNetworksetup() 29 | } 30 | } 31 | return undefined 32 | } 33 | 34 | let lastSSID: string | undefined 35 | export async function checkSSID(): Promise { 36 | try { 37 | const { pauseSSID = [] } = await getAppConfig() 38 | if (pauseSSID.length === 0) return 39 | const currentSSID = await getCurrentSSID() 40 | if (currentSSID === lastSSID) return 41 | lastSSID = currentSSID 42 | if (currentSSID && pauseSSID.includes(currentSSID)) { 43 | await patchControledMihomoConfig({ mode: 'direct' }) 44 | await patchMihomoConfig({ mode: 'direct' }) 45 | mainWindow?.webContents.send('controledMihomoConfigUpdated') 46 | ipcMain.emit('updateTrayMenu') 47 | } else { 48 | await patchControledMihomoConfig({ mode: 'rule' }) 49 | await patchMihomoConfig({ mode: 'rule' }) 50 | mainWindow?.webContents.send('controledMihomoConfigUpdated') 51 | ipcMain.emit('updateTrayMenu') 52 | } 53 | } catch { 54 | // ignore 55 | } 56 | } 57 | 58 | export async function startSSIDCheck(): Promise { 59 | await checkSSID() 60 | setInterval(checkSSID, 30000) 61 | } 62 | 63 | async function getSSIDByAirport(): Promise { 64 | const execPromise = promisify(exec) 65 | const { stdout } = await execPromise( 66 | '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -I' 67 | ) 68 | if (stdout.trim().startsWith('WARNING')) { 69 | throw new Error('airport cannot be used') 70 | } 71 | for (const line of stdout.split('\n')) { 72 | if (line.trim().startsWith('SSID')) { 73 | return line.split(': ')[1].trim() 74 | } 75 | } 76 | return undefined 77 | } 78 | 79 | async function getSSIDByNetworksetup(): Promise { 80 | const execPromise = promisify(exec) 81 | if (net.isOnline()) { 82 | const service = await getDefaultDevice() 83 | const { stdout } = await execPromise(`networksetup -listpreferredwirelessnetworks ${service}`) 84 | if (stdout.trim().startsWith('Preferred networks on')) { 85 | if (stdout.split('\n').length > 1) { 86 | return stdout.split('\n')[1].trim() 87 | } 88 | } 89 | } 90 | return undefined 91 | } 92 | 93 | async function getSSIDByNetsh(): Promise { 94 | const execPromise = promisify(exec) 95 | const { stdout } = await execPromise('netsh wlan show interfaces') 96 | for (const line of stdout.split('\n')) { 97 | if (line.trim().startsWith('SSID')) { 98 | return line.split(': ')[1].trim() 99 | } 100 | } 101 | return undefined 102 | } 103 | 104 | async function getSSIDByIwconfig(): Promise { 105 | const execPromise = promisify(exec) 106 | const { stdout } = await execPromise( 107 | `iwconfig 2>/dev/null | grep 'ESSID' | awk -F'"' '{print $2}'` 108 | ) 109 | if (stdout.trim() !== '') { 110 | return stdout.trim() 111 | } 112 | return undefined 113 | } 114 | -------------------------------------------------------------------------------- /src/main/utils/calc.ts: -------------------------------------------------------------------------------- 1 | export function calcTraffic(byte: number): string { 2 | if (byte < 1024) return `${byte} B` 3 | byte /= 1024 4 | if (byte < 1024) return `${formatNumString(byte)} KB` 5 | byte /= 1024 6 | if (byte < 1024) return `${formatNumString(byte)} MB` 7 | byte /= 1024 8 | if (byte < 1024) return `${formatNumString(byte)} GB` 9 | byte /= 1024 10 | if (byte < 1024) return `${formatNumString(byte)} TB` 11 | byte /= 1024 12 | if (byte < 1024) return `${formatNumString(byte)} PB` 13 | byte /= 1024 14 | if (byte < 1024) return `${formatNumString(byte)} EB` 15 | byte /= 1024 16 | if (byte < 1024) return `${formatNumString(byte)} ZB` 17 | byte /= 1024 18 | return `${formatNumString(byte)} YB` 19 | } 20 | 21 | function formatNumString(num: number): string { 22 | let str = num.toFixed(2) 23 | if (str.length <= 5) return str 24 | if (str.length == 6) { 25 | str = num.toFixed(1) 26 | return str 27 | } else { 28 | str = Math.round(num).toString() 29 | return str 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/utils/image.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { getControledMihomoConfig } from '../config' 3 | 4 | export async function getImageDataURL(url: string): Promise { 5 | const { 'mixed-port': port = 7890 } = await getControledMihomoConfig() 6 | const res = await axios.get(url, { 7 | responseType: 'arraybuffer', 8 | ...(port != 0 && { 9 | proxy: { 10 | protocol: 'http', 11 | host: '127.0.0.1', 12 | port 13 | } 14 | }) 15 | }) 16 | const mimeType = res.headers['content-type'] 17 | const dataURL = `data:${mimeType};base64,${Buffer.from(res.data).toString('base64')}` 18 | return dataURL 19 | } 20 | -------------------------------------------------------------------------------- /src/main/utils/merge.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | function isObject(item: any): boolean { 3 | return item && typeof item === 'object' && !Array.isArray(item) 4 | } 5 | 6 | function trimWrap(str: string): string { 7 | if (str.startsWith('<') && str.endsWith('>')) { 8 | return str.slice(1, -1) 9 | } 10 | return str 11 | } 12 | 13 | export function deepMerge(target: T, other: Partial, isOverride?: boolean): T { 14 | for (const key in other) { 15 | if (isObject(other[key])) { 16 | if (key.endsWith('!')) { 17 | const k = trimWrap(key.slice(0, -1)) 18 | target[k] = other[key] 19 | } else { 20 | const k = trimWrap(key) 21 | if (!target[k]) Object.assign(target, { [k]: {} }) 22 | deepMerge(target[k] as object, other[k] as object, isOverride) 23 | } 24 | } else if (Array.isArray(other[key])) { 25 | if (isOverride && key.startsWith('+')) { 26 | const k = trimWrap(key.slice(1)) 27 | if (!target[k]) Object.assign(target, { [k]: [] }) 28 | target[k] = [...other[key], ...(target[k] as never[])] 29 | } else if (isOverride && key.endsWith('+')) { 30 | const k = trimWrap(key.slice(0, -1)) 31 | if (!target[k]) Object.assign(target, { [k]: [] }) 32 | target[k] = [...(target[k] as never[]), ...other[key]] 33 | } else { 34 | const k = trimWrap(key) 35 | Object.assign(target, { [k]: other[key] }) 36 | } 37 | } else { 38 | Object.assign(target, { [key]: other[key] }) 39 | } 40 | } 41 | return target as T 42 | } 43 | -------------------------------------------------------------------------------- /src/preload/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ElectronAPI } from '@electron-toolkit/preload' 2 | import { webUtils } from 'electron' 3 | 4 | declare global { 5 | interface Window { 6 | electron: ElectronAPI 7 | api: { webUtils: typeof webUtils } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, webUtils } from 'electron' 2 | import { electronAPI } from '@electron-toolkit/preload' 3 | 4 | // Custom APIs for renderer 5 | const api = { 6 | webUtils: webUtils 7 | } 8 | // Use `contextBridge` APIs to expose Electron APIs to 9 | // renderer only if context isolation is enabled, otherwise 10 | // just add to the DOM global. 11 | if (process.contextIsolated) { 12 | try { 13 | contextBridge.exposeInMainWorld('electron', electronAPI) 14 | contextBridge.exposeInMainWorld('api', api) 15 | } catch (error) { 16 | console.error(error) 17 | } 18 | } else { 19 | // @ts-ignore (define in dts) 20 | window.electron = electronAPI 21 | // @ts-ignore (define in dts) 22 | window.api = api 23 | } 24 | -------------------------------------------------------------------------------- /src/renderer/floating.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sparkle Floating 6 | 7 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sparkle 6 | 7 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/renderer/src/FloatingApp.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from 'react' 2 | import MihomoIcon from './components/base/mihomo-icon' 3 | import { calcTraffic } from './utils/calc' 4 | import { showContextMenu, triggerMainWindow } from './utils/ipc' 5 | import { useAppConfig } from './hooks/use-app-config' 6 | import { useControledMihomoConfig } from './hooks/use-controled-mihomo-config' 7 | 8 | const FloatingApp: React.FC = () => { 9 | const { appConfig } = useAppConfig() 10 | const { controledMihomoConfig } = useControledMihomoConfig() 11 | const { sysProxy, spinFloatingIcon = true } = appConfig || {} 12 | const { tun } = controledMihomoConfig || {} 13 | const sysProxyEnabled = sysProxy?.enable 14 | const tunEnabled = tun?.enable 15 | 16 | const [upload, setUpload] = useState(0) 17 | const [download, setDownload] = useState(0) 18 | 19 | // 根据总速率计算旋转速度 20 | const spinSpeed = useMemo(() => { 21 | const total = upload + download 22 | if (total === 0) return 0 23 | if (total < 1024) return 2 24 | if (total < 1024 * 1024) return 3 25 | if (total < 1024 * 1024 * 1024) return 4 26 | return 5 27 | }, [upload, download]) 28 | 29 | const [rotation, setRotation] = useState(0) 30 | 31 | useEffect(() => { 32 | if (!spinFloatingIcon) return 33 | 34 | let animationFrameId: number 35 | const animate = (): void => { 36 | setRotation((prev) => { 37 | if (prev === 360) { 38 | return 0 39 | } 40 | return prev + spinSpeed 41 | }) 42 | animationFrameId = requestAnimationFrame(animate) 43 | } 44 | 45 | animationFrameId = requestAnimationFrame(animate) 46 | return (): void => { 47 | cancelAnimationFrame(animationFrameId) 48 | } 49 | }, [spinSpeed, spinFloatingIcon]) 50 | 51 | useEffect(() => { 52 | window.electron.ipcRenderer.on('mihomoTraffic', async (_e, info: IMihomoTrafficInfo) => { 53 | setUpload(info.up) 54 | setDownload(info.down) 55 | }) 56 | return (): void => { 57 | window.electron.ipcRenderer.removeAllListeners('mihomoTraffic') 58 | } 59 | }, []) 60 | 61 | return ( 62 |
63 |
64 |
65 |
{ 67 | e.preventDefault() 68 | showContextMenu() 69 | }} 70 | onClick={() => { 71 | triggerMainWindow() 72 | }} 73 | style={ 74 | spinFloatingIcon 75 | ? { 76 | transform: `rotate(${rotation}deg)`, 77 | transition: 'transform 0.1s linear' 78 | } 79 | : {} 80 | } 81 | className={`app-nodrag cursor-pointer floating-thumb ${tunEnabled ? 'bg-secondary' : sysProxyEnabled ? 'bg-primary' : 'bg-default'} hover:opacity-hover rounded-full h-[calc(100%-4px)] aspect-square`} 82 | > 83 | 84 |
85 |
86 |
87 |
88 |

89 | {calcTraffic(upload)}/s 90 |

91 |

92 | {calcTraffic(download)}/s 93 |

94 |
95 |
96 |
97 |
98 | ) 99 | } 100 | 101 | export default FloatingApp 102 | -------------------------------------------------------------------------------- /src/renderer/src/assets/floating.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .floating-text { 6 | font-family: 7 | 'Microsoft YaHei', 8 | system-ui, 9 | -apple-system, 10 | BlinkMacSystemFont; 11 | } 12 | 13 | html { 14 | background: none !important; 15 | background-color: transparent !important; 16 | } 17 | 18 | .app-nodrag { 19 | -webkit-app-region: none; 20 | } 21 | 22 | .app-drag { 23 | -webkit-app-region: drag; 24 | } 25 | 26 | * { 27 | user-select: none; 28 | } 29 | -------------------------------------------------------------------------------- /src/renderer/src/assets/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @font-face { 6 | font-family: 'Noto Color Emoji'; 7 | src: url('./NotoColorEmoji.ttf'); 8 | } 9 | 10 | .driver-popover { 11 | background-color: hsl(var(--heroui-content2)) !important; 12 | border-radius: 8px !important; 13 | color: hsl(var(--heroui-foreground)) !important; 14 | } 15 | 16 | .driver-popover a { 17 | color: hsl(var(--heroui-primary)) !important; 18 | text-decoration: underline !important; 19 | } 20 | 21 | .driver-popover-close-btn { 22 | color: hsl(var(--heroui-foreground)) !important; 23 | } 24 | 25 | .driver-popover-progress-text { 26 | color: hsl(var(--heroui-default-500)) !important; 27 | } 28 | 29 | .driver-popover-prev-btn { 30 | color: white !important; 31 | text-shadow: none !important; 32 | border: none !important; 33 | padding: 8px !important; 34 | border-radius: 5px !important; 35 | font-size: 12px !important; 36 | background-color: hsl(var(--heroui-primary)) !important; 37 | } 38 | 39 | .driver-popover-next-btn { 40 | color: white !important; 41 | text-shadow: none !important; 42 | border: none !important; 43 | padding: 8px !important; 44 | border-radius: 5px !important; 45 | font-size: 12px !important; 46 | background-color: hsl(var(--heroui-primary)) !important; 47 | } 48 | 49 | .driver-popover-arrow-side-bottom { 50 | border-bottom-color: hsl(var(--heroui-content2)) !important; 51 | } 52 | 53 | .driver-popover-arrow-side-top { 54 | border-top-color: hsl(var(--heroui-content2)) !important; 55 | } 56 | 57 | .driver-popover-arrow-side-left { 58 | border-left-color: hsl(var(--heroui-content2)) !important; 59 | } 60 | 61 | .driver-popover-arrow-side-right { 62 | border-right-color: hsl(var(--heroui-content2)) !important; 63 | } 64 | 65 | .app-nodrag { 66 | -webkit-app-region: none; 67 | } 68 | .app-drag { 69 | -webkit-app-region: drag; 70 | } 71 | 72 | * { 73 | user-select: none; 74 | } 75 | 76 | *:focus { 77 | outline: none; 78 | } 79 | 80 | .flag-emoji { 81 | font-family: 82 | system-ui, 83 | -apple-system, 84 | BlinkMacSystemFont, 85 | 'Segoe UI', 86 | Roboto, 87 | Oxygen, 88 | Ubuntu, 89 | Cantarell, 90 | 'Open Sans', 91 | 'Helvetica Neue', 92 | sans-serif, 93 | 'Apple Color Emoji', 94 | 'Noto Color Emoji'; 95 | } 96 | 97 | .no-scrollbar::-webkit-scrollbar { 98 | display: none; 99 | } 100 | 101 | *::-webkit-scrollbar { 102 | width: 8px; 103 | height: 8px; 104 | } 105 | 106 | *::-webkit-scrollbar-corner { 107 | background-color: transparent; 108 | } 109 | 110 | /* Light mode */ 111 | @media (prefers-color-scheme: light) { 112 | *::-webkit-scrollbar-thumb { 113 | background: #c0c1c58f; 114 | border-radius: 5px; 115 | } 116 | 117 | *::-webkit-scrollbar-thumb:hover { 118 | background: #c0c1c550; 119 | } 120 | } 121 | 122 | /* Dark mode */ 123 | @media (prefers-color-scheme: dark) { 124 | *::-webkit-scrollbar-thumb { 125 | background: #c0c1c550; 126 | border-radius: 5px; 127 | } 128 | 129 | *::-webkit-scrollbar-thumb:hover { 130 | background: #c0c1c58f; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/renderer/src/components/base/base-confirm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react' 3 | 4 | interface ConfirmModalProps { 5 | onChange: (open: boolean) => void 6 | title?: string 7 | description?: React.ReactNode 8 | confirmText?: string 9 | cancelText?: string 10 | onConfirm: () => void | Promise 11 | } 12 | 13 | const ConfirmModal: React.FC = ({ 14 | onChange, 15 | title = '请确认', 16 | description, 17 | confirmText = '确认', 18 | cancelText = '取消', 19 | onConfirm 20 | }) => ( 21 | 33 | 34 | {title} 35 | 36 |
{description}
37 |
38 | 39 | 42 | 52 | 53 |
54 |
55 | ) 56 | 57 | export default ConfirmModal 58 | -------------------------------------------------------------------------------- /src/renderer/src/components/base/base-error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@heroui/react' 2 | import { JSX, ReactNode } from 'react' 3 | import { ErrorBoundary, FallbackProps } from 'react-error-boundary' 4 | 5 | const ErrorFallback = ({ error }: FallbackProps): JSX.Element => { 6 | return ( 7 |
8 |

9 | {'应用崩溃了 :( 请将以下信息提交给开发者以排查错误'} 10 |

11 | 12 | {/* */} 20 | 29 | 30 | 40 | 41 |

{error.message}

42 | 43 |
44 | Error Stack 45 |
{error.stack}
46 |
47 |
48 | ) 49 | } 50 | 51 | interface Props { 52 | children?: ReactNode 53 | } 54 | 55 | const BaseErrorBoundary = (props: Props): JSX.Element => { 56 | return {props.children} 57 | } 58 | 59 | export default BaseErrorBoundary 60 | -------------------------------------------------------------------------------- /src/renderer/src/components/base/base-page.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Divider } from '@heroui/react' 2 | import { useAppConfig } from '@renderer/hooks/use-app-config' 3 | import { platform } from '@renderer/utils/init' 4 | import { isAlwaysOnTop, setAlwaysOnTop } from '@renderer/utils/ipc' 5 | import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' 6 | import { RiPushpin2Fill, RiPushpin2Line } from 'react-icons/ri' 7 | interface Props { 8 | title?: React.ReactNode 9 | header?: React.ReactNode 10 | children?: React.ReactNode 11 | contentClassName?: string 12 | } 13 | let saveOnTop = false 14 | 15 | const BasePage = forwardRef((props, ref) => { 16 | const { appConfig } = useAppConfig() 17 | const { useWindowFrame = false } = appConfig || {} 18 | const [overlayWidth, setOverlayWidth] = React.useState(0) 19 | const [onTop, setOnTop] = useState(saveOnTop) 20 | 21 | const updateAlwaysOnTop = async (): Promise => { 22 | setOnTop(await isAlwaysOnTop()) 23 | saveOnTop = await isAlwaysOnTop() 24 | } 25 | 26 | useEffect(() => { 27 | if (platform !== 'darwin' && !useWindowFrame) { 28 | try { 29 | // @ts-ignore windowControlsOverlay 30 | const windowControlsOverlay = window.navigator.windowControlsOverlay 31 | setOverlayWidth(window.innerWidth - windowControlsOverlay.getTitlebarAreaRect().width) 32 | } catch (e) { 33 | // ignore 34 | } 35 | } 36 | }, []) 37 | 38 | const contentRef = useRef(null) 39 | useImperativeHandle(ref, () => { 40 | return contentRef.current as HTMLDivElement 41 | }) 42 | 43 | return ( 44 |
45 |
46 |
47 |
{props.title}
48 |
49 | {props.header} 50 |
70 |
71 | 72 | 73 |
74 |
75 | {props.children} 76 |
77 |
78 | ) 79 | }) 80 | 81 | BasePage.displayName = 'BasePage' 82 | export default BasePage 83 | -------------------------------------------------------------------------------- /src/renderer/src/components/base/base-setting-card.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Accordion, AccordionItem, Card, CardBody } from '@heroui/react' 3 | 4 | interface Props { 5 | title?: string 6 | children?: React.ReactNode 7 | className?: string 8 | } 9 | 10 | const SettingCard: React.FC = (props) => { 11 | return !props.title ? ( 12 | 13 | {props.children} 14 | 15 | ) : ( 16 | 17 | 23 | {props.children} 24 | 25 | 26 | ) 27 | } 28 | 29 | export default SettingCard 30 | -------------------------------------------------------------------------------- /src/renderer/src/components/base/base-setting-item.tsx: -------------------------------------------------------------------------------- 1 | import { Divider } from '@heroui/react' 2 | 3 | import React from 'react' 4 | 5 | interface Props { 6 | title: React.ReactNode 7 | actions?: React.ReactNode 8 | children?: React.ReactNode 9 | divider?: boolean 10 | } 11 | 12 | const SettingItem: React.FC = (props) => { 13 | const { title, actions, children, divider = false } = props 14 | 15 | return ( 16 | <> 17 |
18 |
19 |

{title}

20 |
{actions}
21 |
22 | {children} 23 |
24 | {divider && } 25 | 26 | ) 27 | } 28 | 29 | export default SettingItem 30 | -------------------------------------------------------------------------------- /src/renderer/src/components/base/border-switch.css: -------------------------------------------------------------------------------- 1 | .border-switch { 2 | input[type='checkbox'] { 3 | width: 100%; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/renderer/src/components/base/border-swtich.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { cn, Switch, SwitchProps } from '@heroui/react' 3 | import './border-switch.css' 4 | 5 | interface SiderSwitchProps extends SwitchProps { 6 | isShowBorder?: boolean 7 | } 8 | 9 | const BorderSwitch: React.FC = (props) => { 10 | const { isShowBorder = false, classNames, ...switchProps } = props 11 | 12 | return ( 13 | 26 | ) 27 | } 28 | 29 | export default BorderSwitch 30 | -------------------------------------------------------------------------------- /src/renderer/src/components/base/collapse-input.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import { Input, InputProps } from '@heroui/react' 3 | import { FaSearch } from 'react-icons/fa' 4 | 5 | interface CollapseInputProps extends InputProps { 6 | title: string 7 | } 8 | 9 | const CollapseInput: React.FC = (props) => { 10 | const { title, ...inputProps } = props 11 | const inputRef = useRef(null) 12 | return ( 13 |
14 | { 27 | e.stopPropagation() 28 | inputRef.current?.focus() 29 | }} 30 | > 31 | 32 |
33 | } 34 | onClick={(e) => { 35 | e.stopPropagation() 36 | inputRef.current?.focus() 37 | }} 38 | /> 39 | 40 | ) 41 | } 42 | 43 | export default CollapseInput 44 | -------------------------------------------------------------------------------- /src/renderer/src/components/base/interface-select.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { Select, SelectItem } from '@heroui/react' 3 | import { getInterfaces } from '@renderer/utils/ipc' 4 | 5 | const InterfaceSelect: React.FC<{ 6 | value: string 7 | exclude?: string[] 8 | onChange: (iface: string) => void 9 | }> = ({ value, onChange, exclude = [] }) => { 10 | const [ifaces, setIfaces] = useState([]) 11 | useEffect(() => { 12 | const fetchInterfaces = async () => { 13 | const names = Object.keys(await getInterfaces()) 14 | setIfaces(names.filter((name) => !exclude.includes(name))) 15 | } 16 | fetchInterfaces() 17 | }, []) 18 | 19 | return ( 20 | 34 | ) 35 | } 36 | 37 | export default InterfaceSelect 38 | -------------------------------------------------------------------------------- /src/renderer/src/components/base/mihomo-icon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from 'react' 2 | import { GenIcon, IconBaseProps } from 'react-icons' 3 | function MihomoIcon(props: IconBaseProps): JSX.Element { 4 | return GenIcon({ 5 | tag: 'svg', 6 | attr: { viewBox: '0 0 76.14 72.14' }, 7 | child: [ 8 | { 9 | tag: 'path', 10 | attr: { 11 | d: 'm38.2,35.17c-2.73,0-5.58.46-8.54,1.4-.36.12-.75.03-1.04-.23l-11.79-10.76c-.66-.6-1.69-.55-2.29.11-.27.3-.42.69-.42,1.09v25.51c0,.95-.48,1.42-1.43,1.42-3.49,0-7.45-.69-11.89-2.06-.27-.09-.46-.34-.46-.63V1.45c0-.48.24-.74.72-.78L9.19,0c.23-.02.46.06.63.21l22.99,20.97c.37.33.87.48,1.35.39,1.55-.28,2.9-.42,4.05-.42s2.5.14,4.05.43c.49.09.99-.05,1.36-.39L66.64.26c.17-.15.4-.23.63-.21l8.13.68c.48.04.72.3.72.78l-.09,49.57c0,.29-.19.54-.46.63-4.44,1.36-8.4,2.04-11.89,2.04-.95,0-1.43-.47-1.43-1.42l.05-25.51c0-.89-.72-1.62-1.62-1.62-.4,0-.79.15-1.09.42l-11.81,10.74c-.29.26-.68.34-1.04.22-2.96-.94-5.81-1.41-8.54-1.41Z' 12 | }, 13 | child: [] 14 | }, 15 | { 16 | tag: 'path', 17 | attr: { 18 | d: 'm38.6,54.9c.94.02,1.42.03,1.43.03.73.04,1.3.66,1.27,1.38,0,.19-.06.37-.15.54-.57,1.07-1.11,2.04-1.64,2.91-.11.18-.25.3-.42.37s-.38.1-.61.09c-.23,0-.43-.04-.6-.12-.17-.07-.3-.2-.4-.38-.49-.89-1-1.88-1.52-2.98-.31-.65-.02-1.44.65-1.74.17-.08.36-.12.55-.13.01,0,.49.01,1.44.03Z' 19 | }, 20 | child: [] 21 | }, 22 | { 23 | tag: 'rect', 24 | attr: { 25 | x: '0', 26 | y: '57', 27 | width: '21.12', 28 | height: '2.7', 29 | rx: '.9', 30 | ry: '.9' 31 | }, 32 | child: [] 33 | }, 34 | { 35 | tag: 'rect', 36 | attr: { 37 | x: '55.81', 38 | y: '57', 39 | width: '20.28', 40 | height: '2.7', 41 | rx: '.9', 42 | ry: '.9' 43 | }, 44 | child: [] 45 | }, 46 | { 47 | tag: 'rect', 48 | attr: { 49 | x: '-.29', 50 | y: '66.23', 51 | width: '21.96', 52 | height: '2.8', 53 | rx: '.9', 54 | ry: '.9', 55 | transform: 'translate(-21.33 7.11) rotate(-18.9)' 56 | }, 57 | child: [] 58 | }, 59 | { 60 | tag: 'rect', 61 | attr: { 62 | x: '64.5', 63 | y: '56.89', 64 | width: '2.82', 65 | height: '21.4', 66 | rx: '.9', 67 | ry: '.9', 68 | transform: 'translate(-19.39 108.06) rotate(-71.1)' 69 | }, 70 | child: [] 71 | } 72 | ] 73 | })(props) 74 | } 75 | 76 | export default MihomoIcon 77 | -------------------------------------------------------------------------------- /src/renderer/src/components/connections/connection-item.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, CardFooter, CardHeader, Chip } from '@heroui/react' 2 | import { calcTraffic } from '@renderer/utils/calc' 3 | import dayjs from 'dayjs' 4 | import React, { useEffect } from 'react' 5 | import { CgClose, CgTrash } from 'react-icons/cg' 6 | 7 | interface Props { 8 | index: number 9 | info: IMihomoConnectionDetail 10 | selected: IMihomoConnectionDetail | undefined 11 | setSelected: React.Dispatch> 12 | setIsDetailModalOpen: React.Dispatch> 13 | close: (id: string) => void 14 | } 15 | 16 | const ConnectionItem: React.FC = (props) => { 17 | const { index, info, close, selected, setSelected, setIsDetailModalOpen } = props 18 | 19 | useEffect(() => { 20 | if (selected?.id === info.id) { 21 | setSelected(info) 22 | } 23 | }, [info]) 24 | 25 | return ( 26 |
27 | { 31 | setSelected(info) 32 | setIsDetailModalOpen(true) 33 | }} 34 | > 35 |
36 |
37 | 38 | 44 | {info.metadata.type}({info.metadata.network.toUpperCase()}) 45 | 46 |
47 | {info.metadata.process || info.metadata.sourceIP} 48 | {' -> '} 49 | {info.metadata.host || 50 | info.metadata.sniffHost || 51 | info.metadata.destinationIP || 52 | info.metadata.remoteDestination} 53 |
54 | 55 | {dayjs(info.start).fromNow()} 56 | 57 |
58 | { 60 | e.currentTarget.scrollLeft += e.deltaY 61 | }} 62 | className="overscroll-contain pt-2 flex justify-start gap-1 overflow-x-auto no-scrollbar" 63 | > 64 | 70 | {info.chains[0]} 71 | 72 | 73 | ↑ {calcTraffic(info.upload)} ↓ {calcTraffic(info.download)} 74 | 75 | {info.uploadSpeed !== 0 || info.downloadSpeed !== 0 ? ( 76 | 77 | ↑ {calcTraffic(info.uploadSpeed || 0)}/s ↓ {calcTraffic(info.downloadSpeed || 0)} 78 | /s 79 | 80 | ) : null} 81 | 82 |
83 | 94 |
95 |
96 |
97 | ) 98 | } 99 | 100 | export default ConnectionItem 101 | -------------------------------------------------------------------------------- /src/renderer/src/components/logs/log-item.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardBody, CardHeader } from '@heroui/react' 2 | import React from 'react' 3 | 4 | const colorMap = { 5 | error: 'danger', 6 | warning: 'warning', 7 | info: 'primary', 8 | debug: 'default' 9 | } 10 | const LogItem: React.FC = (props) => { 11 | const { type, payload, time, index } = props 12 | return ( 13 |
14 | 15 | 16 |
17 | {props.type.toUpperCase()} 18 |
19 | {time} 20 |
21 | {payload} 22 |
23 |
24 | ) 25 | } 26 | 27 | export default LogItem 28 | -------------------------------------------------------------------------------- /src/renderer/src/components/mihomo/env-setting.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import SettingCard from '../base/base-setting-card' 3 | import SettingItem from '../base/base-setting-item' 4 | import { Button, Divider, Switch } from '@heroui/react' 5 | import { useAppConfig } from '@renderer/hooks/use-app-config' 6 | import { restartCore } from '@renderer/utils/ipc' 7 | import EditableList from '../base/base-list-editor' 8 | import { platform } from '@renderer/utils/init' 9 | 10 | const EnvSetting: React.FC = () => { 11 | const { appConfig, patchAppConfig } = useAppConfig() 12 | const { 13 | disableLoopbackDetector, 14 | disableEmbedCA, 15 | disableSystemCA, 16 | disableNftables, 17 | skipSafePathCheck, 18 | safePaths = [] 19 | } = appConfig || {} 20 | const handleConfigChangeWithRestart = async (key: string, value: any) => { 21 | try { 22 | await patchAppConfig({ [key]: value }) 23 | await restartCore() 24 | } catch (e) { 25 | alert(e) 26 | } finally { 27 | PubSub.publish('mihomo-core-changed') 28 | } 29 | } 30 | const [safePathsInput, setSafePathsInput] = useState(safePaths) 31 | 32 | return ( 33 | 34 | 35 | { 39 | handleConfigChangeWithRestart('disableSystemCA', v) 40 | }} 41 | /> 42 | 43 | 44 | { 48 | handleConfigChangeWithRestart('disableEmbedCA', v) 49 | }} 50 | /> 51 | 52 | 53 | { 57 | handleConfigChangeWithRestart('disableLoopbackDetector', v) 58 | }} 59 | /> 60 | 61 | {platform == 'linux' && ( 62 | 63 | { 67 | handleConfigChangeWithRestart('disableNftables', v) 68 | }} 69 | /> 70 | 71 | )} 72 | 73 | { 77 | handleConfigChangeWithRestart('skipSafePathCheck', v) 78 | }} 79 | /> 80 | 81 | {!skipSafePathCheck && ( 82 | <> 83 | 84 | 85 | {safePathsInput.join('') != safePaths.join('') && ( 86 | 95 | )} 96 | 97 | 98 | 99 | )} 100 | 101 | ) 102 | } 103 | 104 | export default EnvSetting 105 | -------------------------------------------------------------------------------- /src/renderer/src/components/mihomo/interface-modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Modal, 3 | ModalContent, 4 | ModalHeader, 5 | ModalBody, 6 | ModalFooter, 7 | Button, 8 | Snippet 9 | } from '@heroui/react' 10 | import React, { useEffect, useState } from 'react' 11 | import { getInterfaces } from '@renderer/utils/ipc' 12 | interface Props { 13 | onClose: () => void 14 | } 15 | const InterfaceModal: React.FC = (props) => { 16 | const { onClose } = props 17 | const [info, setInfo] = useState>({}) 18 | const getInfo = async (): Promise => { 19 | setInfo(await getInterfaces()) 20 | } 21 | 22 | useEffect(() => { 23 | getInfo() 24 | }, []) 25 | 26 | return ( 27 | 35 | 36 | 网络信息 37 | 38 | {Object.entries(info).map(([key, value]) => { 39 | return ( 40 |
41 |

{key}

42 | {value.map((v) => { 43 | return ( 44 |
45 |
46 | {v.family} 47 | 48 | {v.address} 49 | 50 |
51 |
52 | ) 53 | })} 54 |
55 | ) 56 | })} 57 |
58 | 59 | 62 | 63 |
64 |
65 | ) 66 | } 67 | 68 | export default InterfaceModal 69 | -------------------------------------------------------------------------------- /src/renderer/src/components/override/edit-file-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react' 2 | import React, { useEffect, useState } from 'react' 3 | import { BaseEditor } from '../base/base-editor' 4 | import { getOverride, restartCore, setOverride } from '@renderer/utils/ipc' 5 | interface Props { 6 | id: string 7 | language: 'javascript' | 'yaml' 8 | onClose: () => void 9 | } 10 | const EditFileModal: React.FC = (props) => { 11 | const { id, language, onClose } = props 12 | const [currData, setCurrData] = useState('') 13 | 14 | const getContent = async (): Promise => { 15 | setCurrData(await getOverride(id, language === 'javascript' ? 'js' : 'yaml')) 16 | } 17 | 18 | useEffect(() => { 19 | getContent() 20 | }, []) 21 | 22 | return ( 23 | 35 | 36 | 37 | 编辑覆写{language === 'javascript' ? '脚本' : '配置'} 38 | 39 | 40 | setCurrData(value)} 44 | /> 45 | 46 | 47 | 50 | 65 | 66 | 67 | 68 | ) 69 | } 70 | 71 | export default EditFileModal 72 | -------------------------------------------------------------------------------- /src/renderer/src/components/override/edit-info-modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Modal, 3 | ModalContent, 4 | ModalHeader, 5 | ModalBody, 6 | ModalFooter, 7 | Button, 8 | Input, 9 | Switch 10 | } from '@heroui/react' 11 | import React, { useState } from 'react' 12 | import SettingItem from '../base/base-setting-item' 13 | import { restartCore } from '@renderer/utils/ipc' 14 | interface Props { 15 | item: IOverrideItem 16 | updateOverrideItem: (item: IOverrideItem) => Promise 17 | onClose: () => void 18 | } 19 | const EditInfoModal: React.FC = (props) => { 20 | const { item, updateOverrideItem, onClose } = props 21 | const [values, setValues] = useState(item) 22 | 23 | const onSave = async (): Promise => { 24 | await updateOverrideItem(values) 25 | await restartCore() 26 | onClose() 27 | } 28 | 29 | return ( 30 | 38 | 39 | 编辑信息 40 | 41 | 42 | { 47 | setValues({ ...values, name: v }) 48 | }} 49 | /> 50 | 51 | {values.type === 'remote' && ( 52 | 53 | { 58 | setValues({ ...values, url: v }) 59 | }} 60 | /> 61 | 62 | )} 63 | 64 | { 68 | setValues({ ...values, global: v }) 69 | }} 70 | /> 71 | 72 | 73 | 74 | 77 | 80 | 81 | 82 | 83 | ) 84 | } 85 | 86 | export default EditInfoModal 87 | -------------------------------------------------------------------------------- /src/renderer/src/components/override/exec-log-modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Modal, 3 | ModalContent, 4 | ModalHeader, 5 | ModalBody, 6 | ModalFooter, 7 | Button, 8 | Divider 9 | } from '@heroui/react' 10 | import React, { useEffect, useState } from 'react' 11 | import { getOverride } from '@renderer/utils/ipc' 12 | interface Props { 13 | id: string 14 | onClose: () => void 15 | } 16 | const ExecLogModal: React.FC = (props) => { 17 | const { id, onClose } = props 18 | const [logs, setLogs] = useState([]) 19 | 20 | const getLog = async (): Promise => { 21 | setLogs((await getOverride(id, 'log')).split('\n').filter(Boolean)) 22 | } 23 | 24 | useEffect(() => { 25 | getLog() 26 | }, []) 27 | 28 | return ( 29 | 37 | 38 | 执行日志 39 | 40 | {logs.map((log) => { 41 | return ( 42 | <> 43 | {log} 44 | 45 | 46 | ) 47 | })} 48 | 49 | 50 | 53 | 54 | 55 | 56 | ) 57 | } 58 | 59 | export default ExecLogModal 60 | -------------------------------------------------------------------------------- /src/renderer/src/components/profiles/edit-file-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react' 2 | import React, { useEffect, useState } from 'react' 3 | import { BaseEditor } from '../base/base-editor' 4 | import { getProfileStr, setProfileStr } from '@renderer/utils/ipc' 5 | import { useNavigate } from 'react-router-dom' 6 | interface Props { 7 | id: string 8 | onClose: () => void 9 | } 10 | const EditFileModal: React.FC = (props) => { 11 | const { id, onClose } = props 12 | const [currData, setCurrData] = useState('') 13 | const navigate = useNavigate() 14 | 15 | const getContent = async (): Promise => { 16 | setCurrData(await getProfileStr(id)) 17 | } 18 | 19 | useEffect(() => { 20 | getContent() 21 | }, []) 22 | 23 | return ( 24 | 36 | 37 | 38 |
39 |
编辑订阅
40 | 41 | 注意:此处编辑配置更新订阅后会还原,如需要自定义配置请使用 42 | 53 | 功能 54 | 55 |
56 |
57 | 58 | setCurrData(value)} /> 59 | 60 | 61 | 64 | 74 | 75 |
76 |
77 | ) 78 | } 79 | 80 | export default EditFileModal 81 | -------------------------------------------------------------------------------- /src/renderer/src/components/proxies/proxy-item.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, CardBody } from '@heroui/react' 2 | import { mihomoUnfixedProxy } from '@renderer/utils/ipc' 3 | import React, { useMemo, useState } from 'react' 4 | import { FaMapPin } from 'react-icons/fa6' 5 | 6 | interface Props { 7 | mutateProxies: () => void 8 | onProxyDelay: (proxy: string, url?: string) => Promise 9 | proxyDisplayMode: 'simple' | 'full' 10 | proxy: IMihomoProxy | IMihomoGroup 11 | group: IMihomoMixedGroup 12 | onSelect: (group: string, proxy: string) => void 13 | selected: boolean 14 | } 15 | 16 | const ProxyItem: React.FC = (props) => { 17 | const { mutateProxies, proxyDisplayMode, group, proxy, selected, onSelect, onProxyDelay } = props 18 | 19 | const delay = useMemo(() => { 20 | if (proxy.history.length > 0) { 21 | return proxy.history[proxy.history.length - 1].delay 22 | } 23 | return -1 24 | }, [proxy]) 25 | 26 | const [loading, setLoading] = useState(false) 27 | function delayColor(delay: number): 'primary' | 'success' | 'warning' | 'danger' { 28 | if (delay === -1) return 'primary' 29 | if (delay === 0) return 'danger' 30 | if (delay < 500) return 'success' 31 | return 'warning' 32 | } 33 | 34 | function delayText(delay: number): string { 35 | if (delay === -1) return '测试' 36 | if (delay === 0) return '超时' 37 | return delay.toString() 38 | } 39 | 40 | const onDelay = (): void => { 41 | setLoading(true) 42 | onProxyDelay(proxy.name, group.testUrl).finally(() => { 43 | mutateProxies() 44 | setLoading(false) 45 | }) 46 | } 47 | 48 | const fixed = group.fixed && group.fixed === proxy.name 49 | 50 | return ( 51 | onSelect(group.name, proxy.name)} 53 | isPressable 54 | fullWidth 55 | shadow="sm" 56 | className={`${fixed ? 'bg-secondary/30' : selected ? 'bg-primary/30' : 'bg-content2'}`} 57 | radius="sm" 58 | > 59 | 60 |
61 |
62 |
63 | {proxy.name} 64 |
65 | {proxyDisplayMode === 'full' && ( 66 |
67 | {proxy.type} 68 |
69 | )} 70 |
71 |
72 | {fixed && ( 73 | 86 | )} 87 | 98 |
99 |
100 |
101 |
102 | ) 103 | } 104 | 105 | export default ProxyItem 106 | -------------------------------------------------------------------------------- /src/renderer/src/components/resources/viewer.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react' 2 | import React, { useEffect, useState } from 'react' 3 | import { BaseEditor } from '../base/base-editor' 4 | import { getFileStr, setFileStr } from '@renderer/utils/ipc' 5 | import yaml from 'js-yaml' 6 | type Language = 'yaml' | 'javascript' | 'css' | 'json' | 'text' 7 | 8 | interface Props { 9 | onClose: () => void 10 | path: string 11 | type: string 12 | title: string 13 | privderType: string 14 | format?: string 15 | } 16 | const Viewer: React.FC = (props) => { 17 | const { type, path, title, format, privderType, onClose } = props 18 | const [currData, setCurrData] = useState('') 19 | let language: Language = !format || format === 'YamlRule' ? 'yaml' : 'text' 20 | 21 | const getContent = async (): Promise => { 22 | let fileContent: React.SetStateAction 23 | if (type === 'Inline') { 24 | fileContent = await getFileStr('config.yaml') 25 | language = 'yaml' 26 | } else { 27 | fileContent = await getFileStr(path) 28 | } 29 | try { 30 | const parsedYaml = yaml.load(fileContent) 31 | if (privderType === 'proxy-providers') { 32 | setCurrData(yaml.dump({ 33 | 'proxies': parsedYaml[privderType][title].payload 34 | })) 35 | } else { 36 | setCurrData(yaml.dump({ 37 | 'rules': parsedYaml[privderType][title].payload 38 | })) 39 | } 40 | } catch (error) { 41 | setCurrData(fileContent) 42 | } 43 | } 44 | 45 | useEffect(() => { 46 | getContent() 47 | }, []) 48 | 49 | return ( 50 | 62 | 63 | {title} 64 | 65 | setCurrData(value)} 70 | /> 71 | 72 | 73 | 76 | {type == 'File' && ( 77 | 87 | )} 88 | 89 | 90 | 91 | ) 92 | } 93 | 94 | export default Viewer 95 | -------------------------------------------------------------------------------- /src/renderer/src/components/rules/rule-item.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardBody } from '@heroui/react' 2 | import React from 'react' 3 | 4 | const RuleItem: React.FC = (props) => { 5 | const { type, payload, proxy, index } = props 6 | return ( 7 |
8 | 9 | 10 |
11 | {payload} 12 |
13 |
14 |
{type}
15 |
{proxy}
16 |
17 |
18 |
19 |
20 | ) 21 | } 22 | 23 | export default RuleItem 24 | -------------------------------------------------------------------------------- /src/renderer/src/components/settings/css-editor-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react' 2 | import { BaseEditor } from '@renderer/components/base/base-editor' 3 | import { readTheme } from '@renderer/utils/ipc' 4 | import React, { useEffect, useState } from 'react' 5 | interface Props { 6 | theme: string 7 | onCancel: () => void 8 | onConfirm: (script: string) => void 9 | } 10 | const CSSEditorModal: React.FC = (props) => { 11 | const { theme, onCancel, onConfirm } = props 12 | const [currData, setCurrData] = useState('') 13 | 14 | useEffect(() => { 15 | if (theme) { 16 | readTheme(theme).then((css) => { 17 | setCurrData(css) 18 | }) 19 | } 20 | }, [theme]) 21 | 22 | return ( 23 | 35 | 36 | 编辑主题 37 | 38 | setCurrData(value || '')} 42 | /> 43 | 44 | 45 | 48 | 51 | 52 | 53 | 54 | ) 55 | } 56 | 57 | export default CSSEditorModal 58 | -------------------------------------------------------------------------------- /src/renderer/src/components/settings/sider-config.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import SettingCard from '../base/base-setting-card' 3 | import SettingItem from '../base/base-setting-item' 4 | import { RadioGroup, Radio } from '@heroui/react' 5 | import { useAppConfig } from '@renderer/hooks/use-app-config' 6 | const titleMap = { 7 | sysproxyCardStatus: '系统代理', 8 | tunCardStatus: '虚拟网卡', 9 | profileCardStatus: '订阅管理', 10 | proxyCardStatus: '代理组', 11 | ruleCardStatus: '规则', 12 | resourceCardStatus: '外部资源', 13 | overrideCardStatus: '覆写', 14 | connectionCardStatus: '连接', 15 | mihomoCoreCardStatus: '内核', 16 | dnsCardStatus: 'DNS', 17 | sniffCardStatus: '域名嗅探', 18 | logCardStatus: '日志', 19 | substoreCardStatus: 'Sub-Store' 20 | } 21 | const SiderConfig: React.FC = () => { 22 | const { appConfig, patchAppConfig } = useAppConfig() 23 | const { 24 | sysproxyCardStatus = 'col-span-1', 25 | tunCardStatus = 'col-span-1', 26 | profileCardStatus = 'col-span-2', 27 | proxyCardStatus = 'col-span-1', 28 | ruleCardStatus = 'col-span-1', 29 | resourceCardStatus = 'col-span-1', 30 | overrideCardStatus = 'col-span-1', 31 | connectionCardStatus = 'col-span-2', 32 | mihomoCoreCardStatus = 'col-span-2', 33 | dnsCardStatus = 'col-span-1', 34 | sniffCardStatus = 'col-span-1', 35 | logCardStatus = 'col-span-1', 36 | substoreCardStatus = 'col-span-1' 37 | } = appConfig || {} 38 | 39 | const cardStatus = { 40 | sysproxyCardStatus, 41 | tunCardStatus, 42 | profileCardStatus, 43 | proxyCardStatus, 44 | ruleCardStatus, 45 | resourceCardStatus, 46 | overrideCardStatus, 47 | connectionCardStatus, 48 | mihomoCoreCardStatus, 49 | dnsCardStatus, 50 | sniffCardStatus, 51 | logCardStatus, 52 | substoreCardStatus 53 | } 54 | 55 | return ( 56 | 57 | {Object.keys(cardStatus).map((key, index, array) => { 58 | return ( 59 | 60 | { 64 | patchAppConfig({ [key]: v as CardStatus }) 65 | }} 66 | > 67 | 68 | 69 | 隐藏 70 | 71 | 72 | ) 73 | })} 74 | 75 | ) 76 | } 77 | 78 | export default SiderConfig 79 | -------------------------------------------------------------------------------- /src/renderer/src/components/settings/webdav-restore-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react' 2 | import { relaunchApp, webdavDelete, webdavRestore } from '@renderer/utils/ipc' 3 | import React, { useState } from 'react' 4 | import { MdDeleteForever } from 'react-icons/md' 5 | interface Props { 6 | filenames: string[] 7 | onClose: () => void 8 | } 9 | const WebdavRestoreModal: React.FC = (props) => { 10 | const { filenames: names, onClose } = props 11 | const [filenames, setFilenames] = useState(names) 12 | const [restoring, setRestoring] = useState(false) 13 | 14 | return ( 15 | 23 | 24 | 恢复备份 25 | 26 | {filenames.length === 0 ? ( 27 |
还没有备份
28 | ) : ( 29 | filenames.map((filename) => ( 30 |
31 | 50 | 66 |
67 | )) 68 | )} 69 |
70 | 71 | 74 | 75 |
76 |
77 | ) 78 | } 79 | 80 | export default WebdavRestoreModal 81 | -------------------------------------------------------------------------------- /src/renderer/src/components/sider/config-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Modal, 3 | ModalContent, 4 | ModalHeader, 5 | ModalBody, 6 | ModalFooter, 7 | Button, 8 | Switch 9 | } from '@heroui/react' 10 | import React, { useEffect, useState, useCallback } from 'react' 11 | import { BaseEditor } from '../base/base-editor' 12 | import { 13 | getProfileConfig, 14 | getRawProfileStr, 15 | getRuntimeConfigStr, 16 | getCurrentProfileStr, 17 | getOverrideProfileStr 18 | } from '@renderer/utils/ipc' 19 | import useSWR from 'swr' 20 | 21 | interface Props { 22 | onClose: () => void 23 | } 24 | const ConfigViewer: React.FC = ({ onClose }) => { 25 | const [runtimeConfig, setRuntimeConfig] = useState('') 26 | const [rawProfile, setRawProfile] = useState('') 27 | const [profileConfig, setProfileConfig] = useState('') 28 | const [overrideConfig, setOverrideConfig] = useState('') 29 | const [isDiff, setIsDiff] = useState(false) 30 | const [isRaw, setIsRaw] = useState(false) 31 | const [isOverride, setIsOverride] = useState(false) 32 | const [sideBySide, setSideBySide] = useState(false) 33 | 34 | const { data: appConfig } = useSWR('getProfileConfig', getProfileConfig) 35 | 36 | const fetchConfigs = useCallback(async () => { 37 | setRuntimeConfig(await getRuntimeConfigStr()) 38 | setRawProfile(await getRawProfileStr()) 39 | setProfileConfig(await getCurrentProfileStr()) 40 | setOverrideConfig(await getOverrideProfileStr()) 41 | }, [appConfig]) 42 | 43 | useEffect(() => { 44 | fetchConfigs() 45 | }, [fetchConfigs]) 46 | 47 | return ( 48 | 60 | 61 | 当前运行时配置 62 | 63 | 78 | 79 | 80 |
81 | 82 | 对比当前配置 83 | 84 | 侧边显示 85 | { 89 | setIsRaw(value) 90 | if (value) { 91 | setIsOverride(false) 92 | } 93 | }} 94 | /> 95 | 显示原始文本 96 | { 100 | setIsOverride(value) 101 | if (value) { 102 | setIsRaw(false) 103 | } 104 | }} 105 | /> 106 | 显示覆写后文本 107 |
108 | 111 |
112 |
113 |
114 | ) 115 | } 116 | 117 | export default ConfigViewer -------------------------------------------------------------------------------- /src/renderer/src/components/sider/dns-card.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react' 2 | import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config' 3 | import BorderSwitch from '@renderer/components/base/border-swtich' 4 | import { LuServer } from 'react-icons/lu' 5 | import { useLocation, useNavigate } from 'react-router-dom' 6 | import { patchMihomoConfig } from '@renderer/utils/ipc' 7 | import { useSortable } from '@dnd-kit/sortable' 8 | import { CSS } from '@dnd-kit/utilities' 9 | import { useAppConfig } from '@renderer/hooks/use-app-config' 10 | import React from 'react' 11 | 12 | interface Props { 13 | iconOnly?: boolean 14 | } 15 | const DNSCard: React.FC = (props) => { 16 | const { appConfig } = useAppConfig() 17 | const { iconOnly } = props 18 | const { dnsCardStatus = 'col-span-1', controlDns = true } = appConfig || {} 19 | const location = useLocation() 20 | const navigate = useNavigate() 21 | const match = location.pathname.includes('/dns') 22 | const { controledMihomoConfig, patchControledMihomoConfig } = useControledMihomoConfig() 23 | const { dns, tun } = controledMihomoConfig || {} 24 | const { enable = true } = dns || {} 25 | const { 26 | attributes, 27 | listeners, 28 | setNodeRef, 29 | transform: tf, 30 | transition, 31 | isDragging 32 | } = useSortable({ 33 | id: 'dns' 34 | }) 35 | const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null 36 | const onChange = async (enable: boolean): Promise => { 37 | await patchControledMihomoConfig({ dns: { enable } }) 38 | await patchMihomoConfig({ dns: { enable } }) 39 | } 40 | 41 | if (iconOnly) { 42 | return ( 43 |
44 | 45 | 56 | 57 |
58 | ) 59 | } 60 | 61 | return ( 62 |
71 | 78 | 79 |
80 | 90 | 96 |
97 |
98 | 99 |

102 | DNS 103 |

104 |
105 |
106 |
107 | ) 108 | } 109 | 110 | export default DNSCard 111 | -------------------------------------------------------------------------------- /src/renderer/src/components/sider/log-card.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react' 2 | import { IoJournalOutline } from 'react-icons/io5' 3 | import { useLocation, useNavigate } from 'react-router-dom' 4 | import { useSortable } from '@dnd-kit/sortable' 5 | import { CSS } from '@dnd-kit/utilities' 6 | import { useAppConfig } from '@renderer/hooks/use-app-config' 7 | import React from 'react' 8 | 9 | interface Props { 10 | iconOnly?: boolean 11 | } 12 | 13 | const LogCard: React.FC = (props) => { 14 | const { appConfig } = useAppConfig() 15 | const { iconOnly } = props 16 | const { logCardStatus = 'col-span-1' } = appConfig || {} 17 | const location = useLocation() 18 | const navigate = useNavigate() 19 | const match = location.pathname.includes('/logs') 20 | const { 21 | attributes, 22 | listeners, 23 | setNodeRef, 24 | transform: tf, 25 | transition, 26 | isDragging 27 | } = useSortable({ 28 | id: 'log' 29 | }) 30 | const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null 31 | 32 | if (iconOnly) { 33 | return ( 34 |
35 | 36 | 47 | 48 |
49 | ) 50 | } 51 | return ( 52 |
61 | 68 | 69 |
70 | 81 |
82 |
83 | 84 |

87 | 日志 88 |

89 |
90 |
91 |
92 | ) 93 | } 94 | 95 | export default LogCard 96 | -------------------------------------------------------------------------------- /src/renderer/src/components/sider/outbound-mode-switcher.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs, Tab } from '@heroui/react' 2 | import { useAppConfig } from '@renderer/hooks/use-app-config' 3 | import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config' 4 | import { useGroups } from '@renderer/hooks/use-groups' 5 | import { mihomoCloseAllConnections, patchMihomoConfig } from '@renderer/utils/ipc' 6 | import { Key } from 'react' 7 | 8 | interface Props { 9 | iconOnly?: boolean 10 | } 11 | 12 | const OutboundModeSwitcher: React.FC = (props) => { 13 | const { iconOnly } = props 14 | const { controledMihomoConfig, patchControledMihomoConfig } = useControledMihomoConfig() 15 | const { mutate: mutateGroups } = useGroups() 16 | const { appConfig } = useAppConfig() 17 | const { autoCloseConnection = true } = appConfig || {} 18 | const { mode } = controledMihomoConfig || {} 19 | 20 | const onChangeMode = async (mode: OutboundMode): Promise => { 21 | await patchControledMihomoConfig({ mode }) 22 | await patchMihomoConfig({ mode }) 23 | if (autoCloseConnection) { 24 | await mihomoCloseAllConnections() 25 | } 26 | mutateGroups() 27 | window.electron.ipcRenderer.send('updateTrayMenu') 28 | } 29 | if (!mode) return null 30 | if (iconOnly) { 31 | return ( 32 | onChangeMode(key as OutboundMode)} 39 | > 40 | 41 | 42 | 43 | 44 | ) 45 | } 46 | return ( 47 | onChangeMode(key as OutboundMode)} 55 | > 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | 63 | export default OutboundModeSwitcher 64 | -------------------------------------------------------------------------------- /src/renderer/src/components/sider/override-card.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react' 2 | import React from 'react' 3 | import { MdFormatOverline } from 'react-icons/md' 4 | import { useLocation, useNavigate } from 'react-router-dom' 5 | import { useSortable } from '@dnd-kit/sortable' 6 | import { CSS } from '@dnd-kit/utilities' 7 | import { useAppConfig } from '@renderer/hooks/use-app-config' 8 | 9 | interface Props { 10 | iconOnly?: boolean 11 | } 12 | 13 | const OverrideCard: React.FC = (props) => { 14 | const { appConfig } = useAppConfig() 15 | const { iconOnly } = props 16 | const { overrideCardStatus = 'col-span-1' } = appConfig || {} 17 | const location = useLocation() 18 | const navigate = useNavigate() 19 | const match = location.pathname.includes('/override') 20 | const { 21 | attributes, 22 | listeners, 23 | setNodeRef, 24 | transform: tf, 25 | transition, 26 | isDragging 27 | } = useSortable({ 28 | id: 'override' 29 | }) 30 | const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null 31 | if (iconOnly) { 32 | return ( 33 |
34 | 35 | 46 | 47 |
48 | ) 49 | } 50 | return ( 51 |
60 | 67 | 68 |
69 | 80 |
81 |
82 | 83 |

86 | 覆写 87 |

88 |
89 |
90 |
91 | ) 92 | } 93 | 94 | export default OverrideCard 95 | -------------------------------------------------------------------------------- /src/renderer/src/components/sider/proxy-card.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, CardBody, CardFooter, Chip, Tooltip } from '@heroui/react' 2 | import { useSortable } from '@dnd-kit/sortable' 3 | import { CSS } from '@dnd-kit/utilities' 4 | import { LuGroup } from 'react-icons/lu' 5 | import { useLocation, useNavigate } from 'react-router-dom' 6 | import { useGroups } from '@renderer/hooks/use-groups' 7 | import { useAppConfig } from '@renderer/hooks/use-app-config' 8 | import React from 'react' 9 | 10 | interface Props { 11 | iconOnly?: boolean 12 | } 13 | 14 | const ProxyCard: React.FC = (props) => { 15 | const { appConfig } = useAppConfig() 16 | const { iconOnly } = props 17 | const { proxyCardStatus = 'col-span-1' } = appConfig || {} 18 | const location = useLocation() 19 | const navigate = useNavigate() 20 | const match = location.pathname.includes('/proxies') 21 | const { groups = [] } = useGroups() 22 | const { 23 | attributes, 24 | listeners, 25 | setNodeRef, 26 | transform: tf, 27 | transition, 28 | isDragging 29 | } = useSortable({ 30 | id: 'proxy' 31 | }) 32 | const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null 33 | 34 | if (iconOnly) { 35 | return ( 36 |
37 | 38 | 49 | 50 |
51 | ) 52 | } 53 | return ( 54 |
63 | 70 | 71 |
72 | 82 | 98 | {groups.length} 99 | 100 |
101 |
102 | 103 |

106 | 代理组 107 |

108 |
109 |
110 |
111 | ) 112 | } 113 | 114 | export default ProxyCard 115 | -------------------------------------------------------------------------------- /src/renderer/src/components/sider/resource-card.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react' 2 | import React from 'react' 3 | import { useLocation, useNavigate } from 'react-router-dom' 4 | import { useSortable } from '@dnd-kit/sortable' 5 | import { CSS } from '@dnd-kit/utilities' 6 | import { IoLayersOutline } from 'react-icons/io5' 7 | import { useAppConfig } from '@renderer/hooks/use-app-config' 8 | 9 | interface Props { 10 | iconOnly?: boolean 11 | } 12 | 13 | const ResourceCard: React.FC = (props) => { 14 | const { appConfig } = useAppConfig() 15 | const { iconOnly } = props 16 | const { resourceCardStatus = 'col-span-1' } = appConfig || {} 17 | const location = useLocation() 18 | const navigate = useNavigate() 19 | const match = location.pathname.includes('/resources') 20 | const { 21 | attributes, 22 | listeners, 23 | setNodeRef, 24 | transform: tf, 25 | transition, 26 | isDragging 27 | } = useSortable({ 28 | id: 'resource' 29 | }) 30 | const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null 31 | 32 | if (iconOnly) { 33 | return ( 34 |
35 | 36 | 47 | 48 |
49 | ) 50 | } 51 | return ( 52 |
61 | 68 | 69 |
70 | 81 |
82 |
83 | 84 |

87 | 外部资源 88 |

89 |
90 |
91 |
92 | ) 93 | } 94 | 95 | export default ResourceCard 96 | -------------------------------------------------------------------------------- /src/renderer/src/components/sider/rule-card.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, CardBody, CardFooter, Chip, Tooltip } from '@heroui/react' 2 | import { MdOutlineAltRoute } from 'react-icons/md' 3 | import { useLocation, useNavigate } from 'react-router-dom' 4 | import { useSortable } from '@dnd-kit/sortable' 5 | import { CSS } from '@dnd-kit/utilities' 6 | import { useRules } from '@renderer/hooks/use-rules' 7 | import { useAppConfig } from '@renderer/hooks/use-app-config' 8 | import React from 'react' 9 | 10 | interface Props { 11 | iconOnly?: boolean 12 | } 13 | 14 | const RuleCard: React.FC = (props) => { 15 | const { appConfig } = useAppConfig() 16 | const { iconOnly } = props 17 | const { ruleCardStatus = 'col-span-1' } = appConfig || {} 18 | const location = useLocation() 19 | const navigate = useNavigate() 20 | const match = location.pathname.includes('/rules') 21 | const { rules } = useRules() 22 | const { 23 | attributes, 24 | listeners, 25 | setNodeRef, 26 | transform: tf, 27 | transition, 28 | isDragging 29 | } = useSortable({ 30 | id: 'rule' 31 | }) 32 | const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null 33 | 34 | if (iconOnly) { 35 | return ( 36 |
37 | 38 | 49 | 50 |
51 | ) 52 | } 53 | return ( 54 |
63 | 70 | 71 |
72 | 83 | 99 | {rules?.rules?.length ?? 0} 100 | 101 |
102 |
103 | 104 |

107 | 规则 108 |

109 |
110 |
111 |
112 | ) 113 | } 114 | 115 | export default RuleCard 116 | -------------------------------------------------------------------------------- /src/renderer/src/components/sider/sniff-card.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react' 2 | import BorderSwitch from '@renderer/components/base/border-swtich' 3 | import { RiScan2Fill } from 'react-icons/ri' 4 | import { useLocation, useNavigate } from 'react-router-dom' 5 | import { patchMihomoConfig } from '@renderer/utils/ipc' 6 | import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config' 7 | import { useSortable } from '@dnd-kit/sortable' 8 | import { CSS } from '@dnd-kit/utilities' 9 | import { useAppConfig } from '@renderer/hooks/use-app-config' 10 | import React from 'react' 11 | 12 | interface Props { 13 | iconOnly?: boolean 14 | } 15 | const SniffCard: React.FC = (props) => { 16 | const { appConfig } = useAppConfig() 17 | const { iconOnly } = props 18 | const { sniffCardStatus = 'col-span-1', controlSniff = true } = appConfig || {} 19 | const location = useLocation() 20 | const navigate = useNavigate() 21 | const match = location.pathname.includes('/sniffer') 22 | const { controledMihomoConfig, patchControledMihomoConfig } = useControledMihomoConfig() 23 | const { sniffer } = controledMihomoConfig || {} 24 | const { enable } = sniffer || {} 25 | const { 26 | attributes, 27 | listeners, 28 | setNodeRef, 29 | transform: tf, 30 | transition, 31 | isDragging 32 | } = useSortable({ 33 | id: 'sniff' 34 | }) 35 | const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null 36 | const onChange = async (enable: boolean): Promise => { 37 | await patchControledMihomoConfig({ sniffer: { enable } }) 38 | await patchMihomoConfig({ sniffer: { enable } }) 39 | } 40 | 41 | if (iconOnly) { 42 | return ( 43 |
44 | 45 | 56 | 57 |
58 | ) 59 | } 60 | 61 | return ( 62 |
71 | 78 | 79 |
80 | 91 | 96 |
97 |
98 | 99 |

102 | 域名嗅探 103 |

104 |
105 |
106 |
107 | ) 108 | } 109 | 110 | export default SniffCard 111 | -------------------------------------------------------------------------------- /src/renderer/src/components/sider/substore-card.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react' 2 | import { useLocation, useNavigate } from 'react-router-dom' 3 | import { useSortable } from '@dnd-kit/sortable' 4 | import { CSS } from '@dnd-kit/utilities' 5 | import SubStoreIcon from '../base/substore-icon' 6 | import { useAppConfig } from '@renderer/hooks/use-app-config' 7 | import React from 'react' 8 | 9 | interface Props { 10 | iconOnly?: boolean 11 | } 12 | 13 | const SubStoreCard: React.FC = (props) => { 14 | const { appConfig } = useAppConfig() 15 | const { iconOnly } = props 16 | const { substoreCardStatus = 'col-span-1', useSubStore = true } = appConfig || {} 17 | const location = useLocation() 18 | const navigate = useNavigate() 19 | const match = location.pathname.includes('/substore') 20 | const { 21 | attributes, 22 | listeners, 23 | setNodeRef, 24 | transform: tf, 25 | transition, 26 | isDragging 27 | } = useSortable({ 28 | id: 'substore' 29 | }) 30 | const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null 31 | 32 | if (iconOnly) { 33 | return ( 34 |
35 | 36 | 47 | 48 |
49 | ) 50 | } 51 | 52 | return ( 53 |
62 | 69 | 70 |
71 | 81 |
82 |
83 | 84 |

87 | Sub-Store 88 |

89 |
90 |
91 |
92 | ) 93 | } 94 | 95 | export default SubStoreCard 96 | -------------------------------------------------------------------------------- /src/renderer/src/components/sysproxy/bypass-editor-modal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import yaml from 'js-yaml' 3 | import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from "@heroui/react" 4 | import { BaseEditor } from "../base/base-editor" 5 | 6 | interface Props { 7 | bypass: string[] 8 | onCancel: () => void 9 | onConfirm: (bypass: string[]) => void 10 | } 11 | 12 | const ByPassEditorModal: React.FC = ({ bypass, onCancel, onConfirm }) => { 13 | const [currData, setCurrData] = useState('') 14 | useEffect(() => { 15 | setCurrData(yaml.dump({ bypass })) 16 | }, [bypass]) 17 | const handleConfirm = () => { 18 | try { 19 | const parsed = yaml.load(currData) 20 | if (parsed && Array.isArray(parsed.bypass)) { 21 | onConfirm(parsed.bypass) 22 | } else { 23 | alert('YAML 格式错误') 24 | } 25 | } catch (e) { 26 | alert('YAML 解析失败: ' + e) 27 | } 28 | } 29 | 30 | return ( 31 | 43 | 44 | 编辑绕过列表 (YAML) 45 | 46 | setCurrData(value || '')} 50 | /> 51 | 52 | 53 | 56 | 59 | 60 | 61 | 62 | ) 63 | } 64 | 65 | export default ByPassEditorModal -------------------------------------------------------------------------------- /src/renderer/src/components/sysproxy/pac-editor-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react' 2 | import { BaseEditor } from '@renderer/components/base/base-editor' 3 | import React, { useState } from 'react' 4 | interface Props { 5 | script: string 6 | onCancel: () => void 7 | onConfirm: (script: string) => void 8 | } 9 | const PacEditorModal: React.FC = (props) => { 10 | const { script, onCancel, onConfirm } = props 11 | const [currData, setCurrData] = useState(script) 12 | 13 | return ( 14 | 26 | 27 | 编辑PAC脚本 28 | 29 | setCurrData(value || '')} 33 | /> 34 | 35 | 36 | 39 | 42 | 43 | 44 | 45 | ) 46 | } 47 | 48 | export default PacEditorModal 49 | -------------------------------------------------------------------------------- /src/renderer/src/components/updater/updater-button.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@heroui/react' 2 | import React, { useState } from 'react' 3 | import UpdaterModal from './updater-modal' 4 | import { GrUpgrade } from 'react-icons/gr' 5 | 6 | interface Props { 7 | iconOnly?: boolean 8 | latest?: { 9 | version: string 10 | changelog: string 11 | } 12 | } 13 | 14 | const UpdaterButton: React.FC = (props) => { 15 | const { iconOnly, latest } = props 16 | const [openModal, setOpenModal] = useState(false) 17 | 18 | if (!latest) return null 19 | 20 | return ( 21 | <> 22 | {openModal && ( 23 | { 27 | setOpenModal(false) 28 | }} 29 | /> 30 | )} 31 | {iconOnly ? ( 32 | 43 | ) : ( 44 | 55 | )} 56 | 57 | ) 58 | } 59 | 60 | export default UpdaterButton 61 | -------------------------------------------------------------------------------- /src/renderer/src/components/updater/updater-modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Modal, 3 | ModalContent, 4 | ModalHeader, 5 | ModalBody, 6 | ModalFooter, 7 | Button, 8 | Code 9 | } from '@heroui/react' 10 | import ReactMarkdown from 'react-markdown' 11 | import React, { useState } from 'react' 12 | import { downloadAndInstallUpdate } from '@renderer/utils/ipc' 13 | 14 | interface Props { 15 | version: string 16 | changelog: string 17 | onClose: () => void 18 | } 19 | const UpdaterModal: React.FC = (props) => { 20 | const { version, changelog, onClose } = props 21 | const [downloading, setDownloading] = useState(false) 22 | const onUpdate = async (): Promise => { 23 | try { 24 | await downloadAndInstallUpdate(version) 25 | } catch (e) { 26 | alert(e) 27 | } 28 | } 29 | 30 | return ( 31 | 39 | 40 | 41 |
{version} 版本就绪
42 | 56 |
57 | 58 | 70 | 71 | 72 | 75 | 93 | 94 |
95 |
96 | ) 97 | } 98 | 99 | export default UpdaterModal 100 | -------------------------------------------------------------------------------- /src/renderer/src/floating.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { ThemeProvider as NextThemesProvider } from 'next-themes' 4 | import { HeroUIProvider } from '@heroui/react' 5 | import '@renderer/assets/floating.css' 6 | import FloatingApp from '@renderer/FloatingApp' 7 | import BaseErrorBoundary from './components/base/base-error-boundary' 8 | import { AppConfigProvider } from './hooks/use-app-config' 9 | import { ControledMihomoConfigProvider } from './hooks/use-controled-mihomo-config' 10 | 11 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ) 26 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/use-app-config.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, ReactNode } from 'react' 2 | import useSWR from 'swr' 3 | import { getAppConfig, patchAppConfig as patch } from '@renderer/utils/ipc' 4 | 5 | interface AppConfigContextType { 6 | appConfig: IAppConfig | undefined 7 | mutateAppConfig: () => void 8 | patchAppConfig: (value: Partial) => Promise 9 | } 10 | 11 | const AppConfigContext = createContext(undefined) 12 | 13 | export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 14 | const { data: appConfig, mutate: mutateAppConfig } = useSWR('getConfig', () => getAppConfig()) 15 | 16 | const patchAppConfig = async (value: Partial): Promise => { 17 | try { 18 | await patch(value) 19 | } catch (e) { 20 | alert(e) 21 | } finally { 22 | mutateAppConfig() 23 | } 24 | } 25 | 26 | React.useEffect(() => { 27 | window.electron.ipcRenderer.on('appConfigUpdated', () => { 28 | mutateAppConfig() 29 | }) 30 | return (): void => { 31 | window.electron.ipcRenderer.removeAllListeners('appConfigUpdated') 32 | } 33 | }, []) 34 | 35 | return ( 36 | 37 | {children} 38 | 39 | ) 40 | } 41 | 42 | export const useAppConfig = (): AppConfigContextType => { 43 | const context = useContext(AppConfigContext) 44 | if (context === undefined) { 45 | throw new Error('useAppConfig must be used within an AppConfigProvider') 46 | } 47 | return context 48 | } 49 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/use-controled-mihomo-config.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, ReactNode } from 'react' 2 | import useSWR from 'swr' 3 | import { getControledMihomoConfig, patchControledMihomoConfig as patch } from '@renderer/utils/ipc' 4 | 5 | interface ControledMihomoConfigContextType { 6 | controledMihomoConfig: Partial | undefined 7 | mutateControledMihomoConfig: () => void 8 | patchControledMihomoConfig: (value: Partial) => Promise 9 | } 10 | 11 | const ControledMihomoConfigContext = createContext( 12 | undefined 13 | ) 14 | 15 | export const ControledMihomoConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 16 | const { data: controledMihomoConfig, mutate: mutateControledMihomoConfig } = useSWR( 17 | 'getControledMihomoConfig', 18 | () => getControledMihomoConfig() 19 | ) 20 | 21 | const patchControledMihomoConfig = async (value: Partial): Promise => { 22 | try { 23 | await patch(value) 24 | } catch (e) { 25 | alert(e) 26 | } finally { 27 | mutateControledMihomoConfig() 28 | } 29 | } 30 | 31 | React.useEffect(() => { 32 | window.electron.ipcRenderer.on('controledMihomoConfigUpdated', () => { 33 | mutateControledMihomoConfig() 34 | }) 35 | return (): void => { 36 | window.electron.ipcRenderer.removeAllListeners('controledMihomoConfigUpdated') 37 | } 38 | }, []) 39 | 40 | return ( 41 | 44 | {children} 45 | 46 | ) 47 | } 48 | 49 | export const useControledMihomoConfig = (): ControledMihomoConfigContextType => { 50 | const context = useContext(ControledMihomoConfigContext) 51 | if (context === undefined) { 52 | throw new Error('useControledMihomoConfig must be used within a ControledMihomoConfigProvider') 53 | } 54 | return context 55 | } 56 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/use-groups.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, ReactNode } from 'react' 2 | import useSWR from 'swr' 3 | import { mihomoGroups } from '@renderer/utils/ipc' 4 | 5 | interface GroupsContextType { 6 | groups: IMihomoMixedGroup[] | undefined 7 | mutate: () => void 8 | } 9 | 10 | const GroupsContext = createContext(undefined) 11 | 12 | export const GroupsProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 13 | const { data: groups, mutate } = useSWR('mihomoGroups', mihomoGroups, { 14 | errorRetryInterval: 200, 15 | errorRetryCount: 10 16 | }) 17 | 18 | React.useEffect(() => { 19 | window.electron.ipcRenderer.on('groupsUpdated', () => { 20 | mutate() 21 | }) 22 | return (): void => { 23 | window.electron.ipcRenderer.removeAllListeners('groupsUpdated') 24 | } 25 | }, []) 26 | 27 | return {children} 28 | } 29 | 30 | export const useGroups = (): GroupsContextType => { 31 | const context = useContext(GroupsContext) 32 | if (context === undefined) { 33 | throw new Error('useGroups must be used within an GroupsProvider') 34 | } 35 | return context 36 | } 37 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/use-override-config.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, ReactNode } from 'react' 2 | import useSWR from 'swr' 3 | import { 4 | getOverrideConfig, 5 | setOverrideConfig as set, 6 | addOverrideItem as add, 7 | removeOverrideItem as remove, 8 | updateOverrideItem as update 9 | } from '@renderer/utils/ipc' 10 | 11 | interface OverrideConfigContextType { 12 | overrideConfig: IOverrideConfig | undefined 13 | setOverrideConfig: (config: IOverrideConfig) => Promise 14 | mutateOverrideConfig: () => void 15 | addOverrideItem: (item: Partial) => Promise 16 | updateOverrideItem: (item: IOverrideItem) => Promise 17 | removeOverrideItem: (id: string) => Promise 18 | } 19 | 20 | const OverrideConfigContext = createContext(undefined) 21 | 22 | export const OverrideConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 23 | const { data: overrideConfig, mutate: mutateOverrideConfig } = useSWR('getOverrideConfig', () => 24 | getOverrideConfig() 25 | ) 26 | 27 | const setOverrideConfig = async (config: IOverrideConfig): Promise => { 28 | try { 29 | await set(config) 30 | } catch (e) { 31 | alert(e) 32 | } finally { 33 | mutateOverrideConfig() 34 | } 35 | } 36 | 37 | const addOverrideItem = async (item: Partial): Promise => { 38 | try { 39 | await add(item) 40 | } catch (e) { 41 | alert(e) 42 | } finally { 43 | mutateOverrideConfig() 44 | } 45 | } 46 | 47 | const removeOverrideItem = async (id: string): Promise => { 48 | try { 49 | await remove(id) 50 | } catch (e) { 51 | alert(e) 52 | } finally { 53 | mutateOverrideConfig() 54 | } 55 | } 56 | 57 | const updateOverrideItem = async (item: IOverrideItem): Promise => { 58 | try { 59 | await update(item) 60 | } catch (e) { 61 | alert(e) 62 | } finally { 63 | mutateOverrideConfig() 64 | } 65 | } 66 | 67 | return ( 68 | 78 | {children} 79 | 80 | ) 81 | } 82 | 83 | export const useOverrideConfig = (): OverrideConfigContextType => { 84 | const context = useContext(OverrideConfigContext) 85 | if (context === undefined) { 86 | throw new Error('useOverrideConfig must be used within an OverrideConfigProvider') 87 | } 88 | return context 89 | } 90 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/use-profile-config.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, ReactNode } from 'react' 2 | import useSWR from 'swr' 3 | import { 4 | getProfileConfig, 5 | setProfileConfig as set, 6 | addProfileItem as add, 7 | removeProfileItem as remove, 8 | updateProfileItem as update, 9 | changeCurrentProfile as change 10 | } from '@renderer/utils/ipc' 11 | 12 | interface ProfileConfigContextType { 13 | profileConfig: IProfileConfig | undefined 14 | setProfileConfig: (config: IProfileConfig) => Promise 15 | mutateProfileConfig: () => void 16 | addProfileItem: (item: Partial) => Promise 17 | updateProfileItem: (item: IProfileItem) => Promise 18 | removeProfileItem: (id: string) => Promise 19 | changeCurrentProfile: (id: string) => Promise 20 | } 21 | 22 | const ProfileConfigContext = createContext(undefined) 23 | 24 | export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 25 | const { data: profileConfig, mutate: mutateProfileConfig } = useSWR('getProfileConfig', () => 26 | getProfileConfig() 27 | ) 28 | 29 | const setProfileConfig = async (config: IProfileConfig): Promise => { 30 | try { 31 | await set(config) 32 | } catch (e) { 33 | alert(e) 34 | } finally { 35 | mutateProfileConfig() 36 | window.electron.ipcRenderer.send('updateTrayMenu') 37 | } 38 | } 39 | 40 | const addProfileItem = async (item: Partial): Promise => { 41 | try { 42 | await add(item) 43 | } catch (e) { 44 | alert(e) 45 | } finally { 46 | mutateProfileConfig() 47 | window.electron.ipcRenderer.send('updateTrayMenu') 48 | } 49 | } 50 | 51 | const removeProfileItem = async (id: string): Promise => { 52 | try { 53 | await remove(id) 54 | } catch (e) { 55 | alert(e) 56 | } finally { 57 | mutateProfileConfig() 58 | window.electron.ipcRenderer.send('updateTrayMenu') 59 | } 60 | } 61 | 62 | const updateProfileItem = async (item: IProfileItem): Promise => { 63 | try { 64 | await update(item) 65 | } catch (e) { 66 | alert(e) 67 | } finally { 68 | mutateProfileConfig() 69 | window.electron.ipcRenderer.send('updateTrayMenu') 70 | } 71 | } 72 | 73 | const changeCurrentProfile = async (id: string): Promise => { 74 | try { 75 | await change(id) 76 | } catch (e) { 77 | alert(e) 78 | } finally { 79 | mutateProfileConfig() 80 | window.electron.ipcRenderer.send('updateTrayMenu') 81 | } 82 | } 83 | 84 | React.useEffect(() => { 85 | window.electron.ipcRenderer.on('profileConfigUpdated', () => { 86 | mutateProfileConfig() 87 | }) 88 | return (): void => { 89 | window.electron.ipcRenderer.removeAllListeners('profileConfigUpdated') 90 | } 91 | }, []) 92 | 93 | return ( 94 | 105 | {children} 106 | 107 | ) 108 | } 109 | 110 | export const useProfileConfig = (): ProfileConfigContextType => { 111 | const context = useContext(ProfileConfigContext) 112 | if (context === undefined) { 113 | throw new Error('useProfileConfig must be used within a ProfileConfigProvider') 114 | } 115 | return context 116 | } 117 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/use-rules.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, ReactNode } from 'react' 2 | import useSWR from 'swr' 3 | import { mihomoRules } from '@renderer/utils/ipc' 4 | 5 | interface RulesContextType { 6 | rules: IMihomoRulesInfo | undefined 7 | mutate: () => void 8 | } 9 | 10 | const RulesContext = createContext(undefined) 11 | 12 | export const RulesProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 13 | const { data: rules, mutate } = useSWR('mihomoRules', mihomoRules, { 14 | errorRetryInterval: 200, 15 | errorRetryCount: 10 16 | }) 17 | 18 | React.useEffect(() => { 19 | window.electron.ipcRenderer.on('rulesUpdated', () => { 20 | mutate() 21 | }) 22 | return (): void => { 23 | window.electron.ipcRenderer.removeAllListeners('rulesUpdated') 24 | } 25 | }, []) 26 | 27 | return {children} 28 | } 29 | 30 | export const useRules = (): RulesContextType => { 31 | const context = useContext(RulesContext) 32 | if (context === undefined) { 33 | throw new Error('useRules must be used within an RulesProvider') 34 | } 35 | return context 36 | } 37 | -------------------------------------------------------------------------------- /src/renderer/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { HashRouter } from 'react-router-dom' 4 | import { ThemeProvider as NextThemesProvider } from 'next-themes' 5 | import { HeroUIProvider } from '@heroui/react' 6 | import { init, platform } from '@renderer/utils/init' 7 | import '@renderer/assets/main.css' 8 | import App from '@renderer/App' 9 | import BaseErrorBoundary from './components/base/base-error-boundary' 10 | import { openDevTools, quitApp } from './utils/ipc' 11 | import { AppConfigProvider } from './hooks/use-app-config' 12 | import { ControledMihomoConfigProvider } from './hooks/use-controled-mihomo-config' 13 | import { OverrideConfigProvider } from './hooks/use-override-config' 14 | import { ProfileConfigProvider } from './hooks/use-profile-config' 15 | import { RulesProvider } from './hooks/use-rules' 16 | import { GroupsProvider } from './hooks/use-groups' 17 | 18 | let F12Count = 0 19 | 20 | init().then(() => { 21 | document.addEventListener('keydown', (e) => { 22 | if (platform !== 'darwin' && e.ctrlKey && e.key === 'q') { 23 | e.preventDefault() 24 | quitApp() 25 | } 26 | if (platform === 'darwin' && e.metaKey && e.key === 'q') { 27 | e.preventDefault() 28 | quitApp() 29 | } 30 | if (e.key === 'Escape') { 31 | e.preventDefault() 32 | window.close() 33 | } 34 | if (e.key === 'F12') { 35 | e.preventDefault() 36 | F12Count++ 37 | if (F12Count >= 5) { 38 | openDevTools() 39 | F12Count = 0 40 | } 41 | } 42 | }) 43 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | ) 68 | }) 69 | -------------------------------------------------------------------------------- /src/renderer/src/pages/logs.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from '@renderer/components/base/base-page' 2 | import LogItem from '@renderer/components/logs/log-item' 3 | import { useEffect, useMemo, useRef, useState } from 'react' 4 | import { Button, Divider, Input } from '@heroui/react' 5 | import { Virtuoso, VirtuosoHandle } from 'react-virtuoso' 6 | import { IoLocationSharp } from 'react-icons/io5' 7 | import { CgTrash } from 'react-icons/cg' 8 | 9 | import { includesIgnoreCase } from '@renderer/utils/includes' 10 | 11 | const cachedLogs: { 12 | log: IMihomoLogInfo[] 13 | trigger: ((i: IMihomoLogInfo[]) => void) | null 14 | clean: () => void 15 | } = { 16 | log: [], 17 | trigger: null, 18 | clean(): void { 19 | this.log = [] 20 | if (this.trigger !== null) { 21 | this.trigger(this.log) 22 | } 23 | } 24 | } 25 | 26 | window.electron.ipcRenderer.on('mihomoLogs', (_e, log: IMihomoLogInfo) => { 27 | log.time = new Date().toLocaleString() 28 | cachedLogs.log.push(log) 29 | if (cachedLogs.log.length >= 500) { 30 | cachedLogs.log.shift() 31 | } 32 | if (cachedLogs.trigger !== null) { 33 | cachedLogs.trigger(cachedLogs.log) 34 | } 35 | }) 36 | 37 | const Logs: React.FC = () => { 38 | const [logs, setLogs] = useState(cachedLogs.log) 39 | const [filter, setFilter] = useState('') 40 | const [trace, setTrace] = useState(true) 41 | 42 | const virtuosoRef = useRef(null) 43 | const filteredLogs = useMemo(() => { 44 | if (filter === '') return logs 45 | return logs.filter((log) => { 46 | return includesIgnoreCase(log.payload, filter) || includesIgnoreCase(log.type, filter) 47 | }) 48 | }, [logs, filter]) 49 | 50 | useEffect(() => { 51 | if (!trace) return 52 | virtuosoRef.current?.scrollToIndex({ 53 | index: filteredLogs.length - 1, 54 | behavior: 'smooth', 55 | align: 'end', 56 | offset: 0 57 | }) 58 | }, [filteredLogs, trace]) 59 | 60 | useEffect(() => { 61 | const old = cachedLogs.trigger 62 | cachedLogs.trigger = (a): void => { 63 | setLogs([...a]) 64 | } 65 | return (): void => { 66 | cachedLogs.trigger = old 67 | } 68 | }, []) 69 | 70 | return ( 71 | 72 |
73 |
74 | 81 | 93 | 106 |
107 | 108 |
109 |
110 | { 116 | return ( 117 | 124 | ) 125 | }} 126 | /> 127 |
128 |
129 | ) 130 | } 131 | 132 | export default Logs 133 | -------------------------------------------------------------------------------- /src/renderer/src/pages/resources.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from '@renderer/components/base/base-page' 2 | import GeoData from '@renderer/components/resources/geo-data' 3 | import ProxyProvider from '@renderer/components/resources/proxy-provider' 4 | import RuleProvider from '@renderer/components/resources/rule-provider' 5 | const Resources: React.FC = () => { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | ) 13 | } 14 | 15 | export default Resources 16 | -------------------------------------------------------------------------------- /src/renderer/src/pages/rules.tsx: -------------------------------------------------------------------------------- 1 | import BasePage from '@renderer/components/base/base-page' 2 | import RuleItem from '@renderer/components/rules/rule-item' 3 | import { Virtuoso } from 'react-virtuoso' 4 | import { useMemo, useState } from 'react' 5 | import { Divider, Input } from '@heroui/react' 6 | import { useRules } from '@renderer/hooks/use-rules' 7 | import { includesIgnoreCase } from '@renderer/utils/includes' 8 | 9 | const Rules: React.FC = () => { 10 | const { rules } = useRules() 11 | const [filter, setFilter] = useState('') 12 | 13 | const filteredRules = useMemo(() => { 14 | if (!rules) return [] 15 | if (filter === '') return rules.rules 16 | return rules.rules.filter((rule) => { 17 | return ( 18 | includesIgnoreCase(rule.payload, filter) || 19 | includesIgnoreCase(rule.type, filter) || 20 | includesIgnoreCase(rule.proxy, filter) 21 | ) 22 | }) 23 | }, [rules, filter]) 24 | 25 | return ( 26 | 27 |
28 |
29 | 36 |
37 | 38 |
39 |
40 | ( 43 | 50 | )} 51 | /> 52 |
53 |
54 | ) 55 | } 56 | 57 | export default Rules 58 | -------------------------------------------------------------------------------- /src/renderer/src/pages/settings.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@heroui/react' 2 | import BasePage from '@renderer/components/base/base-page' 3 | // import { CgWebsite } from 'react-icons/cg' 4 | import { IoLogoGithub } from 'react-icons/io5' 5 | import WebdavConfig from '@renderer/components/settings/webdav-config' 6 | import GeneralConfig from '@renderer/components/settings/general-config' 7 | import MihomoConfig from '@renderer/components/settings/mihomo-config' 8 | import Actions from '@renderer/components/settings/actions' 9 | import ShortcutConfig from '@renderer/components/settings/shortcut-config' 10 | import { FaTelegramPlane } from 'react-icons/fa' 11 | import SiderConfig from '@renderer/components/settings/sider-config' 12 | import SubStoreConfig from '@renderer/components/settings/substore-config' 13 | import AppearanceConfig from '@renderer/components/settings/appearance-confis' 14 | 15 | const Settings: React.FC = () => { 16 | return ( 17 | 21 | {/* */} 33 | 45 | 57 | 58 | } 59 | > 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | ) 70 | } 71 | 72 | export default Settings 73 | -------------------------------------------------------------------------------- /src/renderer/src/pages/substore.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@heroui/react' 2 | import BasePage from '@renderer/components/base/base-page' 3 | import { useAppConfig } from '@renderer/hooks/use-app-config' 4 | import { 5 | subStoreFrontendPort, 6 | subStorePort, 7 | startSubStoreFrontendServer, 8 | startSubStoreBackendServer, 9 | stopSubStoreFrontendServer, 10 | stopSubStoreBackendServer, 11 | downloadSubStore 12 | } from '@renderer/utils/ipc' 13 | import React, { useEffect, useState } from 'react' 14 | import { HiExternalLink } from 'react-icons/hi' 15 | import { IoMdCloudDownload } from 'react-icons/io' 16 | 17 | const SubStore: React.FC = () => { 18 | const { appConfig } = useAppConfig() 19 | const { useCustomSubStore, customSubStoreUrl } = appConfig || {} 20 | const [backendPort, setBackendPort] = useState() 21 | const [frontendPort, setFrontendPort] = useState() 22 | const [isUpdating, setIsUpdating] = useState(false) 23 | const getPort = async (): Promise => { 24 | setBackendPort(await subStorePort()) 25 | setFrontendPort(await subStoreFrontendPort()) 26 | } 27 | useEffect(() => { 28 | getPort() 29 | }, [useCustomSubStore]) 30 | 31 | if (!useCustomSubStore && !backendPort) return null 32 | if (!frontendPort) return null 33 | return ( 34 | <> 35 | 39 | 68 | 82 | 83 | } 84 | > 85 |