Compare commits

..

No commits in common. "main" and "v1.3.2" have entirely different histories.
main ... v1.3.2

145 changed files with 7024 additions and 13430 deletions

View File

@ -9,10 +9,9 @@ body:
description: 在提交之前,请勾选以下所有选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。 description: 在提交之前,请勾选以下所有选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。
options: options:
- label: 我已在标题简短的描述了我所遇到的问题 - label: 我已在标题简短的描述了我所遇到的问题
- label: 我已在 [Issue Tracker](./?q=is%3Aissue) 中寻找过我要提出的问题,但未找到相同的问题 - label: 我未在[Issue Tracker](./?q=is%3Aissue)中找到我要提出的问题
- label: 我已在 [常见问题](https://mihomo.party/docs/issues/common) 中寻找过我要提出的问题,并没有找到答案 - label: 我未在[常见问题](https://mihomo.party/docs/issues/common)中找到我要提出的问题
- label: 这是 GUI 程序的问题,而不是内核程序的问题 - label: 这是GUI程序的问题,而不是内核程序的问题
- label: 我已经关闭所有杀毒软件/代理软件后测试过,问题依旧存在
- label: 我已经使用最新的测试版本测试过,问题依旧存在 - label: 我已经使用最新的测试版本测试过,问题依旧存在
- type: dropdown - type: dropdown

View File

@ -3,6 +3,3 @@ contact_links:
- name: '常见问题' - name: '常见问题'
about: '提出问题前请先查看常见问题' about: '提出问题前请先查看常见问题'
url: 'https://mihomo.party/docs/issues/common' url: 'https://mihomo.party/docs/issues/common'
- name: '交流群组'
about: '提问/讨论性质的问题请勿提交issue'
url: 'https://t.me/mihomo_party_group'

View File

@ -9,8 +9,8 @@ body:
description: 在提交之前,请勾选以下所有选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。 description: 在提交之前,请勾选以下所有选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。
options: options:
- label: 我已在标题简短的描述了我所需的功能 - label: 我已在标题简短的描述了我所需的功能
- label: 我已在 [Issue Tracker](./?q=is%3Aissue) 中寻找过,但未找到我所需的功能 - label: 我已在[Issue Tracker](./?q=is%3Aissue)中寻找过,但未找到我所需的功能
- label: 这是向 GUI 程序提出的功能请求,而不是内核程序 - label: 这是向GUI程序提出的功能请求,而不是内核程序
- label: 我未在最新的测试版本找到我所需的功能 - label: 我未在最新的测试版本找到我所需的功能
- type: dropdown - type: dropdown

View File

@ -1,13 +1,10 @@
name: Build name: Build
on: on:
push: push:
branches:
- master
tags: tags:
- v* - v*
paths-ignore:
- 'README.md'
- '.github/ISSUE_TEMPLATE/**'
- '.github/workflows/issues.yml'
workflow_dispatch:
permissions: write-all permissions: write-all
@ -145,7 +142,6 @@ jobs:
run: | run: |
pnpm install pnpm install
pnpm add @mihomo-party/sysproxy-linux-${{ matrix.arch }}-gnu pnpm add @mihomo-party/sysproxy-linux-${{ matrix.arch }}-gnu
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
pnpm prepare --${{ matrix.arch }} pnpm prepare --${{ matrix.arch }}
- name: Build - name: Build
env: env:
@ -200,35 +196,9 @@ jobs:
env: env:
npm_config_arch: ${{ matrix.arch }} npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }} npm_config_target_arch: ${{ matrix.arch }}
APPLE_ID: ${{ secrets.APPLE_ID }} run: pnpm build:mac --${{ matrix.arch }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: |
chmod +x build/pkg-scripts/postinstall build/pkg-scripts/preinstall
pnpm build:mac --${{ matrix.arch }}
- name: Setup temporary installer signing keychain
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.CSC_INSTALLER_LINK }}
p12-password: ${{ secrets.CSC_INSTALLER_KEY_PASSWORD }}
- name: Sign the Apple pkg
run: |
for pkg_name in $(ls -1 dist/*.pkg); do
pkg_name=$(ls -1 dist/*.pkg)
mv $pkg_name Unsigned-Workbench.pkg
productsign --sign "Developer ID Installer: Prometheus Advertising Corp (489PDK5LP3)" Unsigned-Workbench.pkg $pkg_name
rm -f Unsigned-Workbench.pkg
xcrun notarytool submit $pkg_name --apple-id $APPLE_ID --team-id $APPLE_TEAM_ID --password $APPLE_APP_SPECIFIC_PASSWORD --wait
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Generate checksums - name: Generate checksums
run: pnpm checksum .pkg run: pnpm checksum .dmg
- name: Upload Artifacts - name: Upload Artifacts
if: startsWith(github.ref, 'refs/heads/') if: startsWith(github.ref, 'refs/heads/')
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@ -236,7 +206,7 @@ jobs:
name: MacOS ${{ matrix.arch }} name: MacOS ${{ matrix.arch }}
path: | path: |
dist/*.sha256 dist/*.sha256
dist/*.pkg dist/*.dmg
if-no-files-found: error if-no-files-found: error
- name: Publish Release - name: Publish Release
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
@ -244,102 +214,25 @@ jobs:
with: with:
files: | files: |
dist/*.sha256 dist/*.sha256
dist/*.pkg dist/*.dmg
body_path: changelog.md
token: ${{ secrets.GITHUB_TOKEN }}
macos10:
strategy:
fail-fast: false
matrix:
arch:
- x64
- arm64
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
run: npm install -g pnpm
- name: Install Dependencies
env:
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
run: |
pnpm install
pnpm add @mihomo-party/sysproxy-darwin-${{ matrix.arch }}
pnpm add -D electron@32.2.2
pnpm prepare --${{ matrix.arch }}
- name: Build
env:
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: |
sed -i "" -e "s/macos/catalina/" electron-builder.yml
chmod +x build/pkg-scripts/postinstall build/pkg-scripts/preinstall
pnpm build:mac --${{ matrix.arch }}
- name: Setup temporary installer signing keychain
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.CSC_INSTALLER_LINK }}
p12-password: ${{ secrets.CSC_INSTALLER_KEY_PASSWORD }}
- name: Sign the Apple pkg
run: |
for pkg_name in $(ls -1 dist/*.pkg); do
pkg_name=$(ls -1 dist/*.pkg)
mv $pkg_name Unsigned-Workbench.pkg
productsign --sign "Developer ID Installer: Prometheus Advertising Corp (489PDK5LP3)" Unsigned-Workbench.pkg $pkg_name
rm -f Unsigned-Workbench.pkg
xcrun notarytool submit $pkg_name --apple-id $APPLE_ID --team-id $APPLE_TEAM_ID --password $APPLE_APP_SPECIFIC_PASSWORD --wait
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Generate checksums
run: pnpm checksum .pkg
- name: Upload Artifacts
if: startsWith(github.ref, 'refs/heads/')
uses: actions/upload-artifact@v4
with:
name: Catalina ${{ matrix.arch }}
path: |
dist/*.sha256
dist/*.pkg
if-no-files-found: error
- name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.sha256
dist/*.pkg
body_path: changelog.md body_path: changelog.md
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
updater: updater:
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
needs: [windows, macos, windows7, macos10] needs: [windows, macos, windows7]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup pnpm - name: Setup pnpm
run: npm install -g pnpm run: npm install -g pnpm
- name: Install Dependencies - name: Build Latest
run: pnpm install run: pnpm install && pnpm updater
- name: Telegram Notification - name: Telegram Notification
env: env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
run: pnpm telegram run: pnpm telegram
- name: Generate latest.yml
run: pnpm updater
- name: Publish Release - name: Publish Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
@ -387,7 +280,7 @@ jobs:
pkgbuild: aur/${{ matrix.pkgname }}/PKGBUILD pkgbuild: aur/${{ matrix.pkgname }}/PKGBUILD
commit_username: pompurin404 commit_username: pompurin404
commit_email: pompurin404@mihomo.party commit_email: pompurin404@mihomo.party
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} ssh_private_key: ${{ secrets.PRIVATE_KEY }}
commit_message: Update AUR package commit_message: Update AUR package
ssh_keyscan_types: rsa,ed25519 ssh_keyscan_types: rsa,ed25519
allow_empty_commits: false allow_empty_commits: false
@ -409,7 +302,7 @@ jobs:
pkgbuild: aur/mihomo-party-git/PKGBUILD pkgbuild: aur/mihomo-party-git/PKGBUILD
commit_username: pompurin404 commit_username: pompurin404
commit_email: pompurin404@mihomo.party commit_email: pompurin404@mihomo.party
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} ssh_private_key: ${{ secrets.PRIVATE_KEY }}
commit_message: Update AUR package commit_message: Update AUR package
ssh_keyscan_types: rsa,ed25519 ssh_keyscan_types: rsa,ed25519
allow_empty_commits: false allow_empty_commits: false
@ -430,3 +323,21 @@ jobs:
release-tag: v${{env.VERSION}} release-tag: v${{env.VERSION}}
installers-regex: 'mihomo-party-windows-.*setup\.exe$' installers-regex: 'mihomo-party-windows-.*setup\.exe$'
token: ${{ secrets.POMPURIN404_TOKEN }} token: ${{ secrets.POMPURIN404_TOKEN }}
homebrew:
if: startsWith(github.ref, 'refs/tags/v')
name: Update Homebrew cask
needs: macos
runs-on: macos-latest
steps:
- name: Set up Git
run: |
git config --global user.email pompurin404@mihomo.party
git config --global user.name pompurin404
- name: Update Homebrew cask
env:
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.POMPURIN404_TOKEN }}
run: |
brew tap mihomo-party-org/mihomo-party
brew update
brew bump-cask-pr mihomo-party --version $(echo ${{ github.ref }} | tr -d 'refs/tags/v') --no-browse

View File

@ -1,29 +0,0 @@
name: Review Issues
on:
issues:
types: [opened]
jobs:
review:
runs-on: ubuntu-latest
steps:
- name: Generate Token
uses: tibdex/github-app-token@v2
id: generate
with:
app_id: ${{ secrets.BOT_APP_ID }}
private_key: ${{ secrets.BOT_PRIVATE_KEY }}
- name: Review Issues
uses: mihomo-party-org/universal-assistant@v1.0.3
with:
github_token: ${{ steps.generate.outputs.token }}
openai_base_url: ${{ secrets.OPENAI_BASE_URL }}
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
openai_model: ${{ vars.OPENAI_MODEL }}
system_prompt: ${{ vars.SYSTEM_PROMPT }}
available_tools: ${{ vars.AVAILABLE_TOOLS }}
user_input: |
请审查如下 Issue
标题:"${{ github.event.issue.title }}"
内容:"${{ github.event.issue.body }}"

2
.npmrc
View File

@ -1,3 +1,3 @@
shamefully-hoist=true shamefully-hoist=true
virtual-store-dir-max-length=80 virtual-store-dir-max-length=80
public-hoist-pattern[]=*@heroui/* public-hoist-pattern[]=*@nextui-org/*

View File

@ -3,39 +3,28 @@
<img height='48px' src='./images/icon-black.png#gh-light-mode-only'> <img height='48px' src='./images/icon-black.png#gh-light-mode-only'>
</h3> </h3>
<h3 align="center">Another <a href="https://github.com/MetaCubeX/mihomo">Mihomo</a> GUI</h3> <h3 align="center">Another Mihomo GUI</h3>
<p align="center"> <p align="center">
<a href="https://github.com/mihomo-party-org/mihomo-party/releases"> <a href="https://github.com/mihomo-party-org/mihomo-party/releases">
<img src="https://img.shields.io/github/release/mihomo-party-org/mihomo-party/all.svg"> <img src="https://img.shields.io/github/release/mihomo-party-org/mihomo-party/all.svg">
</a> </a>
<a href="https://t.me/mihomo_party_group"> <a href="https://t.me/mihomo_party_channel">
<img src="https://img.shields.io/badge/Telegram-Group-blue?logo=telegram"> <img src="https://img.shields.io/badge/Telegram-Channel-blue?logo=telegram">
</a> </a>
</p> </p>
<div align='center'> <div align='center'>
<img width='90%' src="./images/preview.jpg"> <img width='90%' src="./images/preview.jpg">
</div> </div>
### 本项目由“[狗狗加速](https://party.dginv.click/#/register?code=ARdo0mXx)”赞助
##### [狗狗加速 —— 技术流机场 Doggygo VPN](https://party.dginv.click/#/register?code=ARdo0mXx)
- 高性能海外机场,稳定首选,海外团队,无跑路风险
- Mihomo Party专属8折优惠码party仅有500份
- Party专属链接注册送 3 天,每天 1G 流量 [免费试用](https://party.dginv.click/#/register?code=ARdo0mXx)
- 优惠套餐每月仅需 15.8 元160G 流量,年付 8 折
- 全球首家支持Hysteria1/2 协议集群负载均衡设计高速专线基于最新UDP quic技术极低延迟无视晚高峰4K 秒开配合Mihomo Party食用更省心
- 解锁流媒体及 ChatGPT
- 官网:[https://狗狗加速.com](https://party.dginv.click/#/register?code=ARdo0mXx)
### 特性 ### 特性
- [x] 开箱即用,无需服务模式的 Tun - [x] 开箱即用无需服务模式的Tun
- [x] 多种配色主题可选UI 焕然一新 - [x] 多种配色主题可选UI焕然一新
- [x] 支持大部分 Mihomo 常用配置修改 - [x] 支持大部分Mihomo常用配置修改
- [x] 内置稳定版和预览版 Mihomo 内核 - [x] 内置稳定版和预览版Mihomo内核
- [x] 通过 WebDAV 一键备份和恢复配置 - [x] 通过WebDav一键备份和恢复配置
- [x] 强大的覆写功能,任意修订配置文件 - [x] 强大的覆写功能,任意修订配置文件
- [x] 深度集成 Sub-Store轻松管理订阅 - [x] 深度集成Sub-Store轻松管理订阅
### 安装/使用指南见 [官方文档](https://mihomo.party) ### 安装/使用指南见 [官方文档](https://mihomo.party)

View File

@ -10,6 +10,7 @@ conflicts=("$_pkgname" "$_pkgname-git" "$_pkgname-electron" "$_pkgname-electron-
conflicts=("mihomo-party-git" 'mihomo-party') conflicts=("mihomo-party-git" 'mihomo-party')
depends=('gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs' 'libsecret') depends=('gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs' 'libsecret')
optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).') optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).')
makedepends=('nodejs' 'pnpm' 'jq' 'libxcrypt-compat')
install=$_pkgname.install install=$_pkgname.install
source=("${_pkgname}.sh") source=("${_pkgname}.sh")
source_x86_64=("${_pkgname}-${pkgver}-x86_64.deb::${url}/releases/download/v${pkgver}/mihomo-party-linux-${pkgver}-amd64.deb") source_x86_64=("${_pkgname}-${pkgver}-x86_64.deb::${url}/releases/download/v${pkgver}/mihomo-party-linux-${pkgver}-amd64.deb")
@ -21,9 +22,10 @@ sha256sums_aarch64=('8cd7398b8fc1cd70d41e386af9995cbddc1043d9018391c29f056f14357
package() { package() {
bsdtar -xf data.tar.xz -C "${pkgdir}/" bsdtar -xf data.tar.xz -C "${pkgdir}/"
chmod +x ${pkgdir}/opt/mihomo-party/mihomo-party chmod +x ${pkgdir}/opt/mihomo-party/mihomo-party
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
install -Dm755 "${srcdir}/${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}" cd ${pkgdir}/../..
install -Dm755 "${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
sed -i '3s!/opt/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop" sed -i '3s!/opt/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
chown -R root:root ${pkgdir} chown -R root:root ${pkgdir}

View File

@ -27,9 +27,10 @@ package() {
asar extract $srcdir/opt/mihomo-party/resources/app.asar ${pkgdir}/opt/mihomo-party asar extract $srcdir/opt/mihomo-party/resources/app.asar ${pkgdir}/opt/mihomo-party
cp -r $srcdir/opt/mihomo-party/resources/sidecar ${pkgdir}/opt/mihomo-party/resources/ cp -r $srcdir/opt/mihomo-party/resources/sidecar ${pkgdir}/opt/mihomo-party/resources/
cp -r $srcdir/opt/mihomo-party/resources/files ${pkgdir}/opt/mihomo-party/resources/ cp -r $srcdir/opt/mihomo-party/resources/files ${pkgdir}/opt/mihomo-party/resources/
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
install -Dm755 "${srcdir}/${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}" cd ${pkgdir}/../..
install -Dm755 "${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
install -Dm644 "${_pkgname}.desktop" "${pkgdir}/usr/share/applications/${_pkgname}.desktop" install -Dm644 "${_pkgname}.desktop" "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
install -Dm644 "${pkgdir}/opt/mihomo-party/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png" install -Dm644 "${pkgdir}/opt/mihomo-party/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png"

View File

@ -1,5 +1,5 @@
[Desktop Entry] [Desktop Entry]
Name=Mihomo Party Name=mihomo-party
Exec=mihomo-party %U Exec=mihomo-party %U
Terminal=false Terminal=false
Type=Application Type=Application

View File

@ -24,7 +24,6 @@ options=('!lto')
prepare(){ prepare(){
cd $srcdir/${_pkgname}-${pkgver} cd $srcdir/${_pkgname}-${pkgver}
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
pnpm install pnpm install
} }
@ -37,8 +36,9 @@ package() {
asar extract $srcdir/${_pkgname}-${pkgver}/dist/linux-unpacked/resources/app.asar ${pkgdir}/opt/mihomo-party asar extract $srcdir/${_pkgname}-${pkgver}/dist/linux-unpacked/resources/app.asar ${pkgdir}/opt/mihomo-party
cp -r $srcdir/${_pkgname}-${pkgver}/extra/sidecar ${pkgdir}/opt/mihomo-party/resources/ cp -r $srcdir/${_pkgname}-${pkgver}/extra/sidecar ${pkgdir}/opt/mihomo-party/resources/
cp -r $srcdir/${_pkgname}-${pkgver}/extra/files ${pkgdir}/opt/mihomo-party/resources/ cp -r $srcdir/${_pkgname}-${pkgver}/extra/files ${pkgdir}/opt/mihomo-party/resources/
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
cd ${pkgdir}/../..
install -Dm755 "${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}" install -Dm755 "${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
install -Dm644 "${_pkgname}.desktop" "${pkgdir}/usr/share/applications/${_pkgname}.desktop" install -Dm644 "${_pkgname}.desktop" "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
install -Dm644 "${pkgdir}/opt/mihomo-party/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png" install -Dm644 "${pkgdir}/opt/mihomo-party/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png"

View File

@ -1,5 +1,5 @@
[Desktop Entry] [Desktop Entry]
Name=Mihomo Party Name=mihomo-party
Exec=mihomo-party %U Exec=mihomo-party %U
Terminal=false Terminal=false
Type=Application Type=Application

View File

@ -25,7 +25,6 @@ pkgver() {
prepare(){ prepare(){
cd $srcdir/${_pkgname} cd $srcdir/${_pkgname}
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
pnpm install pnpm install
} }
@ -39,9 +38,11 @@ package() {
bsdtar -xf mihomo-party-linux-$(jq '.version' $srcdir/${_pkgname}/package.json | tr -d 'v"')*.deb bsdtar -xf mihomo-party-linux-$(jq '.version' $srcdir/${_pkgname}/package.json | tr -d 'v"')*.deb
bsdtar -xf data.tar.xz -C "${pkgdir}/" bsdtar -xf data.tar.xz -C "${pkgdir}/"
chmod +x ${pkgdir}/opt/mihomo-party/mihomo-party chmod +x ${pkgdir}/opt/mihomo-party/mihomo-party
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
install -Dm755 "${srcdir}/../${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}" cd ${pkgdir}/../..
install -Dm755 "${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
sed -i '3s!/opt/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop" sed -i '3s!/opt/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
chown -R root:root ${pkgdir} chown -R root:root ${pkgdir}

View File

@ -20,7 +20,6 @@ options=('!lto')
prepare(){ prepare(){
cd $srcdir/${pkgname}-${pkgver} cd $srcdir/${pkgname}-${pkgver}
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
pnpm install pnpm install
} }
@ -34,9 +33,10 @@ package() {
bsdtar -xf mihomo-party-linux-${pkgver}*.deb bsdtar -xf mihomo-party-linux-${pkgver}*.deb
bsdtar -xf data.tar.xz -C "${pkgdir}/" bsdtar -xf data.tar.xz -C "${pkgdir}/"
chmod +x ${pkgdir}/opt/mihomo-party/mihomo-party chmod +x ${pkgdir}/opt/mihomo-party/mihomo-party
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
install -Dm755 "${srcdir}/../${pkgname}.sh" "${pkgdir}/usr/bin/${pkgname}" cd ${pkgdir}/../..
install -Dm755 "${pkgname}.sh" "${pkgdir}/usr/bin/${pkgname}"
sed -i '3s!/opt/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${pkgname}.desktop" sed -i '3s!/opt/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${pkgname}.desktop"
chown -R root:root ${pkgdir} chown -R root:root ${pkgdir}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

View File

@ -11,8 +11,6 @@ else
fi fi
chmod 4755 '/opt/mihomo-party/chrome-sandbox' || true chmod 4755 '/opt/mihomo-party/chrome-sandbox' || true
chmod +sx /opt/mihomo-party/resources/sidecar/mihomo
chmod +sx /opt/mihomo-party/resources/sidecar/mihomo-alpha
if hash update-mime-database 2>/dev/null; then if hash update-mime-database 2>/dev/null; then
update-mime-database /usr/share/mime || true update-mime-database /usr/share/mime || true

View File

@ -1,173 +0,0 @@
#!/bin/sh
set -e
# 设置日志文件
LOG_FILE="/tmp/mihomo-party-install.log"
exec > "$LOG_FILE" 2>&1
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
}
# 检查 root 权限
if [ "$EUID" -ne 0 ]; then
log "Error: Please run as root"
exit 1
fi
# 判断 $2 是否以 .app 结尾
if [[ $2 == *".app" ]]; then
APP_PATH="$2"
else
APP_PATH="$2/Mihomo Party.app"
fi
HELPER_PATH="/Library/PrivilegedHelperTools/party.mihomo.helper"
LAUNCH_DAEMON="/Library/LaunchDaemons/party.mihomo.helper.plist"
log "Starting installation..."
# 创建目录并设置权限
log "Creating directories and setting permissions..."
mkdir -p "/Library/PrivilegedHelperTools"
chmod 755 "/Library/PrivilegedHelperTools"
chown root:wheel "/Library/PrivilegedHelperTools"
# 设置核心文件权限
log "Setting core file permissions..."
if [ -f "$APP_PATH/Contents/Resources/sidecar/mihomo" ]; then
chown root:admin "$APP_PATH/Contents/Resources/sidecar/mihomo"
chmod +s "$APP_PATH/Contents/Resources/sidecar/mihomo"
log "Set permissions for mihomo"
else
log "Warning: mihomo binary not found at $APP_PATH/Contents/Resources/sidecar/mihomo"
fi
if [ -f "$APP_PATH/Contents/Resources/sidecar/mihomo-alpha" ]; then
chown root:admin "$APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
chmod +s "$APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
log "Set permissions for mihomo-alpha"
else
log "Warning: mihomo-alpha binary not found at $APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
fi
# 复制 helper 工具
log "Installing helper tool..."
if [ -f "$APP_PATH/Contents/Resources/files/party.mihomo.helper" ]; then
cp -f "$APP_PATH/Contents/Resources/files/party.mihomo.helper" "$HELPER_PATH"
chown root:wheel "$HELPER_PATH"
chmod 544 "$HELPER_PATH"
log "Helper tool installed successfully"
else
log "Error: Helper file not found at $APP_PATH/Contents/Resources/files/party.mihomo.helper"
exit 1
fi
# 创建并配置 LaunchDaemon
log "Configuring LaunchDaemon..."
mkdir -p "/Library/LaunchDaemons"
cat << EOF > "$LAUNCH_DAEMON"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>party.mihomo.helper</string>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>party.mihomo.app</string>
</array>
<key>KeepAlive</key>
<true/>
<key>Program</key>
<string>${HELPER_PATH}</string>
<key>StandardErrorPath</key>
<string>/tmp/party.mihomo.helper.err</string>
<key>StandardOutPath</key>
<string>/tmp/party.mihomo.helper.log</string>
</dict>
</plist>
EOF
chown root:wheel "$LAUNCH_DAEMON"
chmod 644 "$LAUNCH_DAEMON"
log "LaunchDaemon configured"
# 验证关键文件
log "Verifying installation..."
if [ ! -x "$HELPER_PATH" ]; then
log "Error: Helper tool is not executable: $HELPER_PATH"
exit 1
fi
# 检查二进制文件有效性
if ! file "$HELPER_PATH" | grep -q "Mach-O"; then
log "Error: Helper tool is not a valid Mach-O binary"
exit 1
fi
# 验证 plist 格式
if ! plutil -lint "$LAUNCH_DAEMON" >/dev/null 2>&1; then
log "Error: Invalid plist format"
exit 1
fi
# 获取 macOS 版本
macos_version=$(sw_vers -productVersion)
macos_major=$(echo "$macos_version" | cut -d. -f1)
log "macOS version: $macos_version"
# 清理现有服务
log "Cleaning up existing services..."
launchctl bootout system "$LAUNCH_DAEMON" 2>/dev/null || true
launchctl unload "$LAUNCH_DAEMON" 2>/dev/null || true
# 加载服务
log "Loading service..."
if [ "$macos_major" -ge 11 ]; then
# macOS Big Sur 及更新版本使用 bootstrap
if ! launchctl bootstrap system "$LAUNCH_DAEMON"; then
log "Bootstrap failed, trying legacy load..."
if ! launchctl load "$LAUNCH_DAEMON"; then
log "Error: Failed to load service with both methods"
exit 1
fi
fi
else
# 旧版本使用 load
if ! launchctl load "$LAUNCH_DAEMON"; then
log "Error: Failed to load service"
exit 1
fi
fi
# 验证服务状态
log "Verifying service status..."
sleep 2
if launchctl list | grep -q "party.mihomo.helper"; then
log "Service loaded successfully"
else
log "Warning: Service may not be running properly"
fi
log "Installation completed successfully"
# Fix user data directory permissions
log "Fixing user data directory permissions..."
for user_home in /Users/*; do
if [ -d "$user_home" ] && [ "$(basename "$user_home")" != "Shared" ] && [ "$(basename "$user_home")" != ".localized" ]; then
username=$(basename "$user_home")
user_data_dir="$user_home/Library/Application Support/mihomo-party"
if [ -d "$user_data_dir" ]; then
current_owner=$(stat -f "%Su" "$user_data_dir" 2>/dev/null || echo "unknown")
if [ "$current_owner" = "root" ]; then
log "Fixing ownership for user: $username"
chown -R "$username:staff" "$user_data_dir" 2>/dev/null || true
chmod -R u+rwX "$user_data_dir" 2>/dev/null || true
fi
fi
fi
done
exit 0

View File

@ -1,26 +0,0 @@
#!/bin/sh
set -e
# 检查 root 权限
if [ "$EUID" -ne 0 ]; then
echo "Please run as root"
exit 1
fi
HELPER_PATH="/Library/PrivilegedHelperTools/party.mihomo.helper"
LAUNCH_DAEMON="/Library/LaunchDaemons/party.mihomo.helper.plist"
# 停止并卸载现有的 LaunchDaemon
if [ -f "$LAUNCH_DAEMON" ]; then
launchctl unload "$LAUNCH_DAEMON" 2>/dev/null || true
rm -f "$LAUNCH_DAEMON"
fi
# 移除 helper 工具
rm -f "$HELPER_PATH"
# 清理可能存在的旧版本文件
rm -rf "/Applications/Mihomo Party.app"
rm -rf "/Applications/Mihomo\\ Party.app"
exit 0

View File

@ -1,72 +1,14 @@
## 1.7.7 ### Breaking Changes
### 新功能 (Feat)
- Mihomo 内核升级 v1.19.12
- 新增 Webdav 最大备数设置和清理逻辑
### 修复 (Fix) - 为了修复macOS应用内更新问题此版本需要手动下载dmg进行安装
- 修复 MacOS 下无法启动的问题(重置工作目录权限)
- 尝试修复不同版本 MacOS 下安装软件时候的报错Input/output error
- 部分遗漏的多国语言翻译
## 1.7.6 ### Features
**此版本修复了 1.7.5 中的几个严重 bug推荐所有人更新** - 支持自定义延迟测试并发数量
- 完善Sub-Store环境变量
- 支持查看已关闭的连接
### 修复 (Fix) ### Bug Fixes
- 修复了内核1.19.8更新后gist同步失效的问题(#780)
- 部分遗漏的多国语言翻译
- MacOS 下启动Error: EACCES: permission denied
- MacOS 系统代理 bypass 不生效
- MacOS 系统代理开启时 500 报错
## 1.7.5 - 修复macOS应用内更新后权限丢失的问题
- 修复高版本macOS SSID获取失败的问题
### 新功能 (Feat)
- 增加组延迟测试时的动画
- 订阅卡片可右键点击
-
### 修复 (Fix)
- 1.7.4引入的内核启动错误
- 无法手动设置内核权限
- 完善 系统代理socket 重建和检测机制
## 1.7.4
### 新功能 (Feat)
- Mihomo 内核升级 v1.19.10
- 改进 socket创建机制防止 MacOS 下系统代理开启无法找到 socket 文件的问题
- mihomo-party-helper增加更多日志以方便调试
- 改进 MacOS 下签名和公正流程
- 增加 MacOS 下 plist 权限设置
- 改进安装流程
-
### 修复 (Fix)
- 修复mihomo-party-helper本地提权漏洞
- 修复 MacOS 下安装失败的问题
- 移除节点页面的滚动位置记忆,解决页面溢出的问题
- DNS hosts 设置在 useHosts 不为 true 时也会被错误应用的问题(#742)
- 当用户在 Profile 设置中修改了更新间隔并保存后,新的间隔时间不会立即生效(#671)
- 禁止选择器组件选择空值
- 修复proxy-provider
## 1.7.3
**注意:如安装后为英文,请在设置中反复选择几次不同语言以写入配置文件**
### 新功能 (Feat)
- Mihomo 内核升级 v1.19.5
- MacOS 下添加 Dock 图标动态展现方式 (#594)
- 更改默认 UA 并添加版本
- 添加固定间隔的配置文件更新按钮 (#670)
- 重构Linux上的手动授权内核方式
- 将sub-store迁移到工作目录下(#552)
- 重置软件增加警告提示
### 修复 (Fix)
- 修复代理节点页面因为重复刷新导致的溢出问题
- 修复由于 Mihomo 核心错误导致启动时窗口丢失 (#601)
- 修复macOS下的sub-store更新问题 (#552)
- 修复多语言翻译
- 修复 defaultBypass 几乎总是 Windows 默认绕过设置 (#602)
- 修复重置防火墙时发生的错误,因为没有指定防火墙规则 (#650)

View File

@ -1,5 +1,6 @@
appId: party.mihomo.app appId: party.mihomo.app
productName: Mihomo Party productName: mihomo-party
executableName: mihomo-party
directories: directories:
buildResources: build buildResources: build
files: files:
@ -30,6 +31,7 @@ win:
artifactName: ${name}-windows-${version}-${arch}-portable.${ext} artifactName: ${name}-windows-${version}-${arch}-portable.${ext}
nsis: nsis:
artifactName: ${name}-windows-${version}-${arch}-setup.${ext} artifactName: ${name}-windows-${version}-${arch}-setup.${ext}
shortcutName: Mihomo Party
uninstallDisplayName: ${productName} uninstallDisplayName: ${productName}
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
oneClick: false oneClick: false
@ -37,34 +39,25 @@ nsis:
createDesktopShortcut: always createDesktopShortcut: always
mac: mac:
target: target:
- pkg - dmg
entitlementsInherit: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist
extendInfo: extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera. - NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone. - NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: true notarize: false
artifactName: ${name}-macos-${version}-${arch}.${ext} artifactName: ${name}-macos-${version}-${arch}.${ext}
pkg:
allowAnywhere: false
allowCurrentUserHome: false
background:
alignment: bottomleft
file: build/background.png
linux: linux:
desktop: desktop:
Name: Mihomo Party
MimeType: 'x-scheme-handler/clash;x-scheme-handler/mihomo' MimeType: 'x-scheme-handler/clash;x-scheme-handler/mihomo'
target: target:
- deb - deb
- rpm - rpm
maintainer: mihomo-party-org maintainer: mihomo-party
category: Utility category: Utility
artifactName: ${name}-linux-${version}-${arch}.${ext} artifactName: ${name}-linux-${version}-${arch}.${ext}
deb: deb:
afterInstall: 'build/linux/postinst' afterInstall: 'build/linux/deb/postinst'
rpm:
afterInstall: 'build/linux/postinst'
npmRebuild: true npmRebuild: true
publish: [] publish: []

View File

@ -22,14 +22,6 @@ export default defineConfig({
plugins: [externalizeDepsPlugin()] plugins: [externalizeDepsPlugin()]
}, },
renderer: { renderer: {
build: {
rollupOptions: {
input: {
index: resolve('src/renderer/index.html'),
floating: resolve('src/renderer/floating.html')
}
}
},
resolve: { resolve: {
alias: { alias: {
'@renderer': resolve('src/renderer/src') '@renderer': resolve('src/renderer/src')

View File

@ -1,9 +1,9 @@
{ {
"name": "mihomo-party", "name": "mihomo-party",
"version": "1.7.7", "version": "1.3.2",
"description": "Mihomo Party", "description": "Mihomo Party",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "mihomo-party-org", "author": "mihomo-party",
"homepage": "https://mihomo.party", "homepage": "https://mihomo.party",
"scripts": { "scripts": {
"format": "prettier --write .", "format": "prettier --write .",
@ -15,7 +15,6 @@
"updater": "node scripts/updater.mjs", "updater": "node scripts/updater.mjs",
"checksum": "node scripts/checksum.mjs", "checksum": "node scripts/checksum.mjs",
"telegram": "node scripts/telegram.mjs", "telegram": "node scripts/telegram.mjs",
"artifact": "node scripts/artifact.mjs",
"dev": "electron-vite dev", "dev": "electron-vite dev",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"build:win": "electron-vite build && electron-builder --publish never --win", "build:win": "electron-vite build && electron-builder --publish never --win",
@ -25,74 +24,66 @@
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"@heroui/react": "^2.6.14", "@mihomo-party/sysproxy": "^2.0.4",
"@mihomo-party/sysproxy": "^2.0.7",
"@mihomo-party/sysproxy-darwin-arm64": "^2.0.7",
"@types/crypto-js": "^4.2.2",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"axios": "^1.7.7", "axios": "^1.7.7",
"chokidar": "^4.0.1", "chokidar": "^4.0.0",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"express": "^5.0.1", "express": "^4.21.0",
"i18next": "^24.2.2", "lodash": "^4.17.21",
"iconv-lite": "^0.6.3", "recharts": "^2.12.7",
"react-i18next": "^15.4.0",
"webdav": "^5.7.1", "webdav": "^5.7.1",
"ws": "^8.18.0", "ws": "^8.18.0",
"yaml": "^2.6.0" "yaml": "^2.5.1"
}, },
"devDependencies": { "devDependencies": {
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/eslint-config-prettier": "^2.0.0", "@electron-toolkit/eslint-config-prettier": "^2.0.0",
"@electron-toolkit/eslint-config-ts": "^2.0.0", "@electron-toolkit/eslint-config-ts": "^2.0.0",
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
"@types/adm-zip": "^0.5.6", "@nextui-org/react": "^2.4.6",
"@types/express": "^5.0.0", "@types/adm-zip": "^0.5.5",
"@types/node": "^22.13.1", "@types/express": "^4.17.21",
"@types/node": "^22.5.5",
"@types/pubsub-js": "^1.8.6", "@types/pubsub-js": "^1.8.6",
"@types/react": "^19.0.4", "@types/react": "^18.3.5",
"@types/react-dom": "^19.0.2", "@types/react-dom": "^18.3.0",
"@types/ws": "^8.5.13", "@types/ws": "^8.5.12",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"cron-validator": "^1.3.1", "cron-validator": "^1.3.1",
"driver.js": "^1.3.5", "driver.js": "^1.3.1",
"electron": "^34.0.2", "electron": "^32.1.2",
"electron-builder": "25.1.8", "electron-builder": "^25.0.5",
"electron-vite": "^2.3.0", "electron-vite": "^2.3.0",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"eslint": "8.57.1", "eslint": "^8.57.0",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.36.1",
"form-data": "^4.0.1", "framer-motion": "^11.5.4",
"framer-motion": "12.0.11", "meta-json-schema": "^1.18.8",
"lodash": "^4.17.21", "monaco-yaml": "^5.2.2",
"meta-json-schema": "^1.18.9", "nanoid": "^5.0.7",
"monaco-yaml": "^5.2.3", "next-themes": "^0.3.0",
"nanoid": "^5.0.8", "postcss": "^8.4.45",
"next-themes": "^0.4.3",
"postcss": "^8.4.47",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"pubsub-js": "^1.9.5", "pubsub-js": "^1.9.4",
"react": "^19.0.0", "react": "^18.3.1",
"react-dom": "^19.0.0", "react-dom": "^18.3.1",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^4.0.13",
"react-icons": "^5.3.0", "react-icons": "^5.3.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-monaco-editor": "^0.58.0", "react-monaco-editor": "^0.56.1",
"react-router-dom": "^7.1.5", "react-router-dom": "^6.26.2",
"react-virtuoso": "^4.12.0", "react-virtuoso": "^4.10.4",
"recharts": "^2.13.3",
"swr": "^2.2.5", "swr": "^2.2.5",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.11",
"tar": "^7.4.3", "tar": "^7.4.3",
"tsx": "^4.19.2", "tsx": "^4.19.1",
"types-pac": "^1.0.3", "types-pac": "^1.0.3",
"typescript": "^5.6.3", "typescript": "^5.6.2",
"vite": "^6.0.7", "vite": "^5.4.5",
"vite-plugin-monaco-editor": "^1.1.0" "vite-plugin-monaco-editor": "^1.1.0"
}, }
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c"
} }

9641
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +0,0 @@
#!/bin/bash
echo "=== Mihomo Party Cleanup Tool ==="
echo "This script will remove all Mihomo Party related files and services."
read -p "Are you sure you want to continue? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 1
fi
# Stop and unload services
echo "Stopping services..."
sudo launchctl unload /Library/LaunchDaemons/party.mihomo.helper.plist 2>/dev/null || true
# Remove files
echo "Removing files..."
sudo rm -f /Library/LaunchDaemons/party.mihomo.helper.plist
sudo rm -f /Library/PrivilegedHelperTools/party.mihomo.helper
sudo rm -rf "/Applications/Mihomo Party.app"
sudo rm -rf "/Applications/Mihomo\\ Party.app"
sudo rm -rf ~/Library/Application\ Support/mihomo-party
sudo rm -rf ~/Library/Caches/mihomo-party
sudo rm -f ~/Library/Preferences/party.mihomo.app.helper.plist
sudo rm -f ~/Library/Preferences/party.mihomo.app.plist
echo "Cleanup complete. Please restart your computer to complete the process."

View File

@ -241,11 +241,6 @@ const resolveMmdb = () =>
file: 'country.mmdb', file: 'country.mmdb',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country-lite.mmdb` downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country-lite.mmdb`
}) })
const resolveMetadb = () =>
resolveResource({
file: 'geoip.metadb',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb`
})
const resolveGeosite = () => const resolveGeosite = () =>
resolveResource({ resolveResource({
file: 'geosite.dat', file: 'geosite.dat',
@ -276,28 +271,6 @@ const resolveRunner = () =>
file: 'mihomo-party-run.exe', file: 'mihomo-party-run.exe',
downloadURL: `https://github.com/mihomo-party-org/mihomo-party-run/releases/download/${arch}/mihomo-party-run.exe` downloadURL: `https://github.com/mihomo-party-org/mihomo-party-run/releases/download/${arch}/mihomo-party-run.exe`
}) })
const resolveMonitor = async () => {
const tempDir = path.join(TEMP_DIR, 'TrafficMonitor')
const tempZip = path.join(tempDir, `${arch}.zip`)
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true })
}
await downloadFile(
`https://github.com/mihomo-party-org/mihomo-party-run/releases/download/monitor/${arch}.zip`,
tempZip
)
const zip = new AdmZip(tempZip)
const resDir = path.join(cwd, 'extra', 'files')
const targetPath = path.join(resDir, 'TrafficMonitor')
if (fs.existsSync(targetPath)) {
fs.rmSync(targetPath, { recursive: true })
}
zip.extractAllTo(targetPath, true)
console.log(`[INFO]: TrafficMonitor finished`)
}
const resolve7zip = () => const resolve7zip = () =>
resolveResource({ resolveResource({
file: '7za.exe', file: '7za.exe',
@ -309,11 +282,6 @@ const resolveSubstore = () =>
downloadURL: downloadURL:
'https://github.com/sub-store-org/Sub-Store/releases/latest/download/sub-store.bundle.js' 'https://github.com/sub-store-org/Sub-Store/releases/latest/download/sub-store.bundle.js'
}) })
const resolveHelper = () =>
resolveResource({
file: 'party.mihomo.helper',
downloadURL: `https://github.com/mihomo-party-org/mihomo-party-helper/releases/download/${arch}/party.mihomo.helper`
})
const resolveSubstoreFrontend = async () => { const resolveSubstoreFrontend = async () => {
const tempDir = path.join(TEMP_DIR, 'substore-frontend') const tempDir = path.join(TEMP_DIR, 'substore-frontend')
const tempZip = path.join(tempDir, 'dist.zip') const tempZip = path.join(tempDir, 'dist.zip')
@ -361,7 +329,6 @@ const tasks = [
retry: 5 retry: 5
}, },
{ name: 'mmdb', func: resolveMmdb, retry: 5 }, { name: 'mmdb', func: resolveMmdb, retry: 5 },
{ name: 'metadb', func: resolveMetadb, retry: 5 },
{ name: 'geosite', func: resolveGeosite, retry: 5 }, { name: 'geosite', func: resolveGeosite, retry: 5 },
{ name: 'geoip', func: resolveGeoIP, retry: 5 }, { name: 'geoip', func: resolveGeoIP, retry: 5 },
{ name: 'asn', func: resolveASN, retry: 5 }, { name: 'asn', func: resolveASN, retry: 5 },
@ -388,12 +355,6 @@ const tasks = [
retry: 5, retry: 5,
winOnly: true winOnly: true
}, },
{
name: 'monitor',
func: resolveMonitor,
retry: 5,
winOnly: true
},
{ {
name: 'substore', name: 'substore',
func: resolveSubstore, func: resolveSubstore,
@ -409,12 +370,6 @@ const tasks = [
func: resolve7zip, func: resolve7zip,
retry: 5, retry: 5,
winOnly: true winOnly: true
},
{
name: 'helper',
func: resolveHelper,
retry: 5,
darwinOnly: true
} }
] ]
@ -424,7 +379,6 @@ async function runTask() {
if (task.winOnly && platform !== 'win32') return runTask() if (task.winOnly && platform !== 'win32') return runTask()
if (task.linuxOnly && platform !== 'linux') return runTask() if (task.linuxOnly && platform !== 'linux') return runTask()
if (task.unixOnly && platform === 'win32') return runTask() if (task.unixOnly && platform === 'win32') return runTask()
if (task.darwinOnly && platform !== 'darwin') return runTask()
for (let i = 0; i < task.retry; i++) { for (let i = 0; i < task.retry; i++) {
try { try {

View File

@ -1,11 +1,9 @@
import axios from 'axios' import axios from 'axios'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
const chat_id = '@MihomoPartyChannel'
const pkg = readFileSync('package.json', 'utf-8') const pkg = readFileSync('package.json', 'utf-8')
const changelog = readFileSync('changelog.md', 'utf-8') const changelog = readFileSync('changelog.md', 'utf-8')
const { version } = JSON.parse(pkg) const { version } = JSON.parse(pkg)
const downloadUrl = `https://github.com/mihomo-party-org/mihomo-party/releases/download/v${version}`
let content = `<b>🌟 <a href="https://github.com/mihomo-party-org/mihomo-party/releases/tag/v${version}">Mihomo Party v${version}</a> 正式发布</b>\n\n` let content = `<b>🌟 <a href="https://github.com/mihomo-party-org/mihomo-party/releases/tag/v${version}">Mihomo Party v${version}</a> 正式发布</b>\n\n`
for (const line of changelog.split('\n')) { for (const line of changelog.split('\n')) {
if (line.length === 0) { if (line.length === 0) {
@ -16,26 +14,8 @@ for (const line of changelog.split('\n')) {
content += `${line}\n` content += `${line}\n`
} }
} }
axios.post(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
content += '\n<b>下载地址:</b>\n<b>Windows10/11</b>\n' chat_id: '@mihomo_party_channel',
content += `安装版:<a href="${downloadUrl}/mihomo-party-windows-${version}-x64-setup.exe">64位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-ia32-setup.exe">32位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-arm64-setup.exe">ARM64</a>\n`
content += `便携版:<a href="${downloadUrl}/mihomo-party-windows-${version}-x64-portable.7z">64位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-ia32-portable.7z">32位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-arm64-portable.7z">ARM64</a>\n`
content += '\n<b>Windows7/8</b>\n'
content += `安装版:<a href="${downloadUrl}/mihomo-party-win7-${version}-x64-setup.exe">64位</a> | <a href="${downloadUrl}/mihomo-party-win7-${version}-ia32-setup.exe">32位</a>\n`
content += `便携版:<a href="${downloadUrl}/mihomo-party-win7-${version}-x64-portable.7z">64位</a> | <a href="${downloadUrl}/mihomo-party-win7-${version}-ia32-portable.7z">32位</a>\n`
content += '\n<b>macOS 11+</b>\n'
content += `PKG<a href="${downloadUrl}/mihomo-party-macos-${version}-x64.pkg
">Intel</a> | <a href="${downloadUrl}/mihomo-party-macos-${version}-arm64.pkg">Apple Silicon</a>\n`
content += '\n<b>macOS 10.15+</b>\n'
content += `PKG<a href="${downloadUrl}/mihomo-party-catalina-${version}-x64.pkg
">Intel</a> | <a href="${downloadUrl}/mihomo-party-catalina-${version}-arm64.pkg">Apple Silicon</a>\n`
content += '\n<b>Linux</b>\n'
content += `DEB<a href="${downloadUrl}/mihomo-party-linux-${version}-amd64.deb
">64位</a> | <a href="${downloadUrl}/mihomo-party-linux-${version}-arm64.deb">ARM64</a>\n`
content += `RPM<a href="${downloadUrl}/mihomo-party-linux-${version}-x86_64.rpm">64位</a> | <a href="${downloadUrl}/mihomo-party-linux-${version}-aarch64.rpm">ARM64</a>`
await axios.post(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
chat_id,
text: content, text: content,
link_preview_options: { link_preview_options: {
is_disabled: false, is_disabled: false,

View File

@ -2,27 +2,11 @@ import yaml from 'yaml'
import { readFileSync, writeFileSync } from 'fs' import { readFileSync, writeFileSync } from 'fs'
const pkg = readFileSync('package.json', 'utf-8') const pkg = readFileSync('package.json', 'utf-8')
let changelog = readFileSync('changelog.md', 'utf-8') const changelog = readFileSync('changelog.md', 'utf-8')
const { version } = JSON.parse(pkg) const { version } = JSON.parse(pkg)
const downloadUrl = `https://github.com/mihomo-party-org/mihomo-party/releases/download/v${version}`
const latest = { const latest = {
version, version,
changelog changelog
} }
changelog += '\n### 下载地址:\n\n#### Windows10/11\n\n'
changelog += `- 安装版:[64位](${downloadUrl}/mihomo-party-windows-${version}-x64-setup.exe) | [32位](${downloadUrl}/mihomo-party-windows-${version}-ia32-setup.exe) | [ARM64](${downloadUrl}/mihomo-party-windows-${version}-arm64-setup.exe)\n\n`
changelog += `- 便携版:[64位](${downloadUrl}/mihomo-party-windows-${version}-x64-portable.7z) | [32位](${downloadUrl}/mihomo-party-windows-${version}-ia32-portable.7z) | [ARM64](${downloadUrl}/mihomo-party-windows-${version}-arm64-portable.7z)\n\n`
changelog += '\n#### Windows7/8\n\n'
changelog += `- 安装版:[64位](${downloadUrl}/mihomo-party-win7-${version}-x64-setup.exe) | [32位](${downloadUrl}/mihomo-party-win7-${version}-ia32-setup.exe)\n\n`
changelog += `- 便携版:[64位](${downloadUrl}/mihomo-party-win7-${version}-x64-portable.7z) | [32位](${downloadUrl}/mihomo-party-win7-${version}-ia32-portable.7z)\n\n`
changelog += '\n#### macOS 11+\n\n'
changelog += `- PKG[Intel](${downloadUrl}/mihomo-party-macos-${version}-x64.pkg) | [Apple Silicon](${downloadUrl}/mihomo-party-macos-${version}-arm64.pkg)\n\n`
changelog += '\n#### macOS 10.15+\n\n'
changelog += `- PKG[Intel](${downloadUrl}/mihomo-party-catalina-${version}-x64.pkg) | [Apple Silicon](${downloadUrl}/mihomo-party-catalina-${version}-arm64.pkg)\n\n`
changelog += '\n#### Linux\n\n'
changelog += `- DEB[64位](${downloadUrl}/mihomo-party-linux-${version}-amd64.deb) | [ARM64](${downloadUrl}/mihomo-party-linux-${version}-arm64.deb)\n\n`
changelog += `- RPM[64位](${downloadUrl}/mihomo-party-linux-${version}-x86_64.rpm) | [ARM64](${downloadUrl}/mihomo-party-linux-${version}-aarch64.rpm)`
writeFileSync('latest.yml', yaml.stringify(latest)) writeFileSync('latest.yml', yaml.stringify(latest))
writeFileSync('changelog.md', changelog)

View File

@ -1,6 +1,7 @@
import { controledMihomoConfigPath } from '../utils/dirs' import { controledMihomoConfigPath } from '../utils/dirs'
import { readFile, writeFile } from 'fs/promises' import { readFile, writeFile } from 'fs/promises'
import yaml from 'yaml' import yaml from 'yaml'
import { getAxios } from '../core/mihomoApi'
import { generateProfile } from '../core/factory' import { generateProfile } from '../core/factory'
import { getAppConfig } from './app' import { getAppConfig } from './app'
import { defaultControledMihomoConfig } from '../utils/template' import { defaultControledMihomoConfig } from '../utils/template'
@ -51,6 +52,9 @@ export async function patchControledMihomoConfig(patch: Partial<IMihomoConfig>):
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
delete controledMihomoConfig?.tun?.device delete controledMihomoConfig?.tun?.device
} }
if (patch['external-controller'] || patch.secret) {
await getAxios(true)
}
await generateProfile() await generateProfile()
await writeFile(controledMihomoConfigPath(), yaml.stringify(controledMihomoConfig), 'utf-8') await writeFile(controledMihomoConfigPath(), yaml.stringify(controledMihomoConfig), 'utf-8')
} }

View File

@ -5,8 +5,6 @@ export {
getCurrentProfileItem, getCurrentProfileItem,
getProfileItem, getProfileItem,
getProfileConfig, getProfileConfig,
getFileStr,
setFileStr,
setProfileConfig, setProfileConfig,
addProfileItem, addProfileItem,
removeProfileItem, removeProfileItem,

View File

@ -75,8 +75,7 @@ export async function createOverride(item: Partial<IOverrideItem>): Promise<IOve
protocol: 'http', protocol: 'http',
host: '127.0.0.1', host: '127.0.0.1',
port: mixedPort port: mixedPort
}, }
responseType: 'text'
}) })
const data = res.data const data = res.data
await setOverride(id, newItem.ext, data) await setOverride(id, newItem.ext, data)

View File

@ -1,16 +1,13 @@
import { getControledMihomoConfig } from './controledMihomo' import { getControledMihomoConfig } from './controledMihomo'
import { mihomoProfileWorkDir, mihomoWorkDir, profileConfigPath, profilePath } from '../utils/dirs' import { profileConfigPath, profilePath } from '../utils/dirs'
import { addProfileUpdater } from '../core/profileUpdater' import { addProfileUpdater } from '../core/profileUpdater'
import { readFile, rm, writeFile } from 'fs/promises' import { readFile, rm, writeFile } from 'fs/promises'
import { restartCore } from '../core/manager' import { restartCore } from '../core/manager'
import { getAppConfig } from './app' import { getAppConfig } from './app'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import axios, { AxiosResponse } from 'axios' import axios from 'axios'
import yaml from 'yaml' import yaml from 'yaml'
import { defaultProfile } from '../utils/template' import { defaultProfile } from '../utils/template'
import { subStorePort } from '../resolve/server'
import { join } from 'path'
import { app } from 'electron'
let profileConfig: IProfileConfig // profile.yaml let profileConfig: IProfileConfig // profile.yaml
@ -94,9 +91,6 @@ export async function removeProfileItem(id: string): Promise<void> {
if (shouldRestart) { if (shouldRestart) {
await restartCore() await restartCore()
} }
if (existsSync(mihomoProfileWorkDir(id))) {
await rm(mihomoProfileWorkDir(id), { recursive: true })
}
} }
export async function getCurrentProfileItem(): Promise<IProfileItem> { export async function getCurrentProfileItem(): Promise<IProfileItem> {
@ -111,11 +105,9 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
name: item.name || (item.type === 'remote' ? 'Remote File' : 'Local File'), name: item.name || (item.type === 'remote' ? 'Remote File' : 'Local File'),
type: item.type, type: item.type,
url: item.url, url: item.url,
substore: item.substore || false,
interval: item.interval || 0, interval: item.interval || 0,
override: item.override || [], override: item.override || [],
useProxy: item.useProxy || false, useProxy: item.useProxy || false,
allowFixedInterval: item.allowFixedInterval || false,
updated: new Date().getTime() updated: new Date().getTime()
} as IProfileItem } as IProfileItem
switch (newItem.type) { switch (newItem.type) {
@ -123,24 +115,7 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
const { userAgent } = await getAppConfig() const { userAgent } = await getAppConfig()
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig() const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
if (!item.url) throw new Error('Empty URL') if (!item.url) throw new Error('Empty URL')
let res: AxiosResponse const res = await axios.get(item.url, {
if (newItem.substore) {
const urlObj = new URL(`http://127.0.0.1:${subStorePort}${item.url}`)
urlObj.searchParams.set('target', 'ClashMeta')
urlObj.searchParams.set('noCache', 'true')
if (newItem.useProxy) {
urlObj.searchParams.set('proxy', `http://127.0.0.1:${mixedPort}`)
} else {
urlObj.searchParams.delete('proxy')
}
res = await axios.get(urlObj.toString(), {
headers: {
'User-Agent': userAgent || `mihomo.party/v${app.getVersion()} (clash.meta)`
},
responseType: 'text'
})
} else {
res = await axios.get(item.url, {
proxy: newItem.useProxy proxy: newItem.useProxy
? { ? {
protocol: 'http', protocol: 'http',
@ -149,12 +124,9 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
} }
: false, : false,
headers: { headers: {
'User-Agent': userAgent || `mihomo.party/v${app.getVersion()} (clash.meta)` 'User-Agent': userAgent || 'clash.meta'
},
responseType: 'text'
})
} }
})
const data = res.data const data = res.data
const headers = res.headers const headers = res.headers
if (headers['content-disposition'] && newItem.name === 'Remote File') { if (headers['content-disposition'] && newItem.name === 'Remote File') {
@ -164,10 +136,8 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
newItem.home = headers['profile-web-page-url'] newItem.home = headers['profile-web-page-url']
} }
if (headers['profile-update-interval']) { if (headers['profile-update-interval']) {
if (!item.allowFixedInterval) {
newItem.interval = parseInt(headers['profile-update-interval']) * 60 newItem.interval = parseInt(headers['profile-update-interval']) * 60
} }
}
if (headers['subscription-userinfo']) { if (headers['subscription-userinfo']) {
newItem.extra = parseSubinfo(headers['subscription-userinfo']) newItem.extra = parseSubinfo(headers['subscription-userinfo'])
} }
@ -225,34 +195,3 @@ function parseSubinfo(str: string): ISubscriptionUserInfo {
}) })
return obj return obj
} }
function isAbsolutePath(path: string): boolean {
return path.startsWith('/') || /^[a-zA-Z]:\\/.test(path)
}
export async function getFileStr(path: string): Promise<string> {
const { diffWorkDir = false } = await getAppConfig()
const { current } = await getProfileConfig()
if (isAbsolutePath(path)) {
return await readFile(path, 'utf-8')
} else {
return await readFile(
join(diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), path),
'utf-8'
)
}
}
export async function setFileStr(path: string, content: string): Promise<void> {
const { diffWorkDir = false } = await getAppConfig()
const { current } = await getProfileConfig()
if (isAbsolutePath(path)) {
await writeFile(path, content, 'utf-8')
} else {
await writeFile(
join(diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), path),
content,
'utf-8'
)
}
}

View File

@ -5,65 +5,28 @@ import {
getProfileItem, getProfileItem,
getOverride, getOverride,
getOverrideItem, getOverrideItem,
getOverrideConfig, getOverrideConfig
getAppConfig
} from '../config' } from '../config'
import { import { mihomoWorkConfigPath, overridePath } from '../utils/dirs'
mihomoProfileWorkDir,
mihomoWorkConfigPath,
mihomoWorkDir,
overridePath
} from '../utils/dirs'
import yaml from 'yaml' import yaml from 'yaml'
import { copyFile, mkdir, writeFile } from 'fs/promises' import { writeFile } from 'fs/promises'
import { deepMerge } from '../utils/merge' import { deepMerge } from '../utils/merge'
import vm from 'vm' import vm from 'vm'
import { existsSync, writeFileSync } from 'fs' import { writeFileSync } from 'fs'
import path from 'path'
let runtimeConfigStr: string let runtimeConfigStr: string
let runtimeConfig: IMihomoConfig let runtimeConfig: IMihomoConfig
export async function generateProfile(): Promise<void> { export async function generateProfile(): Promise<void> {
const { current } = await getProfileConfig() const { current } = await getProfileConfig()
const { diffWorkDir = false } = await getAppConfig()
const currentProfile = await overrideProfile(current, await getProfile(current)) const currentProfile = await overrideProfile(current, await getProfile(current))
const controledMihomoConfig = await getControledMihomoConfig() const controledMihomoConfig = await getControledMihomoConfig()
const profile = deepMerge(currentProfile, controledMihomoConfig) const profile = deepMerge(currentProfile, controledMihomoConfig)
// 确保可以拿到基础日志信息 // 确保可以拿到基础日志信息
// 使用 debug 可以调试内核相关问题 `debug/pprof`
if (['info', 'debug'].includes(profile['log-level']) === false) {
profile['log-level'] = 'info' profile['log-level'] = 'info'
}
runtimeConfig = profile runtimeConfig = profile
runtimeConfigStr = yaml.stringify(profile) runtimeConfigStr = yaml.stringify(profile)
if (diffWorkDir) { await writeFile(mihomoWorkConfigPath(), runtimeConfigStr)
await prepareProfileWorkDir(current)
}
await writeFile(
diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work'),
runtimeConfigStr
)
}
async function prepareProfileWorkDir(current: string | undefined): Promise<void> {
if (!existsSync(mihomoProfileWorkDir(current))) {
await mkdir(mihomoProfileWorkDir(current), { recursive: true })
}
const copy = async (file: string): Promise<void> => {
const targetPath = path.join(mihomoProfileWorkDir(current), file)
const sourcePath = path.join(mihomoWorkDir(), file)
if (!existsSync(targetPath) && existsSync(sourcePath)) {
await copyFile(sourcePath, targetPath)
}
}
await Promise.all([
copy('country.mmdb'),
copy('geoip.metadb'),
copy('geoip.dat'),
copy('geosite.dat'),
copy('ASN.mmdb')
])
} }
async function overrideProfile( async function overrideProfile(
@ -129,7 +92,7 @@ function runOverrideScript(
log('info', '脚本执行成功') log('info', '脚本执行成功')
return newProfile return newProfile
} catch (e) { } catch (e) {
log('exception', `脚本执行失败${e}`) log('exception', `脚本执行失败: ${e}`)
return profile return profile
} }
} }

View File

@ -4,7 +4,6 @@ import {
logPath, logPath,
mihomoCoreDir, mihomoCoreDir,
mihomoCorePath, mihomoCorePath,
mihomoProfileWorkDir,
mihomoTestDir, mihomoTestDir,
mihomoWorkConfigPath, mihomoWorkConfigPath,
mihomoWorkDir mihomoWorkDir
@ -13,11 +12,10 @@ import { generateProfile } from './factory'
import { import {
getAppConfig, getAppConfig,
getControledMihomoConfig, getControledMihomoConfig,
getProfileConfig,
patchAppConfig, patchAppConfig,
patchControledMihomoConfig patchControledMihomoConfig
} from '../config' } from '../config'
import { app, dialog, ipcMain, net } from 'electron' import { app, dialog, ipcMain, net, safeStorage } from 'electron'
import { import {
startMihomoTraffic, startMihomoTraffic,
startMihomoConnections, startMihomoConnections,
@ -34,55 +32,48 @@ import { readFile, rm, writeFile } from 'fs/promises'
import { promisify } from 'util' import { promisify } from 'util'
import { mainWindow } from '..' import { mainWindow } from '..'
import path from 'path' import path from 'path'
import os from 'os' import { existsSync } from 'fs'
import { createWriteStream, existsSync } from 'fs'
import { uploadRuntimeConfig } from '../resolve/gistApi' import { uploadRuntimeConfig } from '../resolve/gistApi'
import { startMonitor } from '../resolve/trafficMonitor'
import i18next from '../../shared/i18n'
chokidar.watch(path.join(mihomoCoreDir(), 'meta-update'), {}).on('unlinkDir', async () => { chokidar.watch(path.join(mihomoCoreDir(), 'meta-update'), {}).on('unlinkDir', async () => {
try { try {
await stopCore(true) await stopCore(true)
await startCore() await startCore()
} catch (e) { } catch (e) {
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`) dialog.showErrorBox('内核启动出错', `${e}`)
} }
}) })
export const mihomoIpcPath =
process.platform === 'win32' ? '\\\\.\\pipe\\MihomoParty\\mihomo' : '/tmp/mihomo-party.sock'
const ctlParam = process.platform === 'win32' ? '-ext-ctl-pipe' : '-ext-ctl-unix'
let setPublicDNSTimer: NodeJS.Timeout | null = null let setPublicDNSTimer: NodeJS.Timeout | null = null
let recoverDNSTimer: NodeJS.Timeout | null = null let recoverDNSTimer: NodeJS.Timeout | null = null
let child: ChildProcess let child: ChildProcess
let retry = 10 let retry = 10
export async function startCore(detached = false): Promise<Promise<void>[]> { export async function startCore(detached = false): Promise<Promise<void>[]> {
const { const { core = 'mihomo', autoSetDNS = true, encryptedPassword } = await getAppConfig()
core = 'mihomo',
autoSetDNS = true,
diffWorkDir = false,
mihomoCpuPriority = 'PRIORITY_NORMAL',
disableLoopbackDetector = false,
disableEmbedCA = false,
disableSystemCA = false,
skipSafePathCheck = false
} = await getAppConfig()
const { 'log-level': logLevel } = await getControledMihomoConfig() const { 'log-level': logLevel } = await getControledMihomoConfig()
if (existsSync(path.join(dataDir(), 'core.pid'))) { if (existsSync(path.join(dataDir(), 'core.pid'))) {
const pid = parseInt(await readFile(path.join(dataDir(), 'core.pid'), 'utf-8')) const pid = parseInt(await readFile(path.join(dataDir(), 'core.pid'), 'utf-8'))
try { try {
process.kill(pid, 'SIGINT') process.kill(pid, 'SIGINT')
} catch {
if (process.platform !== 'win32' && encryptedPassword && isEncryptionAvailable()) {
const execPromise = promisify(exec)
const password = safeStorage.decryptString(Buffer.from(encryptedPassword))
try {
await execPromise(`echo "${password}" | sudo -S kill ${pid}`)
} catch { } catch {
// ignore // ignore
}
}
} finally { } finally {
await rm(path.join(dataDir(), 'core.pid')) await rm(path.join(dataDir(), 'core.pid'))
} }
} }
const { current } = await getProfileConfig()
const { tun } = await getControledMihomoConfig() const { tun } = await getControledMihomoConfig()
const corePath = mihomoCorePath(core) const corePath = mihomoCorePath(core)
await autoGrantCorePermition(corePath)
await generateProfile() await generateProfile()
await checkProfile() await checkProfile()
await stopCore() await stopCore()
@ -95,26 +86,10 @@ export async function startCore(detached = false): Promise<Promise<void>[]> {
}) })
} }
} }
const stdout = createWriteStream(logPath(), { flags: 'a' }) child = spawn(corePath, ['-d', mihomoWorkDir()], {
const stderr = createWriteStream(logPath(), { flags: 'a' })
const env = {
DISABLE_LOOPBACK_DETECTOR: String(disableLoopbackDetector),
DISABLE_EMBED_CA: String(disableEmbedCA),
DISABLE_SYSTEM_CA: String(disableSystemCA),
SKIP_SAFE_PATH_CHECK: String(skipSafePathCheck)
}
child = spawn(
corePath,
['-d', diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), ctlParam, mihomoIpcPath],
{
detached: detached, detached: detached,
stdio: detached ? 'ignore' : undefined, stdio: detached ? 'ignore' : undefined
env: env })
}
)
if (process.platform === 'win32' && child.pid) {
os.setPriority(child.pid, os.constants.priority[mihomoCpuPriority])
}
if (detached) { if (detached) {
child.unref() child.unref()
return new Promise((resolve) => { return new Promise((resolve) => {
@ -133,35 +108,36 @@ export async function startCore(detached = false): Promise<Promise<void>[]> {
await stopCore() await stopCore()
} }
}) })
child.stdout?.pipe(stdout) child.stdout?.on('data', async (data) => {
child.stderr?.pipe(stderr) await writeFile(logPath(), data, { flag: 'a' })
})
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
child.stdout?.on('data', async (data) => { child.stdout?.on('data', async (data) => {
const str = data.toString() if (data.toString().includes('configure tun interface: operation not permitted')) {
if (str.includes('configure tun interface: operation not permitted')) {
patchControledMihomoConfig({ tun: { enable: false } }) patchControledMihomoConfig({ tun: { enable: false } })
mainWindow?.webContents.send('controledMihomoConfigUpdated') mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu') ipcMain.emit('updateTrayMenu')
reject(i18next.t('tun.error.tunPermissionDenied')) reject('虚拟网卡启动失败, 请尝试手动授予内核权限')
} }
if (data.toString().includes('External controller listen error')) {
if ((process.platform !== 'win32' && str.includes('External controller unix listen error')) || if (retry) {
(process.platform === 'win32' && str.includes('External controller pipe listen error')) retry--
) { try {
reject(i18next.t('mihomo.error.externalControllerListenError')) resolve(await startCore())
} catch (e) {
reject(e)
} }
} else {
if ( reject('内核连接失败, 请尝试修改外部控制端口或重启电脑')
(process.platform !== 'win32' && str.includes('RESTful API unix listening at')) || }
(process.platform === 'win32' && str.includes('RESTful API pipe listening at')) }
) { if (data.toString().includes('RESTful API listening at')) {
resolve([ resolve([
new Promise((resolve) => { new Promise((resolve) => {
child.stdout?.on('data', async (data) => { child.stdout?.on('data', async (data) => {
if (data.toString().toLowerCase().includes('start initial compatible provider default')) { if (data.toString().includes('Start initial Compatible provider default')) {
try { try {
mainWindow?.webContents.send('groupsUpdated') mainWindow?.webContents.send('coreRestart')
mainWindow?.webContents.send('rulesUpdated')
await uploadRuntimeConfig() await uploadRuntimeConfig()
} catch { } catch {
// ignore // ignore
@ -207,7 +183,7 @@ export async function restartCore(): Promise<void> {
try { try {
await startCore() await startCore()
} catch (e) { } catch (e) {
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`) dialog.showErrorBox('内核启动出错', `${e}`)
} }
} }
@ -218,36 +194,21 @@ export async function keepCoreAlive(): Promise<void> {
await writeFile(path.join(dataDir(), 'core.pid'), child.pid.toString()) await writeFile(path.join(dataDir(), 'core.pid'), child.pid.toString())
} }
} catch (e) { } catch (e) {
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`) dialog.showErrorBox('内核启动出错', `${e}`)
} }
} }
export async function quitWithoutCore(): Promise<void> { export async function quitWithoutCore(): Promise<void> {
await keepCoreAlive() await keepCoreAlive()
await startMonitor(true)
app.exit() app.exit()
} }
async function checkProfile(): Promise<void> { async function checkProfile(): Promise<void> {
const { const { core = 'mihomo' } = await getAppConfig()
core = 'mihomo',
diffWorkDir = false,
skipSafePathCheck = false
} = await getAppConfig()
const { current } = await getProfileConfig()
const corePath = mihomoCorePath(core) const corePath = mihomoCorePath(core)
const execFilePromise = promisify(execFile) const execFilePromise = promisify(execFile)
const env = {
SKIP_SAFE_PATH_CHECK: String(skipSafePathCheck)
}
try { try {
await execFilePromise(corePath, [ await execFilePromise(corePath, ['-t', '-f', mihomoWorkConfigPath(), '-d', mihomoTestDir()])
'-t',
'-f',
diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work'),
'-d',
mihomoTestDir()
], { env })
} catch (error) { } catch (error) {
if (error instanceof Error && 'stdout' in error) { if (error instanceof Error && 'stdout' in error) {
const { stdout } = error as { stdout: string } const { stdout } = error as { stdout: string }
@ -255,45 +216,71 @@ async function checkProfile(): Promise<void> {
.split('\n') .split('\n')
.filter((line) => line.includes('level=error')) .filter((line) => line.includes('level=error'))
.map((line) => line.split('level=error')[1]) .map((line) => line.split('level=error')[1])
throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}:\n${errorLines.join('\n')}`) throw new Error(`Profile Check Failed:\n${errorLines.join('\n')}`)
} else { } else {
throw error throw error
} }
} }
} }
export async function manualGrantCorePermition(): Promise<void> { export async function autoGrantCorePermition(corePath: string): Promise<void> {
if (process.platform === 'win32') return
const { encryptedPassword } = await getAppConfig()
const execPromise = promisify(exec)
if (encryptedPassword && isEncryptionAvailable()) {
try {
const password = safeStorage.decryptString(Buffer.from(encryptedPassword))
if (process.platform === 'linux') {
await execPromise(`echo "${password}" | sudo -S chown root:root "${corePath}"`)
await execPromise(`echo "${password}" | sudo -S chmod +sx "${corePath}"`)
}
if (process.platform === 'darwin') {
await execPromise(`echo "${password}" | sudo -S chown root:admin "${corePath}"`)
await execPromise(`echo "${password}" | sudo -S chmod +sx "${corePath}"`)
}
} catch (error) {
patchAppConfig({ encryptedPassword: undefined })
throw error
}
}
}
export async function manualGrantCorePermition(password?: string): Promise<void> {
const { core = 'mihomo' } = await getAppConfig() const { core = 'mihomo' } = await getAppConfig()
const corePath = mihomoCorePath(core) const corePath = mihomoCorePath(core)
const execPromise = promisify(exec) const execPromise = promisify(exec)
const execFilePromise = promisify(execFile)
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
const shell = `chown root:admin ${corePath.replace(' ', '\\\\ ')}\nchmod +sx ${corePath.replace(' ', '\\\\ ')}` const shell = `chown root:admin ${corePath.replace(' ', '\\\\ ')}\nchmod +sx ${corePath.replace(' ', '\\\\ ')}`
const command = `do shell script "${shell}" with administrator privileges` const command = `do shell script "${shell}" with administrator privileges`
await execPromise(`osascript -e '${command}'`) await execPromise(`osascript -e '${command}'`)
} }
if (process.platform === 'linux') { if (process.platform === 'linux') {
await execFilePromise('pkexec', [ await execPromise(`echo "${password}" | sudo -S chown root:root "${corePath}"`)
'bash', await execPromise(`echo "${password}" | sudo -S chmod +sx "${corePath}"`)
'-c',
`chown root:root "${corePath}" && chmod +sx "${corePath}"`
])
} }
} }
export async function getDefaultDevice(): Promise<string> { export function isEncryptionAvailable(): boolean {
return safeStorage.isEncryptionAvailable()
}
export async function getDefaultDevice(password?: string): Promise<string> {
const execPromise = promisify(exec) const execPromise = promisify(exec)
const { stdout: deviceOut } = await execPromise(`route -n get default`) let sudo = ''
if (password) sudo = `echo "${password}" | sudo -S `
const { stdout: deviceOut } = await execPromise(`${sudo}route -n get default`)
let device = deviceOut.split('\n').find((s) => s.includes('interface:')) let device = deviceOut.split('\n').find((s) => s.includes('interface:'))
device = device?.trim().split(' ').slice(1).join(' ') device = device?.trim().split(' ').slice(1).join(' ')
if (!device) throw new Error('Get device failed') if (!device) throw new Error('Get device failed')
return device return device
} }
async function getDefaultService(): Promise<string> { async function getDefaultService(password?: string): Promise<string> {
const execPromise = promisify(exec) const execPromise = promisify(exec)
const device = await getDefaultDevice() let sudo = ''
const { stdout: order } = await execPromise(`networksetup -listnetworkserviceorder`) if (password) sudo = `echo "${password}" | sudo -S `
const device = await getDefaultDevice(password)
const { stdout: order } = await execPromise(`${sudo}networksetup -listnetworkserviceorder`)
const block = order.split('\n\n').find((s) => s.includes(`Device: ${device}`)) const block = order.split('\n\n').find((s) => s.includes(`Device: ${device}`))
if (!block) throw new Error('Get networkservice failed') if (!block) throw new Error('Get networkservice failed')
for (const line of block.split('\n')) { for (const line of block.split('\n')) {
@ -304,10 +291,12 @@ async function getDefaultService(): Promise<string> {
throw new Error('Get service failed') throw new Error('Get service failed')
} }
async function getOriginDNS(): Promise<void> { async function getOriginDNS(password?: string): Promise<void> {
const execPromise = promisify(exec) const execPromise = promisify(exec)
const service = await getDefaultService() let sudo = ''
const { stdout: dns } = await execPromise(`networksetup -getdnsservers "${service}"`) if (password) sudo = `echo "${password}" | sudo -S `
const service = await getDefaultService(password)
const { stdout: dns } = await execPromise(`${sudo}networksetup -getdnsservers "${service}"`)
if (dns.startsWith("There aren't any DNS Servers set on")) { if (dns.startsWith("There aren't any DNS Servers set on")) {
await patchAppConfig({ originDNS: 'Empty' }) await patchAppConfig({ originDNS: 'Empty' })
} else { } else {
@ -315,19 +304,25 @@ async function getOriginDNS(): Promise<void> {
} }
} }
async function setDNS(dns: string): Promise<void> { async function setDNS(dns: string, password?: string): Promise<void> {
const service = await getDefaultService() const service = await getDefaultService(password)
let sudo = ''
if (password) sudo = `echo "${password}" | sudo -S `
const execPromise = promisify(exec) const execPromise = promisify(exec)
await execPromise(`networksetup -setdnsservers "${service}" ${dns}`) await execPromise(`${sudo}networksetup -setdnsservers "${service}" ${dns}`)
} }
async function setPublicDNS(): Promise<void> { async function setPublicDNS(): Promise<void> {
if (process.platform !== 'darwin') return if (process.platform !== 'darwin') return
if (net.isOnline()) { if (net.isOnline()) {
const { originDNS } = await getAppConfig() const { originDNS, encryptedPassword } = await getAppConfig()
if (!originDNS) { if (!originDNS) {
await getOriginDNS() let password: string | undefined
await setDNS('223.5.5.5') if (encryptedPassword && isEncryptionAvailable()) {
password = safeStorage.decryptString(Buffer.from(encryptedPassword))
}
await getOriginDNS(password)
await setDNS('223.5.5.5', password)
} }
} else { } else {
if (setPublicDNSTimer) clearTimeout(setPublicDNSTimer) if (setPublicDNSTimer) clearTimeout(setPublicDNSTimer)
@ -338,9 +333,13 @@ async function setPublicDNS(): Promise<void> {
async function recoverDNS(): Promise<void> { async function recoverDNS(): Promise<void> {
if (process.platform !== 'darwin') return if (process.platform !== 'darwin') return
if (net.isOnline()) { if (net.isOnline()) {
const { originDNS } = await getAppConfig() const { originDNS, encryptedPassword } = await getAppConfig()
if (originDNS) { if (originDNS) {
await setDNS(originDNS) let password: string | undefined
if (encryptedPassword && isEncryptionAvailable()) {
password = safeStorage.decryptString(Buffer.from(encryptedPassword))
}
await setDNS(originDNS, password)
await patchAppConfig({ originDNS: undefined }) await patchAppConfig({ originDNS: undefined })
} }
} else { } else {

View File

@ -5,8 +5,6 @@ import WebSocket from 'ws'
import { tray } from '../resolve/tray' import { tray } from '../resolve/tray'
import { calcTraffic } from '../utils/calc' import { calcTraffic } from '../utils/calc'
import { getRuntimeConfig } from './factory' import { getRuntimeConfig } from './factory'
import { floatingWindow } from '../resolve/floatingWindow'
import { mihomoIpcPath } from './manager'
let axiosIns: AxiosInstance = null! let axiosIns: AxiosInstance = null!
let mihomoTrafficWs: WebSocket | null = null let mihomoTrafficWs: WebSocket | null = null
@ -20,10 +18,15 @@ let connectionsRetry = 10
export const getAxios = async (force: boolean = false): Promise<AxiosInstance> => { export const getAxios = async (force: boolean = false): Promise<AxiosInstance> => {
if (axiosIns && !force) return axiosIns if (axiosIns && !force) return axiosIns
const controledMihomoConfig = await getControledMihomoConfig()
let server = controledMihomoConfig['external-controller']
const secret = controledMihomoConfig.secret ?? ''
if (server?.startsWith(':')) server = `127.0.0.1${server}`
axiosIns = axios.create({ axiosIns = axios.create({
baseURL: `http://localhost`, baseURL: `http://${server}`,
socketPath: mihomoIpcPath, proxy: false,
headers: secret ? { Authorization: `Bearer ${secret}` } : {},
timeout: 15000 timeout: 15000
}) })
@ -76,8 +79,6 @@ export const mihomoProxies = async (): Promise<IMihomoProxies> => {
} }
export const mihomoGroups = async (): Promise<IMihomoMixedGroup[]> => { export const mihomoGroups = async (): Promise<IMihomoMixedGroup[]> => {
const { mode = 'rule' } = await getControledMihomoConfig()
if (mode === 'direct') return []
const proxies = await mihomoProxies() const proxies = await mihomoProxies()
const runtime = await getRuntimeConfig() const runtime = await getRuntimeConfig()
const groups: IMihomoMixedGroup[] = [] const groups: IMihomoMixedGroup[] = []
@ -97,10 +98,6 @@ export const mihomoGroups = async (): Promise<IMihomoMixedGroup[]> => {
groups.push({ ...newGlobal, all: newAll }) groups.push({ ...newGlobal, all: newAll })
} }
} }
if (mode === 'global') {
const global = groups.findIndex((group) => group.name === 'GLOBAL')
groups.unshift(groups.splice(global, 1)[0])
}
return groups return groups
} }
@ -129,11 +126,6 @@ export const mihomoChangeProxy = async (group: string, proxy: string): Promise<I
return await instance.put(`/proxies/${encodeURIComponent(group)}`, { name: proxy }) return await instance.put(`/proxies/${encodeURIComponent(group)}`, { name: proxy })
} }
export const mihomoUnfixedProxy = async (group: string): Promise<IMihomoProxy> => {
const instance = await getAxios()
return await instance.delete(`/proxies/${encodeURIComponent(group)}`)
}
export const mihomoUpgradeGeo = async (): Promise<void> => { export const mihomoUpgradeGeo = async (): Promise<void> => {
const instance = await getAxios() const instance = await getAxios()
return await instance.post('/configs/geo') return await instance.post('/configs/geo')
@ -145,7 +137,7 @@ export const mihomoProxyDelay = async (proxy: string, url?: string): Promise<IMi
const instance = await getAxios() const instance = await getAxios()
return await instance.get(`/proxies/${encodeURIComponent(proxy)}/delay`, { return await instance.get(`/proxies/${encodeURIComponent(proxy)}/delay`, {
params: { params: {
url: url || delayTestUrl || 'http://www.gstatic.com/generate_204', url: url || delayTestUrl || 'https://www.gstatic.com/generate_204',
timeout: delayTestTimeout || 5000 timeout: delayTestTimeout || 5000
} }
}) })
@ -157,7 +149,7 @@ export const mihomoGroupDelay = async (group: string, url?: string): Promise<IMi
const instance = await getAxios() const instance = await getAxios()
return await instance.get(`/group/${encodeURIComponent(group)}/delay`, { return await instance.get(`/group/${encodeURIComponent(group)}/delay`, {
params: { params: {
url: url || delayTestUrl || 'http://www.gstatic.com/generate_204', url: url || delayTestUrl || 'https://www.gstatic.com/generate_204',
timeout: delayTestTimeout || 5000 timeout: delayTestTimeout || 5000
} }
}) })
@ -183,7 +175,13 @@ export const stopMihomoTraffic = (): void => {
} }
const mihomoTraffic = async (): Promise<void> => { const mihomoTraffic = async (): Promise<void> => {
mihomoTrafficWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/traffic`) const controledMihomoConfig = await getControledMihomoConfig()
let server = controledMihomoConfig['external-controller']
const secret = controledMihomoConfig.secret ?? ''
if (server?.startsWith(':')) server = `127.0.0.1${server}`
stopMihomoTraffic()
mihomoTrafficWs = new WebSocket(`ws://${server}/traffic?token=${encodeURIComponent(secret)}`)
mihomoTrafficWs.onmessage = async (e): Promise<void> => { mihomoTrafficWs.onmessage = async (e): Promise<void> => {
const data = e.data as string const data = e.data as string
@ -199,7 +197,6 @@ const mihomoTraffic = async (): Promise<void> => {
`${calcTraffic(json.down)}/s`.padStart(9) `${calcTraffic(json.down)}/s`.padStart(9)
) )
} }
floatingWindow?.webContents.send('mihomoTraffic', json)
} catch { } catch {
// ignore // ignore
} }
@ -235,7 +232,13 @@ export const stopMihomoMemory = (): void => {
} }
const mihomoMemory = async (): Promise<void> => { const mihomoMemory = async (): Promise<void> => {
mihomoMemoryWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/memory`) const controledMihomoConfig = await getControledMihomoConfig()
let server = controledMihomoConfig['external-controller']
const secret = controledMihomoConfig.secret ?? ''
if (server?.startsWith(':')) server = `127.0.0.1${server}`
stopMihomoMemory()
mihomoMemoryWs = new WebSocket(`ws://${server}/memory?token=${encodeURIComponent(secret)}`)
mihomoMemoryWs.onmessage = (e): void => { mihomoMemoryWs.onmessage = (e): void => {
const data = e.data as string const data = e.data as string
@ -277,9 +280,15 @@ export const stopMihomoLogs = (): void => {
} }
const mihomoLogs = async (): Promise<void> => { const mihomoLogs = async (): Promise<void> => {
const { 'log-level': logLevel = 'info' } = await getControledMihomoConfig() const controledMihomoConfig = await getControledMihomoConfig()
const { secret = '', 'log-level': level = 'info' } = controledMihomoConfig
let { 'external-controller': server } = controledMihomoConfig
if (server?.startsWith(':')) server = `127.0.0.1${server}`
stopMihomoLogs()
mihomoLogsWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/logs?level=${logLevel}`) mihomoLogsWs = new WebSocket(
`ws://${server}/logs?token=${encodeURIComponent(secret)}&level=${level}`
)
mihomoLogsWs.onmessage = (e): void => { mihomoLogsWs.onmessage = (e): void => {
const data = e.data as string const data = e.data as string
@ -321,7 +330,15 @@ export const stopMihomoConnections = (): void => {
} }
const mihomoConnections = async (): Promise<void> => { const mihomoConnections = async (): Promise<void> => {
mihomoConnectionsWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/connections`) const controledMihomoConfig = await getControledMihomoConfig()
let server = controledMihomoConfig['external-controller']
const secret = controledMihomoConfig.secret ?? ''
if (server?.startsWith(':')) server = `127.0.0.1${server}`
stopMihomoConnections()
mihomoConnectionsWs = new WebSocket(
`ws://${server}/connections?token=${encodeURIComponent(secret)}`
)
mihomoConnectionsWs.onmessage = (e): void => { mihomoConnectionsWs.onmessage = (e): void => {
const data = e.data as string const data = e.data as string

View File

@ -5,13 +5,13 @@ import { getAppConfig } from '../config'
export async function subStoreSubs(): Promise<ISubStoreSub[]> { export async function subStoreSubs(): Promise<ISubStoreSub[]> {
const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig() const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig()
const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}` const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}`
const res = await axios.get(`${baseUrl}/api/subs`, { responseType: 'json' }) const res = await axios.get(`${baseUrl}/api/subs`)
return res.data.data as ISubStoreSub[] return res.data.data as ISubStoreSub[]
} }
export async function subStoreCollections(): Promise<ISubStoreSub[]> { export async function subStoreCollections(): Promise<ISubStoreSub[]> {
const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig() const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig()
const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}` const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}`
const res = await axios.get(`${baseUrl}/api/collections`, { responseType: 'json' }) const res = await axios.get(`${baseUrl}/api/collections`)
return res.data.data as ISubStoreSub[] return res.data.data as ISubStoreSub[]
} }

View File

@ -1,59 +1,28 @@
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { registerIpcMainHandlers } from './utils/ipc' import { registerIpcMainHandlers } from './utils/ipc'
import windowStateKeeper from 'electron-window-state' import windowStateKeeper from 'electron-window-state'
import { app, shell, BrowserWindow, Menu, dialog, Notification, powerMonitor } from 'electron' import { app, shell, BrowserWindow, Menu, dialog, Notification } from 'electron'
import { addProfileItem, getAppConfig, patchAppConfig } from './config' import { addProfileItem, getAppConfig } from './config'
import { quitWithoutCore, startCore, stopCore } from './core/manager' import { quitWithoutCore, startCore, stopCore } from './core/manager'
import { triggerSysProxy } from './sys/sysproxy' import { triggerSysProxy } from './sys/sysproxy'
import icon from '../../resources/icon.png?asset' import icon from '../../resources/icon.png?asset'
import { createTray, hideDockIcon, showDockIcon } from './resolve/tray' import { createTray } from './resolve/tray'
import { init } from './utils/init' import { init } from './utils/init'
import { join } from 'path' import { join } from 'path'
import { initShortcut } from './resolve/shortcut' import { initShortcut } from './resolve/shortcut'
import { execSync, spawn, exec } from 'child_process' import { execSync } from 'child_process'
import { createElevateTask } from './sys/misc' import { createElevateTask } from './sys/misc'
import { promisify } from 'util'
import { stat } from 'fs/promises'
import { initProfileUpdater } from './core/profileUpdater' import { initProfileUpdater } from './core/profileUpdater'
import { existsSync, writeFileSync } from 'fs' import { existsSync, writeFileSync } from 'fs'
import { exePath, taskDir } from './utils/dirs' import { taskDir } from './utils/dirs'
import path from 'path' import path from 'path'
import { startMonitor } from './resolve/trafficMonitor'
import { showFloatingWindow } from './resolve/floatingWindow'
import iconv from 'iconv-lite'
import { initI18n } from '../shared/i18n'
import i18next from 'i18next'
async function fixUserDataPermissions(): Promise<void> {
if (process.platform !== 'darwin') return
const userDataPath = app.getPath('userData')
if (!existsSync(userDataPath)) return
try {
const stats = await stat(userDataPath)
const currentUid = process.getuid?.() || 0
if (stats.uid === 0 && currentUid !== 0) {
const execPromise = promisify(exec)
const username = process.env.USER || process.env.LOGNAME
if (username) {
await execPromise(`chown -R "${username}:staff" "${userDataPath}"`)
await execPromise(`chmod -R u+rwX "${userDataPath}"`)
}
}
} catch {
// ignore
}
}
let quitTimeout: NodeJS.Timeout | null = null let quitTimeout: NodeJS.Timeout | null = null
export let mainWindow: BrowserWindow | null = null export let mainWindow: BrowserWindow | null = null
if (process.platform === 'win32' && !is.dev) {
if (process.platform === 'win32' && !is.dev && !process.argv.includes('noadmin')) {
try { try {
createElevateTask() createElevateTask()
} catch (createError) { } catch (e) {
try { try {
if (process.argv.slice(1).length > 0) { if (process.argv.slice(1).length > 0) {
writeFileSync(path.join(taskDir(), 'param.txt'), process.argv.slice(1).join(' ')) writeFileSync(path.join(taskDir(), 'param.txt'), process.argv.slice(1).join(' '))
@ -63,67 +32,23 @@ if (process.platform === 'win32' && !is.dev && !process.argv.includes('noadmin')
if (!existsSync(path.join(taskDir(), 'mihomo-party-run.exe'))) { if (!existsSync(path.join(taskDir(), 'mihomo-party-run.exe'))) {
throw new Error('mihomo-party-run.exe not found') throw new Error('mihomo-party-run.exe not found')
} else { } else {
execSync('%SystemRoot%\\System32\\schtasks.exe /run /tn mihomo-party-run') execSync('schtasks /run /tn mihomo-party-run')
} }
} catch (e) { } catch (e) {
let createErrorStr = `${createError}` dialog.showErrorBox('首次启动请以管理员权限运行', '首次启动请以管理员权限运行')
let eStr = `${e}`
try {
createErrorStr = iconv.decode((createError as { stderr: Buffer }).stderr, 'gbk')
eStr = iconv.decode((e as { stderr: Buffer }).stderr, 'gbk')
} catch {
// ignore
}
dialog.showErrorBox(
i18next.t('common.error.adminRequired'),
`${i18next.t('common.error.adminRequired')}\n${createErrorStr}\n${eStr}`
)
} finally { } finally {
app.exit() app.exit()
} }
} }
} }
async function initApp(): Promise<void> { const gotTheLock = app.requestSingleInstanceLock()
await fixUserDataPermissions()
}
initApp() if (!gotTheLock) {
.then(() => {
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit() app.quit()
}
})
.catch(() => {
// ignore permission fix errors
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
}
})
export function customRelaunch(): void {
const script = `while kill -0 ${process.pid} 2>/dev/null; do
sleep 0.1
done
${process.argv.join(' ')} & disown
exit
`
spawn('sh', ['-c', `"${script}"`], {
shell: true,
detached: true,
stdio: 'ignore'
})
} }
if (process.platform === 'linux') { if (process.platform === 'win32') {
app.relaunch = customRelaunch
}
if (process.platform === 'win32' && !exePath().startsWith('C')) {
// https://github.com/electron/electron/issues/43278 // https://github.com/electron/electron/issues/43278
// https://github.com/electron/electron/issues/36698 // https://github.com/electron/electron/issues/36698
app.commandLine.appendSwitch('in-process-gpu') app.commandLine.appendSwitch('in-process-gpu')
@ -143,45 +68,32 @@ app.on('open-url', async (_event, url) => {
showMainWindow() showMainWindow()
await handleDeepLink(url) await handleDeepLink(url)
}) })
// Quit when all windows are closed, except on macOS. There, it's common
app.on('before-quit', async (e) => { // for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', (e) => {
e.preventDefault() e.preventDefault()
triggerSysProxy(false) // if (process.platform !== 'darwin') {
await stopCore() // app.quit()
app.exit() // }
}) })
powerMonitor.on('shutdown', async () => { app.on('before-quit', async () => {
triggerSysProxy(false)
await stopCore() await stopCore()
triggerSysProxy(false)
app.exit() app.exit()
}) })
// 获取系统语言
function getSystemLanguage(): 'zh-CN' | 'en-US' {
const locale = app.getLocale()
return locale.startsWith('zh') ? 'zh-CN' : 'en-US'
}
// This method will be called when Electron has finished // This method will be called when Electron has finished
// initialization and is ready to create browser windows. // initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs. // Some APIs can only be used after this event occurs.
app.whenReady().then(async () => { app.whenReady().then(async () => {
// Set app user model id for windows // Set app user model id for windows
electronApp.setAppUserModelId('party.mihomo.app') electronApp.setAppUserModelId('party.mihomo.app')
try { try {
const appConfig = await getAppConfig()
// 如果配置中没有语言设置,则使用系统语言
if (!appConfig.language) {
const systemLanguage = getSystemLanguage()
await patchAppConfig({ language: systemLanguage })
appConfig.language = systemLanguage
}
await initI18n({ lng: appConfig.language })
await initPromise await initPromise
} catch (e) { } catch (e) {
dialog.showErrorBox(i18next.t('common.error.initFailed'), `${e}`) dialog.showErrorBox('应用初始化失败', `${e}`)
app.quit() app.quit()
} }
try { try {
@ -190,29 +102,17 @@ app.whenReady().then(async () => {
await initProfileUpdater() await initProfileUpdater()
}) })
} catch (e) { } catch (e) {
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`) dialog.showErrorBox('内核启动出错', `${e}`)
} }
try {
await startMonitor()
} catch {
// ignore
}
// Default open or close DevTools by F12 in development // Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production. // and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => { app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window) optimizer.watchWindowShortcuts(window)
}) })
const { showFloatingWindow: showFloating = false, disableTray = false } = await getAppConfig()
registerIpcMainHandlers() registerIpcMainHandlers()
await createWindow() await createWindow()
if (showFloating) {
showFloatingWindow()
}
if (!disableTray) {
await createTray() await createTray()
}
await initShortcut() await initShortcut()
app.on('activate', function () { app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the // On macOS it's common to re-create a window in the app when the
@ -231,7 +131,7 @@ async function handleDeepLink(url: string): Promise<void> {
const profileUrl = urlObj.searchParams.get('url') const profileUrl = urlObj.searchParams.get('url')
const profileName = urlObj.searchParams.get('name') const profileName = urlObj.searchParams.get('name')
if (!profileUrl) { if (!profileUrl) {
throw new Error(i18next.t('profiles.error.urlParamMissing')) throw new Error('缺少参数 url')
} }
await addProfileItem({ await addProfileItem({
type: 'remote', type: 'remote',
@ -239,10 +139,10 @@ async function handleDeepLink(url: string): Promise<void> {
url: profileUrl url: profileUrl
}) })
mainWindow?.webContents.send('profileConfigUpdated') mainWindow?.webContents.send('profileConfigUpdated')
new Notification({ title: i18next.t('profiles.notification.importSuccess') }).show() new Notification({ title: '订阅导入成功' }).show()
break break
} catch (e) { } catch (e) {
dialog.showErrorBox(i18next.t('profiles.error.importFailed'), `${url}\n${e}`) dialog.showErrorBox('订阅导入失败', `${url}\n${e}`)
} }
} }
} }
@ -252,8 +152,7 @@ export async function createWindow(): Promise<void> {
const { useWindowFrame = false } = await getAppConfig() const { useWindowFrame = false } = await getAppConfig()
const mainWindowState = windowStateKeeper({ const mainWindowState = windowStateKeeper({
defaultWidth: 800, defaultWidth: 800,
defaultHeight: 600, defaultHeight: 600
file: 'window-state.json'
}) })
// https://github.com/electron/electron/issues/16521#issuecomment-582955104 // https://github.com/electron/electron/issues/16521#issuecomment-582955104
Menu.setApplicationMenu(null) Menu.setApplicationMenu(null)
@ -278,8 +177,7 @@ export async function createWindow(): Promise<void> {
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.js'),
spellcheck: false, spellcheck: false,
sandbox: false, sandbox: false
devTools: true
} }
}) })
mainWindowState.manage(mainWindow) mainWindowState.manage(mainWindow)
@ -309,21 +207,10 @@ export async function createWindow(): Promise<void> {
mainWindow?.webContents.reload() mainWindow?.webContents.reload()
}) })
mainWindow.on('show', () => {
showDockIcon()
})
mainWindow.on('close', async (event) => { mainWindow.on('close', async (event) => {
event.preventDefault() event.preventDefault()
mainWindow?.hide() mainWindow?.hide()
const { const { autoQuitWithoutCore = false, autoQuitWithoutCoreDelay = 60 } = await getAppConfig()
autoQuitWithoutCore = false,
autoQuitWithoutCoreDelay = 60,
useDockIcon = true
} = await getAppConfig()
if (!useDockIcon) {
hideDockIcon()
}
if (autoQuitWithoutCore) { if (autoQuitWithoutCore) {
if (quitTimeout) { if (quitTimeout) {
clearTimeout(quitTimeout) clearTimeout(quitTimeout)
@ -334,29 +221,11 @@ export async function createWindow(): Promise<void> {
} }
}) })
mainWindow.on('resized', () => {
if (mainWindow) mainWindowState.saveState(mainWindow)
})
mainWindow.on('move', () => {
if (mainWindow) mainWindowState.saveState(mainWindow)
})
mainWindow.on('session-end', async () => {
triggerSysProxy(false)
await stopCore()
})
mainWindow.webContents.setWindowOpenHandler((details) => { mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url) shell.openExternal(details.url)
return { action: 'deny' } return { action: 'deny' }
}) })
// 在开发模式下自动打开 DevTools
if (is.dev) {
mainWindow.webContents.openDevTools()
}
// HMR for renderer base on electron-vite cli. // HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production. // Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) { if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
@ -366,14 +235,6 @@ export async function createWindow(): Promise<void> {
} }
} }
export function triggerMainWindow(): void {
if (mainWindow?.isVisible()) {
closeMainWindow()
} else {
showMainWindow()
}
}
export function showMainWindow(): void { export function showMainWindow(): void {
if (mainWindow) { if (mainWindow) {
if (quitTimeout) { if (quitTimeout) {
@ -383,9 +244,3 @@ export function showMainWindow(): void {
mainWindow.focusOnWebView() mainWindow.focusOnWebView()
} }
} }
export function closeMainWindow(): void {
if (mainWindow) {
mainWindow.close()
}
}

View File

@ -3,11 +3,11 @@ import yaml from 'yaml'
import { app, shell } from 'electron' import { app, shell } from 'electron'
import { getControledMihomoConfig } from '../config' import { getControledMihomoConfig } from '../config'
import { dataDir, exeDir, exePath, isPortable, resourcesFilesDir } from '../utils/dirs' import { dataDir, exeDir, exePath, isPortable, resourcesFilesDir } from '../utils/dirs'
import { copyFile, rm, writeFile } from 'fs/promises' import { rm, writeFile } from 'fs/promises'
import path from 'path' import path from 'path'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import os from 'os' import os from 'os'
import { exec, execSync, spawn } from 'child_process' import { exec, spawn } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
export async function checkUpdate(): Promise<IAppVersion | undefined> { export async function checkUpdate(): Promise<IAppVersion | undefined> {
@ -20,8 +20,7 @@ export async function checkUpdate(): Promise<IAppVersion | undefined> {
protocol: 'http', protocol: 'http',
host: '127.0.0.1', host: '127.0.0.1',
port: mixedPort port: mixedPort
}, }
responseType: 'text'
} }
) )
const latest = yaml.parse(res.data, { merge: true }) as IAppVersion const latest = yaml.parse(res.data, { merge: true }) as IAppVersion
@ -40,8 +39,8 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
'win32-x64': `mihomo-party-windows-${version}-x64-setup.exe`, 'win32-x64': `mihomo-party-windows-${version}-x64-setup.exe`,
'win32-ia32': `mihomo-party-windows-${version}-ia32-setup.exe`, 'win32-ia32': `mihomo-party-windows-${version}-ia32-setup.exe`,
'win32-arm64': `mihomo-party-windows-${version}-arm64-setup.exe`, 'win32-arm64': `mihomo-party-windows-${version}-arm64-setup.exe`,
'darwin-x64': `mihomo-party-macos-${version}-x64.pkg`, 'darwin-x64': `mihomo-party-macos-${version}-x64.dmg`,
'darwin-arm64': `mihomo-party-macos-${version}-arm64.pkg` 'darwin-arm64': `mihomo-party-macos-${version}-arm64.dmg`
} }
let file = fileMap[`${process.platform}-${process.arch}`] let file = fileMap[`${process.platform}-${process.arch}`]
if (isPortable()) { if (isPortable()) {
@ -53,14 +52,6 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
if (process.platform === 'win32' && parseInt(os.release()) < 10) { if (process.platform === 'win32' && parseInt(os.release()) < 10) {
file = file.replace('windows', 'win7') file = file.replace('windows', 'win7')
} }
if (process.platform === 'darwin') {
const productVersion = execSync('sw_vers -productVersion', { encoding: 'utf8' })
.toString()
.trim()
if (parseInt(productVersion) < 11) {
file = file.replace('macos', 'catalina')
}
}
try { try {
if (!existsSync(path.join(dataDir(), file))) { if (!existsSync(path.join(dataDir(), file))) {
const res = await axios.get(`${baseUrl}${file}`, { const res = await axios.get(`${baseUrl}${file}`, {
@ -83,13 +74,9 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
}).unref() }).unref()
} }
if (file.endsWith('.7z')) { if (file.endsWith('.7z')) {
await copyFile(path.join(resourcesFilesDir(), '7za.exe'), path.join(dataDir(), '7za.exe'))
spawn( spawn(
'cmd', path.join(resourcesFilesDir(), '7za.exe'),
[ ['x', `-o"${exeDir()}"`, '-y', path.join(dataDir(), file)],
'/C',
`"timeout /t 2 /nobreak >nul && "${path.join(dataDir(), '7za.exe')}" x -o"${exeDir()}" -y "${path.join(dataDir(), file)}" & start "" "${exePath()}""`
],
{ {
shell: true, shell: true,
detached: true detached: true
@ -97,12 +84,23 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
).unref() ).unref()
app.quit() app.quit()
} }
if (file.endsWith('.pkg')) { if (file.endsWith('.dmg')) {
try { try {
const execPromise = promisify(exec) const execPromise = promisify(exec)
const shell = `installer -pkg ${path.join(dataDir(), file).replace(' ', '\\\\ ')} -target /` const name = exePath().split('.app')[0].replace('/Applications/', '')
const command = `do shell script "${shell}" with administrator privileges` await execPromise(
await execPromise(`osascript -e '${command}'`) `hdiutil attach "${path.join(dataDir(), file)}" -mountpoint "/Volumes/mihomo-party" -nobrowse`
)
try {
await execPromise(`mv /Applications/${name}.app /tmp`)
await execPromise('cp -R "/Volumes/mihomo-party/mihomo-party.app" /Applications/')
await execPromise(`rm -rf /tmp/${name}.app`)
} catch (e) {
await execPromise(`mv /tmp/${name}.app /Applications`)
throw e
} finally {
await execPromise('hdiutil detach "/Volumes/mihomo-party"')
}
app.relaunch() app.relaunch()
app.quit() app.quit()
} catch { } catch {

View File

@ -9,19 +9,12 @@ import {
overrideDir, overrideDir,
profileConfigPath, profileConfigPath,
profilesDir, profilesDir,
subStoreDir,
themesDir themesDir
} from '../utils/dirs' } from '../utils/dirs'
export async function webdavBackup(): Promise<boolean> { export async function webdavBackup(): Promise<boolean> {
const { createClient } = await import('webdav/dist/node/index.js') const { createClient } = await import('webdav/dist/node/index.js')
const { const { webdavUrl = '', webdavUsername = '', webdavPassword = '' } = await getAppConfig()
webdavUrl = '',
webdavUsername = '',
webdavPassword = '',
webdavDir = 'mihomo-party',
webdavMaxBackups = 0
} = await getAppConfig()
const zip = new AdmZip() const zip = new AdmZip()
zip.addLocalFile(appConfigPath()) zip.addLocalFile(appConfigPath())
@ -31,7 +24,7 @@ export async function webdavBackup(): Promise<boolean> {
zip.addLocalFolder(themesDir(), 'themes') zip.addLocalFolder(themesDir(), 'themes')
zip.addLocalFolder(profilesDir(), 'profiles') zip.addLocalFolder(profilesDir(), 'profiles')
zip.addLocalFolder(overrideDir(), 'override') zip.addLocalFolder(overrideDir(), 'override')
zip.addLocalFolder(subStoreDir(), 'substore') zip.addLocalFolder(overrideDir(), 'substore')
const date = new Date() const date = new Date()
const zipFileName = `${process.platform}_${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip` const zipFileName = `${process.platform}_${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip`
@ -40,80 +33,36 @@ export async function webdavBackup(): Promise<boolean> {
password: webdavPassword password: webdavPassword
}) })
try { try {
await client.createDirectory(webdavDir) await client.createDirectory('mihomo-party')
} catch { } catch {
// ignore // ignore
} }
const result = await client.putFileContents(`${webdavDir}/${zipFileName}`, zip.toBuffer()) return await client.putFileContents(`mihomo-party/${zipFileName}`, zip.toBuffer())
if (webdavMaxBackups > 0) {
try {
const files = await client.getDirectoryContents(webdavDir, { glob: '*.zip' })
const fileList = Array.isArray(files) ? files : files.data
const currentPlatformFiles = fileList.filter((file) => {
return file.basename.startsWith(`${process.platform}_`)
})
currentPlatformFiles.sort((a, b) => {
const timeA = a.basename.match(/_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.zip$/)?.[1] || ''
const timeB = b.basename.match(/_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.zip$/)?.[1] || ''
return timeB.localeCompare(timeA)
})
if (currentPlatformFiles.length > webdavMaxBackups) {
const filesToDelete = currentPlatformFiles.slice(webdavMaxBackups)
for (let i = 0; i < filesToDelete.length; i++) {
const file = filesToDelete[i]
await client.deleteFile(`${webdavDir}/${file.basename}`)
if (i < filesToDelete.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
}
} catch (error) {
console.error('Failed to clean up old backup files:', error)
}
}
return result
} }
export async function webdavRestore(filename: string): Promise<void> { export async function webdavRestore(filename: string): Promise<void> {
const { createClient } = await import('webdav/dist/node/index.js') const { createClient } = await import('webdav/dist/node/index.js')
const { const { webdavUrl = '', webdavUsername = '', webdavPassword = '' } = await getAppConfig()
webdavUrl = '',
webdavUsername = '',
webdavPassword = '',
webdavDir = 'mihomo-party'
} = await getAppConfig()
const client = createClient(webdavUrl, { const client = createClient(webdavUrl, {
username: webdavUsername, username: webdavUsername,
password: webdavPassword password: webdavPassword
}) })
const zipData = await client.getFileContents(`${webdavDir}/${filename}`) const zipData = await client.getFileContents(`mihomo-party/${filename}`)
const zip = new AdmZip(zipData as Buffer) const zip = new AdmZip(zipData as Buffer)
zip.extractAllTo(dataDir(), true) zip.extractAllTo(dataDir(), true)
} }
export async function listWebdavBackups(): Promise<string[]> { export async function listWebdavBackups(): Promise<string[]> {
const { createClient } = await import('webdav/dist/node/index.js') const { createClient } = await import('webdav/dist/node/index.js')
const { const { webdavUrl = '', webdavUsername = '', webdavPassword = '' } = await getAppConfig()
webdavUrl = '',
webdavUsername = '',
webdavPassword = '',
webdavDir = 'mihomo-party'
} = await getAppConfig()
const client = createClient(webdavUrl, { const client = createClient(webdavUrl, {
username: webdavUsername, username: webdavUsername,
password: webdavPassword password: webdavPassword
}) })
const files = await client.getDirectoryContents(webdavDir, { glob: '*.zip' }) const files = await client.getDirectoryContents('mihomo-party', { glob: '*.zip' })
if (Array.isArray(files)) { if (Array.isArray(files)) {
return files.map((file) => file.basename) return files.map((file) => file.basename)
} else { } else {
@ -123,16 +72,11 @@ export async function listWebdavBackups(): Promise<string[]> {
export async function webdavDelete(filename: string): Promise<void> { export async function webdavDelete(filename: string): Promise<void> {
const { createClient } = await import('webdav/dist/node/index.js') const { createClient } = await import('webdav/dist/node/index.js')
const { const { webdavUrl = '', webdavUsername = '', webdavPassword = '' } = await getAppConfig()
webdavUrl = '',
webdavUsername = '',
webdavPassword = '',
webdavDir = 'mihomo-party'
} = await getAppConfig()
const client = createClient(webdavUrl, { const client = createClient(webdavUrl, {
username: webdavUsername, username: webdavUsername,
password: webdavPassword password: webdavPassword
}) })
await client.deleteFile(`${webdavDir}/${filename}`) await client.deleteFile(`mihomo-party/${filename}`)
} }

View File

@ -1,90 +0,0 @@
import { is } from '@electron-toolkit/utils'
import { BrowserWindow, ipcMain } from 'electron'
import windowStateKeeper from 'electron-window-state'
import { join } from 'path'
import { getAppConfig, patchAppConfig } from '../config'
import { applyTheme } from './theme'
import { buildContextMenu, showTrayIcon } from './tray'
export let floatingWindow: BrowserWindow | null = null
async function createFloatingWindow(): Promise<void> {
const floatingWindowState = windowStateKeeper({
file: 'floating-window-state.json'
})
const { customTheme = 'default.css' } = await getAppConfig()
floatingWindow = new BrowserWindow({
width: 120,
height: 42,
x: floatingWindowState.x,
y: floatingWindowState.y,
show: false,
frame: false,
alwaysOnTop: true,
resizable: false,
transparent: true,
skipTaskbar: true,
minimizable: false,
maximizable: false,
fullscreenable: false,
closable: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
spellcheck: false,
sandbox: false
}
})
floatingWindowState.manage(floatingWindow)
floatingWindow.on('ready-to-show', () => {
applyTheme(customTheme)
floatingWindow?.show()
floatingWindow?.setAlwaysOnTop(true, 'screen-saver')
})
floatingWindow.on('moved', () => {
if (floatingWindow) floatingWindowState.saveState(floatingWindow)
})
ipcMain.on('updateFloatingWindow', () => {
if (floatingWindow) {
floatingWindow?.webContents.send('controledMihomoConfigUpdated')
floatingWindow?.webContents.send('appConfigUpdated')
}
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
floatingWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/floating.html`)
} else {
floatingWindow.loadFile(join(__dirname, '../renderer/floating.html'))
}
}
export async function showFloatingWindow(): Promise<void> {
if (floatingWindow) {
floatingWindow.show()
} else {
createFloatingWindow()
}
}
export async function triggerFloatingWindow(): Promise<void> {
if (floatingWindow?.isVisible()) {
await patchAppConfig({ showFloatingWindow: false })
await closeFloatingWindow()
} else {
await patchAppConfig({ showFloatingWindow: true })
await showFloatingWindow()
}
}
export async function closeFloatingWindow(): Promise<void> {
if (floatingWindow) {
floatingWindow.close()
floatingWindow.destroy()
floatingWindow = null
}
await showTrayIcon()
await patchAppConfig({ disableTray: false })
}
export async function showContextMenu(): Promise<void> {
const menu = await buildContextMenu()
menu.popup()
}

View File

@ -20,8 +20,7 @@ async function listGists(token: string): Promise<GistInfo[]> {
protocol: 'http', protocol: 'http',
host: '127.0.0.1', host: '127.0.0.1',
port port
}, }
responseType: 'json'
}) })
return res.data as GistInfo[] return res.data as GistInfo[]
} }

View File

@ -1,22 +1,16 @@
import { getAppConfig, getControledMihomoConfig } from '../config' import { getAppConfig, getControledMihomoConfig } from '../config'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
import { dataDir, mihomoWorkDir, subStoreDir, substoreLogPath } from '../utils/dirs' import { mihomoWorkDir, resourcesFilesDir, subStoreDir } from '../utils/dirs'
import subStoreIcon from '../../../resources/subStoreIcon.png?asset' import subStoreIcon from '../../../resources/subStoreIcon.png?asset'
import { createWriteStream, existsSync, mkdirSync } from 'fs'
import { writeFile, rm, cp } from 'fs/promises'
import http from 'http' import http from 'http'
import net from 'net' import net from 'net'
import path from 'path' import path from 'path'
import { nativeImage } from 'electron' import { nativeImage } from 'electron'
import express from 'express' import express from 'express'
import axios from 'axios'
import AdmZip from 'adm-zip'
export let pacPort: number export let pacPort: number
export let subStorePort: number export let subStorePort: number
export let subStoreFrontendPort: number export let subStoreFrontendPort: number
let subStoreFrontendServer: http.Server
let subStoreBackendWorker: Worker
const defaultPacScript = ` const defaultPacScript = `
function FindProxyForURL(url, host) { function FindProxyForURL(url, host) {
@ -27,6 +21,7 @@ function FindProxyForURL(url, host) {
export function findAvailablePort(startPort: number): Promise<number> { export function findAvailablePort(startPort: number): Promise<number> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const server = net.createServer() const server = net.createServer()
server.unref()
server.on('error', (err) => { server.on('error', (err) => {
if (startPort <= 65535) { if (startPort <= 65535) {
resolve(findAvailablePort(startPort + 1)) resolve(findAvailablePort(startPort + 1))
@ -34,81 +29,55 @@ export function findAvailablePort(startPort: number): Promise<number> {
reject(err) reject(err)
} }
}) })
server.on('listening', () => {
server.listen(startPort, () => {
// 端口可用
server.close(() => { server.close(() => {
resolve(startPort) resolve(startPort)
}) })
}) })
server.listen(startPort, '127.0.0.1')
}) })
} }
let pacServer: http.Server
export async function startPacServer(): Promise<void> { export async function startPacServer(): Promise<void> {
await stopPacServer()
const { sysProxy } = await getAppConfig()
const { mode = 'manual', host: cHost, pacScript } = sysProxy
if (mode !== 'auto') {
return
}
const host = cHost || '127.0.0.1'
let script = pacScript || defaultPacScript
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
script = script.replaceAll('%mixed-port%', port.toString())
pacPort = await findAvailablePort(10000) pacPort = await findAvailablePort(10000)
pacServer = http const server = http
.createServer(async (_req, res) => { .createServer(async (_req, res) => {
const {
sysProxy: { pacScript }
} = await getAppConfig()
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
let script = pacScript || defaultPacScript
script = script.replaceAll('%mixed-port%', port.toString())
res.writeHead(200, { 'Content-Type': 'application/x-ns-proxy-autoconfig' }) res.writeHead(200, { 'Content-Type': 'application/x-ns-proxy-autoconfig' })
res.end(script) res.end(script)
}) })
.listen(pacPort, host) .listen(pacPort)
server.unref()
} }
export async function stopPacServer(): Promise<void> { export async function startSubStoreServer(): Promise<void> {
if (pacServer) {
pacServer.close()
}
}
export async function startSubStoreFrontendServer(): Promise<void> {
const { useSubStore = true, subStoreHost = '127.0.0.1' } = await getAppConfig()
if (!useSubStore) return
await stopSubStoreFrontendServer()
subStoreFrontendPort = await findAvailablePort(14122)
const app = express()
app.use(express.static(path.join(mihomoWorkDir(), 'sub-store-frontend')))
subStoreFrontendServer = app.listen(subStoreFrontendPort, subStoreHost)
}
export async function stopSubStoreFrontendServer(): Promise<void> {
if (subStoreFrontendServer) {
subStoreFrontendServer.close()
}
}
export async function startSubStoreBackendServer(): Promise<void> {
const { const {
useSubStore = true, useSubStore = true,
useCustomSubStore = false, useCustomSubStore = false,
useProxyInSubStore = false,
subStoreHost = '127.0.0.1',
subStoreBackendSyncCron = '', subStoreBackendSyncCron = '',
subStoreBackendDownloadCron = '', subStoreBackendDownloadCron = '',
subStoreBackendUploadCron = '' subStoreBackendUploadCron = ''
} = await getAppConfig() } = await getAppConfig()
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
if (!useSubStore) return if (!useSubStore) return
if (!useCustomSubStore) { if (!subStoreFrontendPort) {
await stopSubStoreBackendServer() subStoreFrontendPort = await findAvailablePort(4000)
subStorePort = await findAvailablePort(38324) const app = express()
app.use(express.static(path.join(resourcesFilesDir(), 'sub-store-frontend')))
app.listen(subStoreFrontendPort)
}
if (!useCustomSubStore && !subStorePort) {
subStorePort = await findAvailablePort(3000)
const icon = nativeImage.createFromPath(subStoreIcon) const icon = nativeImage.createFromPath(subStoreIcon)
icon.toDataURL() icon.toDataURL()
const stdout = createWriteStream(substoreLogPath(), { flags: 'a' }) new Worker(path.join(resourcesFilesDir(), 'sub-store.bundle.js'), {
const stderr = createWriteStream(substoreLogPath(), { flags: 'a' }) env: {
const env = {
SUB_STORE_BACKEND_API_PORT: subStorePort.toString(), SUB_STORE_BACKEND_API_PORT: subStorePort.toString(),
SUB_STORE_BACKEND_API_HOST: subStoreHost,
SUB_STORE_DATA_BASE_PATH: subStoreDir(), SUB_STORE_DATA_BASE_PATH: subStoreDir(),
SUB_STORE_BACKEND_CUSTOM_ICON: icon.toDataURL(), SUB_STORE_BACKEND_CUSTOM_ICON: icon.toDataURL(),
SUB_STORE_BACKEND_CUSTOM_NAME: 'Mihomo Party', SUB_STORE_BACKEND_CUSTOM_NAME: 'Mihomo Party',
@ -118,81 +87,6 @@ export async function startSubStoreBackendServer(): Promise<void> {
SUB_STORE_MMDB_COUNTRY_PATH: path.join(mihomoWorkDir(), 'country.mmdb'), SUB_STORE_MMDB_COUNTRY_PATH: path.join(mihomoWorkDir(), 'country.mmdb'),
SUB_STORE_MMDB_ASN_PATH: path.join(mihomoWorkDir(), 'ASN.mmdb') SUB_STORE_MMDB_ASN_PATH: path.join(mihomoWorkDir(), 'ASN.mmdb')
} }
subStoreBackendWorker = new Worker(path.join(mihomoWorkDir(), 'sub-store.bundle.js'), {
env: useProxyInSubStore
? {
...env,
HTTP_PROXY: `http://127.0.0.1:${port}`,
HTTPS_PROXY: `http://127.0.0.1:${port}`,
ALL_PROXY: `http://127.0.0.1:${port}`
}
: env
}) })
subStoreBackendWorker.stdout.pipe(stdout)
subStoreBackendWorker.stderr.pipe(stderr)
}
}
export async function stopSubStoreBackendServer(): Promise<void> {
if (subStoreBackendWorker) {
subStoreBackendWorker.terminate()
}
}
export async function downloadSubStore(): Promise<void> {
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
const frontendDir = path.join(mihomoWorkDir(), 'sub-store-frontend')
const backendPath = path.join(mihomoWorkDir(), 'sub-store.bundle.js')
const tempDir = path.join(dataDir(), 'temp')
try {
// 创建临时目录
if (existsSync(tempDir)) {
await rm(tempDir, { recursive: true })
}
mkdirSync(tempDir, { recursive: true })
// 下载后端文件
const tempBackendPath = path.join(tempDir, 'sub-store.bundle.js')
const backendRes = await axios.get(
'https://github.com/sub-store-org/Sub-Store/releases/latest/download/sub-store.bundle.js',
{
responseType: 'arraybuffer',
headers: { 'Content-Type': 'application/octet-stream' },
proxy: {
protocol: 'http',
host: '127.0.0.1',
port: mixedPort
}
}
)
await writeFile(tempBackendPath, Buffer.from(backendRes.data))
// 下载前端文件
const tempFrontendDir = path.join(tempDir, 'dist')
const frontendRes = await axios.get(
'https://github.com/sub-store-org/Sub-Store-Front-End/releases/latest/download/dist.zip',
{
responseType: 'arraybuffer',
headers: { 'Content-Type': 'application/octet-stream' },
proxy: {
protocol: 'http',
host: '127.0.0.1',
port: mixedPort
}
}
)
// 先解压到临时目录
const zip = new AdmZip(Buffer.from(frontendRes.data))
zip.extractAllTo(tempDir, true)
await cp(tempBackendPath, backendPath)
if (existsSync(frontendDir)) {
await rm(frontendDir, { recursive: true })
}
mkdirSync(frontendDir, { recursive: true })
await cp(path.join(tempDir, 'dist'), frontendDir, { recursive: true })
await rm(tempDir, { recursive: true })
} catch (error) {
console.error('substore.downloadFailed:', error)
throw error
} }
} }

View File

@ -1,5 +1,5 @@
import { app, globalShortcut, ipcMain, Notification } from 'electron' import { app, globalShortcut, ipcMain, Notification } from 'electron'
import { mainWindow, triggerMainWindow } from '..' import { mainWindow, showMainWindow } from '..'
import { import {
getAppConfig, getAppConfig,
getControledMihomoConfig, getControledMihomoConfig,
@ -9,8 +9,6 @@ import {
import { triggerSysProxy } from '../sys/sysproxy' import { triggerSysProxy } from '../sys/sysproxy'
import { patchMihomoConfig } from '../core/mihomoApi' import { patchMihomoConfig } from '../core/mihomoApi'
import { quitWithoutCore, restartCore } from '../core/manager' import { quitWithoutCore, restartCore } from '../core/manager'
import { floatingWindow, triggerFloatingWindow } from './floatingWindow'
import i18next from '../../shared/i18n'
export async function registerShortcut( export async function registerShortcut(
oldShortcut: string, oldShortcut: string,
@ -26,12 +24,11 @@ export async function registerShortcut(
switch (action) { switch (action) {
case 'showWindowShortcut': { case 'showWindowShortcut': {
return globalShortcut.register(newShortcut, () => { return globalShortcut.register(newShortcut, () => {
triggerMainWindow() if (mainWindow?.isVisible()) {
}) mainWindow?.close()
} else {
showMainWindow()
} }
case 'showFloatingWindowShortcut': {
return globalShortcut.register(newShortcut, async () => {
await triggerFloatingWindow()
}) })
} }
case 'triggerSysProxyShortcut': { case 'triggerSysProxyShortcut': {
@ -43,13 +40,12 @@ export async function registerShortcut(
await triggerSysProxy(!enable) await triggerSysProxy(!enable)
await patchAppConfig({ sysProxy: { enable: !enable } }) await patchAppConfig({ sysProxy: { enable: !enable } })
new Notification({ new Notification({
title: i18next.t(!enable ? 'common.notification.systemProxyEnabled' : 'common.notification.systemProxyDisabled') title: `系统代理已${!enable ? '开启' : '关闭'}`
}).show() }).show()
mainWindow?.webContents.send('appConfigUpdated')
floatingWindow?.webContents.send('appConfigUpdated')
} catch { } catch {
// ignore // ignore
} finally { } finally {
mainWindow?.webContents.send('appConfigUpdated')
ipcMain.emit('updateTrayMenu') ipcMain.emit('updateTrayMenu')
} }
}) })
@ -66,13 +62,12 @@ export async function registerShortcut(
} }
await restartCore() await restartCore()
new Notification({ new Notification({
title: i18next.t(!enable ? 'common.notification.tunEnabled' : 'common.notification.tunDisabled') title: `虚拟网卡已${!enable ? '开启' : '关闭'}`
}).show() }).show()
mainWindow?.webContents.send('controledMihomoConfigUpdated')
floatingWindow?.webContents.send('appConfigUpdated')
} catch { } catch {
// ignore // ignore
} finally { } finally {
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu') ipcMain.emit('updateTrayMenu')
} }
}) })
@ -82,7 +77,7 @@ export async function registerShortcut(
await patchControledMihomoConfig({ mode: 'rule' }) await patchControledMihomoConfig({ mode: 'rule' })
await patchMihomoConfig({ mode: 'rule' }) await patchMihomoConfig({ mode: 'rule' })
new Notification({ new Notification({
title: i18next.t('common.notification.ruleMode') title: '已切换至规则模式'
}).show() }).show()
mainWindow?.webContents.send('controledMihomoConfigUpdated') mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu') ipcMain.emit('updateTrayMenu')
@ -93,7 +88,7 @@ export async function registerShortcut(
await patchControledMihomoConfig({ mode: 'global' }) await patchControledMihomoConfig({ mode: 'global' })
await patchMihomoConfig({ mode: 'global' }) await patchMihomoConfig({ mode: 'global' })
new Notification({ new Notification({
title: i18next.t('common.notification.globalMode') title: '已切换至全局模式'
}).show() }).show()
mainWindow?.webContents.send('controledMihomoConfigUpdated') mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu') ipcMain.emit('updateTrayMenu')
@ -104,7 +99,7 @@ export async function registerShortcut(
await patchControledMihomoConfig({ mode: 'direct' }) await patchControledMihomoConfig({ mode: 'direct' })
await patchMihomoConfig({ mode: 'direct' }) await patchMihomoConfig({ mode: 'direct' })
new Notification({ new Notification({
title: i18next.t('common.notification.directMode') title: '已切换至直连模式'
}).show() }).show()
mainWindow?.webContents.send('controledMihomoConfigUpdated') mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu') ipcMain.emit('updateTrayMenu')
@ -127,7 +122,6 @@ export async function registerShortcut(
export async function initShortcut(): Promise<void> { export async function initShortcut(): Promise<void> {
const { const {
showFloatingWindowShortcut,
showWindowShortcut, showWindowShortcut,
triggerSysProxyShortcut, triggerSysProxyShortcut,
triggerTunShortcut, triggerTunShortcut,
@ -144,13 +138,6 @@ export async function initShortcut(): Promise<void> {
// ignore // ignore
} }
} }
if (showFloatingWindowShortcut) {
try {
await registerShortcut('', showFloatingWindowShortcut, 'showFloatingWindowShortcut')
} catch {
// ignore
}
}
if (triggerSysProxyShortcut) { if (triggerSysProxyShortcut) {
try { try {
await registerShortcut('', triggerSysProxyShortcut, 'triggerSysProxyShortcut') await registerShortcut('', triggerSysProxyShortcut, 'triggerSysProxyShortcut')

View File

@ -6,11 +6,8 @@ import AdmZip from 'adm-zip'
import { getControledMihomoConfig } from '../config' import { getControledMihomoConfig } from '../config'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { mainWindow } from '..' import { mainWindow } from '..'
import { floatingWindow } from './floatingWindow'
import { t } from 'i18next'
let insertedCSSKeyMain: string | undefined = undefined let insertedCSSKey: string | undefined = undefined
let insertedCSSKeyFloating: string | undefined = undefined
export async function resolveThemes(): Promise<{ key: string; label: string }[]> { export async function resolveThemes(): Promise<{ key: string; label: string }[]> {
const files = await readdir(themesDir()) const files = await readdir(themesDir())
@ -29,7 +26,7 @@ export async function resolveThemes(): Promise<{ key: string; label: string }[]>
if (themes.find((theme) => theme.key === 'default.css')) { if (themes.find((theme) => theme.key === 'default.css')) {
return themes return themes
} else { } else {
return [{ key: 'default.css', label: t('common.default') }, ...themes] return [{ key: 'default.css', label: '默认' }, ...themes]
} }
} }
@ -70,12 +67,6 @@ export async function writeTheme(theme: string, css: string): Promise<void> {
export async function applyTheme(theme: string): Promise<void> { export async function applyTheme(theme: string): Promise<void> {
const css = await readTheme(theme) const css = await readTheme(theme)
await mainWindow?.webContents.removeInsertedCSS(insertedCSSKeyMain || '') await mainWindow?.webContents.removeInsertedCSS(insertedCSSKey || '')
insertedCSSKeyMain = await mainWindow?.webContents.insertCSS(css) insertedCSSKey = await mainWindow?.webContents.insertCSS(css)
try {
await floatingWindow?.webContents.removeInsertedCSS(insertedCSSKeyFloating || '')
insertedCSSKeyFloating = await floatingWindow?.webContents.insertCSS(css)
} catch {
// ignore
}
} }

View File

@ -1,42 +0,0 @@
import { ChildProcess, spawn } from 'child_process'
import { getAppConfig } from '../config'
import { dataDir, resourcesFilesDir } from '../utils/dirs'
import path from 'path'
import { existsSync } from 'fs'
import { readFile, rm, writeFile } from 'fs/promises'
let child: ChildProcess
export async function startMonitor(detached = false): Promise<void> {
if (process.platform !== 'win32') return
if (existsSync(path.join(dataDir(), 'monitor.pid'))) {
const pid = parseInt(await readFile(path.join(dataDir(), 'monitor.pid'), 'utf-8'))
try {
process.kill(pid, 'SIGINT')
} catch {
// ignore
} finally {
await rm(path.join(dataDir(), 'monitor.pid'))
}
}
await stopMonitor()
const { showTraffic = false } = await getAppConfig()
if (!showTraffic) return
child = spawn(path.join(resourcesFilesDir(), 'TrafficMonitor/TrafficMonitor.exe'), [], {
cwd: path.join(resourcesFilesDir(), 'TrafficMonitor'),
detached: detached,
stdio: detached ? 'ignore' : undefined
})
if (detached) {
if (child && child.pid) {
await writeFile(path.join(dataDir(), 'monitor.pid'), child.pid.toString())
}
child.unref()
}
}
async function stopMonitor(): Promise<void> {
if (child) {
child.kill('SIGINT')
}
}

View File

@ -15,22 +15,15 @@ import {
mihomoGroups, mihomoGroups,
patchMihomoConfig patchMihomoConfig
} from '../core/mihomoApi' } from '../core/mihomoApi'
import { mainWindow, showMainWindow, triggerMainWindow } from '..' import { mainWindow, showMainWindow } from '..'
import { app, clipboard, ipcMain, Menu, nativeImage, shell, Tray } from 'electron' import { app, clipboard, ipcMain, Menu, nativeImage, shell, Tray } from 'electron'
import { dataDir, logDir, mihomoCoreDir, mihomoWorkDir } from '../utils/dirs' import { dataDir, logDir, mihomoCoreDir, mihomoWorkDir } from '../utils/dirs'
import { triggerSysProxy } from '../sys/sysproxy' import { triggerSysProxy } from '../sys/sysproxy'
import { quitWithoutCore, restartCore } from '../core/manager' import { quitWithoutCore, restartCore } from '../core/manager'
import { floatingWindow, triggerFloatingWindow } from './floatingWindow'
import { t } from 'i18next'
export let tray: Tray | null = null export let tray: Tray | null = null
export const buildContextMenu = async (): Promise<Menu> => { const buildContextMenu = async (): Promise<Menu> => {
// 添加调试日志
console.log('Current translation for tray.showWindow:', t('tray.showWindow'))
console.log('Current translation for tray.hideFloatingWindow:', t('tray.hideFloatingWindow'))
console.log('Current translation for tray.showFloatingWindow:', t('tray.showFloatingWindow'))
const { mode, tun } = await getControledMihomoConfig() const { mode, tun } = await getControledMihomoConfig()
const { const {
sysProxy, sysProxy,
@ -38,7 +31,6 @@ export const buildContextMenu = async (): Promise<Menu> => {
autoCloseConnection, autoCloseConnection,
proxyInTray = true, proxyInTray = true,
triggerSysProxyShortcut = '', triggerSysProxyShortcut = '',
showFloatingWindowShortcut = '',
showWindowShortcut = '', showWindowShortcut = '',
triggerTunShortcut = '', triggerTunShortcut = '',
ruleModeShortcut = '', ruleModeShortcut = '',
@ -92,24 +84,15 @@ export const buildContextMenu = async (): Promise<Menu> => {
{ {
id: 'show', id: 'show',
accelerator: showWindowShortcut, accelerator: showWindowShortcut,
label: t('tray.showWindow'), label: '显示窗口',
type: 'normal', type: 'normal',
click: (): void => { click: (): void => {
showMainWindow() showMainWindow()
} }
}, },
{
id: 'show-floating',
accelerator: showFloatingWindowShortcut,
label: floatingWindow?.isVisible() ? t('tray.hideFloatingWindow') : t('tray.showFloatingWindow'),
type: 'normal',
click: async (): Promise<void> => {
await triggerFloatingWindow()
}
},
{ {
id: 'rule', id: 'rule',
label: t('tray.ruleMode'), label: '规则模式',
accelerator: ruleModeShortcut, accelerator: ruleModeShortcut,
type: 'radio', type: 'radio',
checked: mode === 'rule', checked: mode === 'rule',
@ -117,13 +100,12 @@ export const buildContextMenu = async (): Promise<Menu> => {
await patchControledMihomoConfig({ mode: 'rule' }) await patchControledMihomoConfig({ mode: 'rule' })
await patchMihomoConfig({ mode: 'rule' }) await patchMihomoConfig({ mode: 'rule' })
mainWindow?.webContents.send('controledMihomoConfigUpdated') mainWindow?.webContents.send('controledMihomoConfigUpdated')
mainWindow?.webContents.send('groupsUpdated')
ipcMain.emit('updateTrayMenu') ipcMain.emit('updateTrayMenu')
} }
}, },
{ {
id: 'global', id: 'global',
label: t('tray.globalMode'), label: '全局模式',
accelerator: globalModeShortcut, accelerator: globalModeShortcut,
type: 'radio', type: 'radio',
checked: mode === 'global', checked: mode === 'global',
@ -131,13 +113,12 @@ export const buildContextMenu = async (): Promise<Menu> => {
await patchControledMihomoConfig({ mode: 'global' }) await patchControledMihomoConfig({ mode: 'global' })
await patchMihomoConfig({ mode: 'global' }) await patchMihomoConfig({ mode: 'global' })
mainWindow?.webContents.send('controledMihomoConfigUpdated') mainWindow?.webContents.send('controledMihomoConfigUpdated')
mainWindow?.webContents.send('groupsUpdated')
ipcMain.emit('updateTrayMenu') ipcMain.emit('updateTrayMenu')
} }
}, },
{ {
id: 'direct', id: 'direct',
label: t('tray.directMode'), label: '直连模式',
accelerator: directModeShortcut, accelerator: directModeShortcut,
type: 'radio', type: 'radio',
checked: mode === 'direct', checked: mode === 'direct',
@ -145,14 +126,13 @@ export const buildContextMenu = async (): Promise<Menu> => {
await patchControledMihomoConfig({ mode: 'direct' }) await patchControledMihomoConfig({ mode: 'direct' })
await patchMihomoConfig({ mode: 'direct' }) await patchMihomoConfig({ mode: 'direct' })
mainWindow?.webContents.send('controledMihomoConfigUpdated') mainWindow?.webContents.send('controledMihomoConfigUpdated')
mainWindow?.webContents.send('groupsUpdated')
ipcMain.emit('updateTrayMenu') ipcMain.emit('updateTrayMenu')
} }
}, },
{ type: 'separator' }, { type: 'separator' },
{ {
type: 'checkbox', type: 'checkbox',
label: t('tray.systemProxy'), label: '系统代理',
accelerator: triggerSysProxyShortcut, accelerator: triggerSysProxyShortcut,
checked: sysProxy.enable, checked: sysProxy.enable,
click: async (item): Promise<void> => { click: async (item): Promise<void> => {
@ -160,43 +140,36 @@ export const buildContextMenu = async (): Promise<Menu> => {
try { try {
await triggerSysProxy(enable) await triggerSysProxy(enable)
await patchAppConfig({ sysProxy: { enable } }) await patchAppConfig({ sysProxy: { enable } })
mainWindow?.webContents.send('appConfigUpdated')
floatingWindow?.webContents.send('appConfigUpdated')
} catch (e) { } catch (e) {
// ignore // ignore
} finally { } finally {
mainWindow?.webContents.send('appConfigUpdated')
ipcMain.emit('updateTrayMenu') ipcMain.emit('updateTrayMenu')
} }
} }
}, },
{ {
type: 'checkbox', type: 'checkbox',
label: t('tray.tun'), label: '虚拟网卡',
accelerator: triggerTunShortcut, accelerator: triggerTunShortcut,
checked: tun?.enable ?? false, checked: tun?.enable ?? false,
click: async (item): Promise<void> => { click: async (item): Promise<void> => {
const enable = item.checked const enable = item.checked
try {
if (enable) { if (enable) {
await patchControledMihomoConfig({ tun: { enable }, dns: { enable: true } }) await patchControledMihomoConfig({ tun: { enable }, dns: { enable: true } })
} else { } else {
await patchControledMihomoConfig({ tun: { enable } }) await patchControledMihomoConfig({ tun: { enable } })
} }
mainWindow?.webContents.send('controledMihomoConfigUpdated') mainWindow?.webContents.send('controledMihomoConfigUpdated')
floatingWindow?.webContents.send('controledMihomoConfigUpdated')
await restartCore() await restartCore()
} catch {
// ignore
} finally {
ipcMain.emit('updateTrayMenu') ipcMain.emit('updateTrayMenu')
} }
}
}, },
...groupsMenu, ...groupsMenu,
{ type: 'separator' }, { type: 'separator' },
{ {
type: 'submenu', type: 'submenu',
label: t('tray.profiles'), label: '订阅配置',
submenu: items.map((item) => { submenu: items.map((item) => {
return { return {
type: 'radio', type: 'radio',
@ -214,26 +187,26 @@ export const buildContextMenu = async (): Promise<Menu> => {
{ type: 'separator' }, { type: 'separator' },
{ {
type: 'submenu', type: 'submenu',
label: t('tray.openDirectories.title'), label: '打开目录',
submenu: [ submenu: [
{ {
type: 'normal', type: 'normal',
label: t('tray.openDirectories.appDir'), label: '应用目录',
click: (): Promise<string> => shell.openPath(dataDir()) click: (): Promise<string> => shell.openPath(dataDir())
}, },
{ {
type: 'normal', type: 'normal',
label: t('tray.openDirectories.workDir'), label: '工作目录',
click: (): Promise<string> => shell.openPath(mihomoWorkDir()) click: (): Promise<string> => shell.openPath(mihomoWorkDir())
}, },
{ {
type: 'normal', type: 'normal',
label: t('tray.openDirectories.coreDir'), label: '内核目录',
click: (): Promise<string> => shell.openPath(mihomoCoreDir()) click: (): Promise<string> => shell.openPath(mihomoCoreDir())
}, },
{ {
type: 'normal', type: 'normal',
label: t('tray.openDirectories.logDir'), label: '日志目录',
click: (): Promise<string> => shell.openPath(logDir()) click: (): Promise<string> => shell.openPath(logDir())
} }
] ]
@ -241,7 +214,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
envType.length > 1 envType.length > 1
? { ? {
type: 'submenu', type: 'submenu',
label: t('tray.copyEnv'), label: '复制环境变量',
submenu: envType.map((type) => { submenu: envType.map((type) => {
return { return {
id: type, id: type,
@ -255,7 +228,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
} }
: { : {
id: 'copyenv', id: 'copyenv',
label: t('tray.copyEnv'), label: '复制环境变量',
type: 'normal', type: 'normal',
click: async (): Promise<void> => { click: async (): Promise<void> => {
await copyEnv(envType[0]) await copyEnv(envType[0])
@ -264,14 +237,14 @@ export const buildContextMenu = async (): Promise<Menu> => {
{ type: 'separator' }, { type: 'separator' },
{ {
id: 'quitWithoutCore', id: 'quitWithoutCore',
label: t('actions.lightMode.button'), label: '轻量模式',
type: 'normal', type: 'normal',
accelerator: quitWithoutCoreShortcut, accelerator: quitWithoutCoreShortcut,
click: quitWithoutCore click: quitWithoutCore
}, },
{ {
id: 'restart', id: 'restart',
label: t('actions.restartApp'), label: '重启应用',
type: 'normal', type: 'normal',
accelerator: restartAppShortcut, accelerator: restartAppShortcut,
click: (): void => { click: (): void => {
@ -281,7 +254,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
}, },
{ {
id: 'quit', id: 'quit',
label: t('actions.quit.button'), label: '退出应用',
type: 'normal', type: 'normal',
accelerator: 'CommandOrControl+Q', accelerator: 'CommandOrControl+Q',
click: (): void => app.quit() click: (): void => app.quit()
@ -309,7 +282,7 @@ export async function createTray(): Promise<void> {
tray?.setIgnoreDoubleClickEvents(true) tray?.setIgnoreDoubleClickEvents(true)
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
if (!useDockIcon) { if (!useDockIcon) {
hideDockIcon() app.dock.hide()
} }
ipcMain.on('trayIconUpdate', async (_, png: string) => { ipcMain.on('trayIconUpdate', async (_, png: string) => {
const image = nativeImage.createFromDataURL(png).resize({ height: 16 }) const image = nativeImage.createFromDataURL(png).resize({ height: 16 })
@ -317,7 +290,11 @@ export async function createTray(): Promise<void> {
tray?.setImage(image) tray?.setImage(image)
}) })
tray?.addListener('right-click', async () => { tray?.addListener('right-click', async () => {
triggerMainWindow() if (mainWindow?.isVisible()) {
mainWindow?.close()
} else {
showMainWindow()
}
}) })
tray?.addListener('click', async () => { tray?.addListener('click', async () => {
await updateTrayMenu() await updateTrayMenu()
@ -325,7 +302,11 @@ export async function createTray(): Promise<void> {
} }
if (process.platform === 'win32') { if (process.platform === 'win32') {
tray?.addListener('click', () => { tray?.addListener('click', () => {
triggerMainWindow() if (mainWindow?.isVisible()) {
mainWindow?.close()
} else {
showMainWindow()
}
}) })
tray?.addListener('right-click', async () => { tray?.addListener('right-click', async () => {
await updateTrayMenu() await updateTrayMenu()
@ -333,7 +314,11 @@ export async function createTray(): Promise<void> {
} }
if (process.platform === 'linux') { if (process.platform === 'linux') {
tray?.addListener('click', () => { tray?.addListener('click', () => {
triggerMainWindow() if (mainWindow?.isVisible()) {
mainWindow?.close()
} else {
showMainWindow()
}
}) })
ipcMain.on('updateTrayMenu', async () => { ipcMain.on('updateTrayMenu', async () => {
await updateTrayMenu() await updateTrayMenu()
@ -374,28 +359,3 @@ export async function copyEnv(type: 'bash' | 'cmd' | 'powershell'): Promise<void
} }
} }
} }
export async function showTrayIcon(): Promise<void> {
if (!tray) {
await createTray()
}
}
export async function closeTrayIcon(): Promise<void> {
if (tray) {
tray.destroy()
}
tray = null
}
export async function showDockIcon(): Promise<void> {
if (process.platform === 'darwin' && !app.dock.isVisible()) {
await app.dock.show()
}
}
export async function hideDockIcon(): Promise<void> {
if (process.platform === 'darwin' && app.dock.isVisible()) {
app.dock.hide()
}
}

View File

@ -1,4 +1,4 @@
import { exePath, homeDir, taskDir } from '../utils/dirs' import { taskDir, exePath, homeDir } from '../utils/dirs'
import { mkdir, readFile, rm, writeFile } from 'fs/promises' import { mkdir, readFile, rm, writeFile } from 'fs/promises'
import { exec } from 'child_process' import { exec } from 'child_process'
import { existsSync } from 'fs' import { existsSync } from 'fs'
@ -7,9 +7,12 @@ import path from 'path'
const appName = 'mihomo-party' const appName = 'mihomo-party'
function getTaskXml(): string { const taskXml = `<?xml version="1.0" encoding="UTF-16"?>
return `<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"> <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Date>${new Date().toISOString()}</Date>
<Author>${process.env.USERNAME}</Author>
</RegistrationInfo>
<Triggers> <Triggers>
<LogonTrigger> <LogonTrigger>
<Enabled>true</Enabled> <Enabled>true</Enabled>
@ -23,12 +26,12 @@ function getTaskXml(): string {
</Principal> </Principal>
</Principals> </Principals>
<Settings> <Settings>
<MultipleInstancesPolicy>Parallel</MultipleInstancesPolicy> <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries> <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries> <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>false</AllowHardTerminate> <AllowHardTerminate>false</AllowHardTerminate>
<StartWhenAvailable>false</StartWhenAvailable> <StartWhenAvailable>true</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable> <RunOnlyIfNetworkAvailable>true</RunOnlyIfNetworkAvailable>
<IdleSettings> <IdleSettings>
<StopOnIdleEnd>false</StopOnIdleEnd> <StopOnIdleEnd>false</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle> <RestartOnIdle>false</RestartOnIdle>
@ -39,25 +42,21 @@ function getTaskXml(): string {
<RunOnlyIfIdle>false</RunOnlyIfIdle> <RunOnlyIfIdle>false</RunOnlyIfIdle>
<WakeToRun>false</WakeToRun> <WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit> <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>3</Priority> <Priority>7</Priority>
</Settings> </Settings>
<Actions Context="Author"> <Actions Context="Author">
<Exec> <Exec>
<Command>"${path.join(taskDir(), `mihomo-party-run.exe`)}"</Command> <Command>${exePath()}</Command>
<Arguments>"${exePath()}"</Arguments>
</Exec> </Exec>
</Actions> </Actions>
</Task> </Task>
` `
}
export async function checkAutoRun(): Promise<boolean> { export async function checkAutoRun(): Promise<boolean> {
if (process.platform === 'win32') { if (process.platform === 'win32') {
const execPromise = promisify(exec) const execPromise = promisify(exec)
try { try {
const { stdout } = await execPromise( const { stdout } = await execPromise(`schtasks /query /tn "${appName}"`)
`chcp 437 && %SystemRoot%\\System32\\schtasks.exe /query /tn "${appName}"`
)
return stdout.includes(appName) return stdout.includes(appName)
} catch (e) { } catch (e) {
return false return false
@ -82,10 +81,8 @@ export async function enableAutoRun(): Promise<void> {
if (process.platform === 'win32') { if (process.platform === 'win32') {
const execPromise = promisify(exec) const execPromise = promisify(exec)
const taskFilePath = path.join(taskDir(), `${appName}.xml`) const taskFilePath = path.join(taskDir(), `${appName}.xml`)
await writeFile(taskFilePath, Buffer.from(`\ufeff${getTaskXml()}`, 'utf-16le')) await writeFile(taskFilePath, Buffer.from(`\ufeff${taskXml}`, 'utf-16le'))
await execPromise( await execPromise(`schtasks /create /tn "${appName}" /xml "${taskFilePath}" /f`)
`%SystemRoot%\\System32\\schtasks.exe /create /tn "${appName}" /xml "${taskFilePath}" /f`
)
} }
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
const execPromise = promisify(exec) const execPromise = promisify(exec)
@ -121,7 +118,7 @@ Categories=Utility;
export async function disableAutoRun(): Promise<void> { export async function disableAutoRun(): Promise<void> {
if (process.platform === 'win32') { if (process.platform === 'win32') {
const execPromise = promisify(exec) const execPromise = promisify(exec)
await execPromise(`%SystemRoot%\\System32\\schtasks.exe /delete /tn "${appName}" /f`) await execPromise(`schtasks /delete /tn "${appName}" /f`)
} }
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
const execPromise = promisify(exec) const execPromise = promisify(exec)

View File

@ -1,10 +1,9 @@
import { exec, execFile, execSync, spawn } from 'child_process' import { exec, execFile, execSync } from 'child_process'
import { app, dialog, nativeTheme, shell } from 'electron' import { dialog, nativeTheme, shell } from 'electron'
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
import path from 'path' import path from 'path'
import { promisify } from 'util' import { promisify } from 'util'
import { import {
dataDir,
exePath, exePath,
mihomoCorePath, mihomoCorePath,
overridePath, overridePath,
@ -45,12 +44,9 @@ export async function openUWPTool(): Promise<void> {
export async function setupFirewall(): Promise<void> { export async function setupFirewall(): Promise<void> {
const execPromise = promisify(exec) const execPromise = promisify(exec)
const removeCommand = ` const removeCommand = `
$rules = @("mihomo", "mihomo-alpha", "Mihomo Party") Remove-NetFirewallRule -DisplayName "mihomo" -ErrorAction SilentlyContinue
foreach ($rule in $rules) { Remove-NetFirewallRule -DisplayName "mihomo-alpha" -ErrorAction SilentlyContinue
if (Get-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue) { Remove-NetFirewallRule -DisplayName "Mihomo Party" -ErrorAction SilentlyContinue
Remove-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue
}
}
` `
const createCommand = ` const createCommand = `
New-NetFirewallRule -DisplayName "mihomo" -Direction Inbound -Action Allow -Program "${mihomoCorePath('mihomo')}" -Enabled True -Profile Any -ErrorAction SilentlyContinue New-NetFirewallRule -DisplayName "mihomo" -Direction Inbound -Action Allow -Program "${mihomoCorePath('mihomo')}" -Enabled True -Profile Any -ErrorAction SilentlyContinue
@ -68,9 +64,12 @@ export function setNativeTheme(theme: 'system' | 'light' | 'dark'): void {
nativeTheme.themeSource = theme nativeTheme.themeSource = theme
} }
function getElevateTaskXml(): string { const elevateTaskXml = `<?xml version="1.0" encoding="UTF-16"?>
return `<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"> <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Date>${new Date().toISOString()}</Date>
<Author>${process.env.USERNAME}</Author>
</RegistrationInfo>
<Triggers /> <Triggers />
<Principals> <Principals>
<Principal id="Author"> <Principal id="Author">
@ -94,8 +93,8 @@ function getElevateTaskXml(): string {
<Hidden>false</Hidden> <Hidden>false</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle> <RunOnlyIfIdle>false</RunOnlyIfIdle>
<WakeToRun>false</WakeToRun> <WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit> <ExecutionTimeLimit>PT72H</ExecutionTimeLimit>
<Priority>3</Priority> <Priority>7</Priority>
</Settings> </Settings>
<Actions Context="Author"> <Actions Context="Author">
<Exec> <Exec>
@ -105,46 +104,13 @@ function getElevateTaskXml(): string {
</Actions> </Actions>
</Task> </Task>
` `
}
export function createElevateTask(): void { export function createElevateTask(): void {
const taskFilePath = path.join(taskDir(), `mihomo-party-run.xml`) const taskFilePath = path.join(taskDir(), `mihomo-party-run.xml`)
writeFileSync(taskFilePath, Buffer.from(`\ufeff${getElevateTaskXml()}`, 'utf-16le')) writeFileSync(taskFilePath, Buffer.from(`\ufeff${elevateTaskXml}`, 'utf-16le'))
execSync(`schtasks /create /tn "mihomo-party-run" /xml "${taskFilePath}" /f`)
copyFileSync( copyFileSync(
path.join(resourcesFilesDir(), 'mihomo-party-run.exe'), path.join(resourcesFilesDir(), 'mihomo-party-run.exe'),
path.join(taskDir(), 'mihomo-party-run.exe') path.join(taskDir(), 'mihomo-party-run.exe')
) )
execSync(
`%SystemRoot%\\System32\\schtasks.exe /create /tn "mihomo-party-run" /xml "${taskFilePath}" /f`
)
}
export function resetAppConfig(): void {
if (process.platform === 'win32') {
spawn(
'cmd',
[
'/C',
`"timeout /t 2 /nobreak >nul && rmdir /s /q "${dataDir()}" && start "" "${exePath()}""`
],
{
shell: true,
detached: true
}
).unref()
} else {
const script = `while kill -0 ${process.pid} 2>/dev/null; do
sleep 0.1
done
rm -rf '${dataDir()}'
${process.argv.join(' ')} & disown
exit
`
spawn('sh', ['-c', `"${script}"`], {
shell: true,
detached: true,
stdio: 'ignore'
})
}
app.quit()
} }

View File

@ -1,17 +1,14 @@
import { triggerAutoProxy, triggerManualProxy } from '@mihomo-party/sysproxy' import { triggerAutoProxy, triggerManualProxy } from '@mihomo-party/sysproxy'
import { getAppConfig, getControledMihomoConfig } from '../config' import { getAppConfig, getControledMihomoConfig } from '../config'
import { pacPort, startPacServer, stopPacServer } from '../resolve/server' import { pacPort } from '../resolve/server'
import { promisify } from 'util' import { promisify } from 'util'
import { execFile } from 'child_process' import { execFile } from 'child_process'
import path from 'path' import path from 'path'
import { resourcesFilesDir } from '../utils/dirs' import { resourcesFilesDir } from '../utils/dirs'
import { net } from 'electron' import { net } from 'electron'
import axios from 'axios'
import fs from 'fs'
let defaultBypass: string[] let defaultBypass: string[]
let triggerSysProxyTimer: NodeJS.Timeout | null = null let triggerSysProxyTimer: NodeJS.Timeout | null = null
const helperSocketPath = '/tmp/mihomo-party-helper.sock'
if (process.platform === 'linux') if (process.platform === 'linux')
defaultBypass = ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1'] defaultBypass = ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1']
@ -66,7 +63,6 @@ export async function triggerSysProxy(enable: boolean): Promise<void> {
} }
async function enableSysProxy(): Promise<void> { async function enableSysProxy(): Promise<void> {
await startPacServer()
const { sysProxy } = await getAppConfig() const { sysProxy } = await getAppConfig()
const { mode, host, bypass = defaultBypass } = sysProxy const { mode, host, bypass = defaultBypass } = sysProxy
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig() const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
@ -82,16 +78,6 @@ async function enableSysProxy(): Promise<void> {
} catch { } catch {
triggerAutoProxy(true, `http://${host || '127.0.0.1'}:${pacPort}/pac`) triggerAutoProxy(true, `http://${host || '127.0.0.1'}:${pacPort}/pac`)
} }
} else if (process.platform === 'darwin') {
await helperRequest(() =>
axios.post(
'http://localhost/pac',
{ url: `http://${host || '127.0.0.1'}:${pacPort}/pac` },
{
socketPath: helperSocketPath
}
)
)
} else { } else {
triggerAutoProxy(true, `http://${host || '127.0.0.1'}:${pacPort}/pac`) triggerAutoProxy(true, `http://${host || '127.0.0.1'}:${pacPort}/pac`)
} }
@ -110,16 +96,6 @@ async function enableSysProxy(): Promise<void> {
} catch { } catch {
triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(',')) triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(','))
} }
} else if (process.platform === 'darwin') {
await helperRequest(() =>
axios.post(
'http://localhost/global',
{ host: host || '127.0.0.1', port: port.toString(), bypass: bypass.join(',') },
{
socketPath: helperSocketPath
}
)
)
} else { } else {
triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(',')) triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(','))
} }
@ -129,7 +105,6 @@ async function enableSysProxy(): Promise<void> {
} }
async function disableSysProxy(): Promise<void> { async function disableSysProxy(): Promise<void> {
await stopPacServer()
const execFilePromise = promisify(execFile) const execFilePromise = promisify(execFile)
if (process.platform === 'win32') { if (process.platform === 'win32') {
try { try {
@ -138,85 +113,8 @@ async function disableSysProxy(): Promise<void> {
triggerAutoProxy(false, '') triggerAutoProxy(false, '')
triggerManualProxy(false, '', 0, '') triggerManualProxy(false, '', 0, '')
} }
} else if (process.platform === 'darwin') {
await helperRequest(() =>
axios.get('http://localhost/off', {
socketPath: helperSocketPath
})
)
} else { } else {
triggerAutoProxy(false, '') triggerAutoProxy(false, '')
triggerManualProxy(false, '', 0, '') triggerManualProxy(false, '', 0, '')
} }
} }
// Helper function to check if socket file exists
function isSocketFileExists(): boolean {
try {
return fs.existsSync(helperSocketPath)
} catch {
return false
}
}
// Helper function to send signal to recreate socket
async function requestSocketRecreation(): Promise<void> {
try {
// Send SIGUSR1 signal to helper process to recreate socket
const { exec } = require('child_process')
const { promisify } = require('util')
const execPromise = promisify(exec)
// Use osascript with administrator privileges (same pattern as manualGrantCorePermition)
const shell = `pkill -USR1 -f party.mihomo.helper`
const command = `do shell script "${shell}" with administrator privileges`
await execPromise(`osascript -e '${command}'`)
// Wait a bit for socket recreation
await new Promise(resolve => setTimeout(resolve, 1000))
} catch (error) {
console.log('Failed to send signal to helper:', error)
throw error
}
}
// Wrapper function for helper requests with auto-retry on socket issues
async function helperRequest(requestFn: () => Promise<unknown>, maxRetries = 1): Promise<unknown> {
let lastError: Error | null = null
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await requestFn()
} catch (error) {
lastError = error as Error
// Check if it's a connection error and socket file doesn't exist
if (attempt < maxRetries &&
((error as NodeJS.ErrnoException).code === 'ECONNREFUSED' ||
(error as NodeJS.ErrnoException).code === 'ENOENT' ||
(error as Error).message?.includes('connect ECONNREFUSED') ||
(error as Error).message?.includes('ENOENT'))) {
console.log(`Helper request failed (attempt ${attempt + 1}), checking socket file...`)
if (!isSocketFileExists()) {
console.log('Socket file missing, requesting recreation...')
try {
await requestSocketRecreation()
console.log('Socket recreation requested, retrying...')
continue
} catch (signalError) {
console.log('Failed to request socket recreation:', signalError)
}
}
}
// If not a connection error or we've exhausted retries, throw the error
if (attempt === maxRetries) {
throw lastError
}
}
}
throw lastError
}

View File

@ -18,13 +18,7 @@ export function dataDir(): string {
} }
export function taskDir(): string { export function taskDir(): string {
const userDataDir = app.getPath('userData') const dir = path.join(app.getPath('userData'), 'tasks')
// 确保 userData 目录存在
if (!existsSync(userDataDir)) {
mkdirSync(userDataDir, { recursive: true })
}
const dir = path.join(userDataDir, 'tasks')
if (!existsSync(dir)) { if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true }) mkdirSync(dir, { recursive: true })
} }
@ -108,20 +102,12 @@ export function mihomoWorkDir(): string {
return path.join(dataDir(), 'work') return path.join(dataDir(), 'work')
} }
export function mihomoProfileWorkDir(id: string | undefined): string {
return path.join(mihomoWorkDir(), id || 'default')
}
export function mihomoTestDir(): string { export function mihomoTestDir(): string {
return path.join(dataDir(), 'test') return path.join(dataDir(), 'test')
} }
export function mihomoWorkConfigPath(id: string | undefined): string { export function mihomoWorkConfigPath(): string {
if (id === 'work') {
return path.join(mihomoWorkDir(), 'config.yaml') return path.join(mihomoWorkDir(), 'config.yaml')
} else {
return path.join(mihomoProfileWorkDir(id), 'config.yaml')
}
} }
export function logDir(): string { export function logDir(): string {
@ -133,9 +119,3 @@ export function logPath(): string {
const name = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}` const name = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
return path.join(logDir(), `${name}.log`) return path.join(logDir(), `${name}.log`)
} }
export function substoreLogPath(): string {
const date = new Date()
const name = `sub-store-${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
return path.join(logDir(), `${name}.log`)
}

View File

@ -1,17 +0,0 @@
import axios from 'axios'
import { getControledMihomoConfig } from '../config'
export async function getImageDataURL(url: string): Promise<string> {
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
const res = await axios.get(url, {
responseType: 'arraybuffer',
proxy: {
protocol: 'http',
host: '127.0.0.1',
port
}
})
const mimeType = res.headers['content-type']
const dataURL = `data:${mimeType};base64,${Buffer.from(res.data).toString('base64')}`
return dataURL
}

View File

@ -22,16 +22,10 @@ import {
defaultProfileConfig defaultProfileConfig
} from './template' } from './template'
import yaml from 'yaml' import yaml from 'yaml'
import { mkdir, writeFile, rm, readdir, cp, stat } from 'fs/promises' import { mkdir, writeFile, copyFile, rm, readdir } from 'fs/promises'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { exec } from 'child_process'
import { promisify } from 'util'
import path from 'path' import path from 'path'
import { import { startPacServer, startSubStoreServer } from '../resolve/server'
startPacServer,
startSubStoreBackendServer,
startSubStoreFrontendServer
} from '../resolve/server'
import { triggerSysProxy } from '../sys/sysproxy' import { triggerSysProxy } from '../sys/sysproxy'
import { import {
getAppConfig, getAppConfig,
@ -42,32 +36,7 @@ import {
import { app } from 'electron' import { app } from 'electron'
import { startSSIDCheck } from '../sys/ssid' import { startSSIDCheck } from '../sys/ssid'
async function fixDataDirPermissions(): Promise<void> {
if (process.platform !== 'darwin') return
const dataDirPath = dataDir()
if (!existsSync(dataDirPath)) return
try {
const stats = await stat(dataDirPath)
const currentUid = process.getuid?.() || 0
if (stats.uid === 0 && currentUid !== 0) {
const execPromise = promisify(exec)
const username = process.env.USER || process.env.LOGNAME
if (username) {
await execPromise(`chown -R "${username}:staff" "${dataDirPath}"`)
await execPromise(`chmod -R u+rwX "${dataDirPath}"`)
}
}
} catch {
// ignore
}
}
async function initDirs(): Promise<void> { async function initDirs(): Promise<void> {
await fixDataDirPermissions()
if (!existsSync(dataDir())) { if (!existsSync(dataDir())) {
await mkdir(dataDir()) await mkdir(dataDir())
} }
@ -115,23 +84,20 @@ async function initConfig(): Promise<void> {
async function initFiles(): Promise<void> { async function initFiles(): Promise<void> {
const copy = async (file: string): Promise<void> => { const copy = async (file: string): Promise<void> => {
const targetPath = path.join(mihomoWorkDir(), file) const targetPath = path.join(mihomoWorkDir(), file)
const testTargetPath = path.join(mihomoTestDir(), file) const testTargrtPath = path.join(mihomoTestDir(), file)
const sourcePath = path.join(resourcesFilesDir(), file) const sourcePath = path.join(resourcesFilesDir(), file)
if (!existsSync(targetPath) && existsSync(sourcePath)) { if (!existsSync(targetPath) && existsSync(sourcePath)) {
await cp(sourcePath, targetPath, { recursive: true }) await copyFile(sourcePath, targetPath)
} }
if (!existsSync(testTargetPath) && existsSync(sourcePath)) { if (!existsSync(testTargrtPath) && existsSync(sourcePath)) {
await cp(sourcePath, testTargetPath, { recursive: true }) await copyFile(sourcePath, testTargrtPath)
} }
} }
await Promise.all([ await Promise.all([
copy('country.mmdb'), copy('country.mmdb'),
copy('geoip.metadb'),
copy('geoip.dat'), copy('geoip.dat'),
copy('geosite.dat'), copy('geosite.dat'),
copy('ASN.mmdb'), copy('ASN.mmdb')
copy('sub-store.bundle.js'),
copy('sub-store-frontend')
]) ])
} }
@ -139,7 +105,7 @@ async function cleanup(): Promise<void> {
// update cache // update cache
const files = await readdir(dataDir()) const files = await readdir(dataDir())
for (const file of files) { for (const file of files) {
if (file.endsWith('.exe') || file.endsWith('.pkg') || file.endsWith('.7z')) { if (file.endsWith('.exe') || file.endsWith('.dmg')) {
try { try {
await rm(path.join(dataDir(), file)) await rm(path.join(dataDir(), file))
} catch { } catch {
@ -182,15 +148,9 @@ async function migration(): Promise<void> {
], ],
appTheme = 'system', appTheme = 'system',
envType = [process.platform === 'win32' ? 'powershell' : 'bash'], envType = [process.platform === 'win32' ? 'powershell' : 'bash'],
useSubStore = true, useSubStore = true
showFloatingWindow = false,
disableTray = false,
encryptedPassword
} = await getAppConfig() } = await getAppConfig()
const { const {
'external-controller-pipe': externalControllerPipe,
'external-controller-unix': externalControllerUnix,
'external-controller': externalController,
'skip-auth-prefixes': skipAuthPrefixes, 'skip-auth-prefixes': skipAuthPrefixes,
authentication, authentication,
'bind-address': bindAddress, 'bind-address': bindAddress,
@ -229,26 +189,6 @@ async function migration(): Promise<void> {
if (typeof envType === 'string') { if (typeof envType === 'string') {
await patchAppConfig({ envType: [envType] }) await patchAppConfig({ envType: [envType] })
} }
// use unix socket
if (externalControllerUnix) {
await patchControledMihomoConfig({ 'external-controller-unix': undefined })
}
// use named pipe
if (externalControllerPipe) {
await patchControledMihomoConfig({
'external-controller-pipe': undefined
})
}
if (externalController === undefined) {
await patchControledMihomoConfig({ 'external-controller': '' })
}
if (!showFloatingWindow && disableTray) {
await patchAppConfig({ disableTray: false })
}
// remove password
if (encryptedPassword) {
await patchAppConfig({ encryptedPassword: undefined })
}
} }
function initDeeplink(): void { function initDeeplink(): void {
@ -269,17 +209,10 @@ export async function init(): Promise<void> {
await migration() await migration()
await initFiles() await initFiles()
await cleanup() await cleanup()
await startSubStoreFrontendServer()
await startSubStoreBackendServer()
const { sysProxy } = await getAppConfig()
try {
if (sysProxy.enable) {
await startPacServer() await startPacServer()
} await startSubStoreServer()
const { sysProxy } = await getAppConfig()
await triggerSysProxy(sysProxy.enable) await triggerSysProxy(sysProxy.enable)
} catch {
// ignore
}
await startSSIDCheck() await startSSIDCheck()
initDeeplink() initDeeplink()

View File

@ -1,4 +1,4 @@
import { app, dialog, ipcMain } from 'electron' import { app, dialog, ipcMain, safeStorage } from 'electron'
import { import {
mihomoChangeProxy, mihomoChangeProxy,
mihomoCloseAllConnections, mihomoCloseAllConnections,
@ -10,7 +10,6 @@ import {
mihomoProxyProviders, mihomoProxyProviders,
mihomoRuleProviders, mihomoRuleProviders,
mihomoRules, mihomoRules,
mihomoUnfixedProxy,
mihomoUpdateProxyProviders, mihomoUpdateProxyProviders,
mihomoUpdateRuleProviders, mihomoUpdateRuleProviders,
mihomoUpgrade, mihomoUpgrade,
@ -31,8 +30,6 @@ import {
removeProfileItem, removeProfileItem,
changeCurrentProfile, changeCurrentProfile,
getProfileStr, getProfileStr,
getFileStr,
setFileStr,
setProfileStr, setProfileStr,
updateProfileItem, updateProfileItem,
setProfileConfig, setProfileConfig,
@ -45,16 +42,13 @@ import {
setOverride, setOverride,
updateOverrideItem updateOverrideItem
} from '../config' } from '../config'
import { startSubStoreServer, subStoreFrontendPort, subStorePort } from '../resolve/server'
import { import {
startSubStoreFrontendServer, isEncryptionAvailable,
startSubStoreBackendServer, manualGrantCorePermition,
stopSubStoreFrontendServer, quitWithoutCore,
stopSubStoreBackendServer, restartCore
downloadSubStore, } from '../core/manager'
subStoreFrontendPort,
subStorePort
} from '../resolve/server'
import { manualGrantCorePermition, quitWithoutCore, restartCore } from '../core/manager'
import { triggerSysProxy } from '../sys/sysproxy' import { triggerSysProxy } from '../sys/sysproxy'
import { checkUpdate, downloadAndInstallUpdate } from '../resolve/autoUpdater' import { checkUpdate, downloadAndInstallUpdate } from '../resolve/autoUpdater'
import { import {
@ -62,16 +56,15 @@ import {
openFile, openFile,
openUWPTool, openUWPTool,
readTextFile, readTextFile,
resetAppConfig,
setNativeTheme, setNativeTheme,
setupFirewall setupFirewall
} from '../sys/misc' } from '../sys/misc'
import { getRuntimeConfig, getRuntimeConfigStr } from '../core/factory' import { getRuntimeConfig, getRuntimeConfigStr } from '../core/factory'
import { listWebdavBackups, webdavBackup, webdavDelete, webdavRestore } from '../resolve/backup' import { listWebdavBackups, webdavBackup, webdavDelete, webdavRestore } from '../resolve/backup'
import { getInterfaces } from '../sys/interface' import { getInterfaces } from '../sys/interface'
import { closeTrayIcon, copyEnv, showTrayIcon } from '../resolve/tray' import { copyEnv } from '../resolve/tray'
import { registerShortcut } from '../resolve/shortcut' import { registerShortcut } from '../resolve/shortcut'
import { closeMainWindow, mainWindow, showMainWindow, triggerMainWindow } from '..' import { mainWindow } from '..'
import { import {
applyTheme, applyTheme,
fetchThemes, fetchThemes,
@ -85,11 +78,6 @@ import { logDir } from './dirs'
import path from 'path' import path from 'path'
import v8 from 'v8' import v8 from 'v8'
import { getGistUrl } from '../resolve/gistApi' import { getGistUrl } from '../resolve/gistApi'
import { getImageDataURL } from './image'
import { startMonitor } from '../resolve/trafficMonitor'
import { closeFloatingWindow, showContextMenu, showFloatingWindow } from '../resolve/floatingWindow'
import i18next from 'i18next'
import { addProfileUpdater } from '../core/profileUpdater'
function ipcErrorWrapper<T>( // eslint-disable-next-line @typescript-eslint/no-explicit-any function ipcErrorWrapper<T>( // eslint-disable-next-line @typescript-eslint/no-explicit-any
fn: (...args: any[]) => Promise<T> // eslint-disable-next-line @typescript-eslint/no-explicit-any fn: (...args: any[]) => Promise<T> // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -131,7 +119,6 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('mihomoChangeProxy', (_e, group, proxy) => ipcMain.handle('mihomoChangeProxy', (_e, group, proxy) =>
ipcErrorWrapper(mihomoChangeProxy)(group, proxy) ipcErrorWrapper(mihomoChangeProxy)(group, proxy)
) )
ipcMain.handle('mihomoUnfixedProxy', (_e, group) => ipcErrorWrapper(mihomoUnfixedProxy)(group))
ipcMain.handle('mihomoUpgradeGeo', ipcErrorWrapper(mihomoUpgradeGeo)) ipcMain.handle('mihomoUpgradeGeo', ipcErrorWrapper(mihomoUpgradeGeo))
ipcMain.handle('mihomoUpgrade', ipcErrorWrapper(mihomoUpgrade)) ipcMain.handle('mihomoUpgrade', ipcErrorWrapper(mihomoUpgrade))
ipcMain.handle('mihomoProxyDelay', (_e, proxy, url) => ipcMain.handle('mihomoProxyDelay', (_e, proxy, url) =>
@ -157,14 +144,11 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('getCurrentProfileItem', ipcErrorWrapper(getCurrentProfileItem)) ipcMain.handle('getCurrentProfileItem', ipcErrorWrapper(getCurrentProfileItem))
ipcMain.handle('getProfileItem', (_e, id) => ipcErrorWrapper(getProfileItem)(id)) ipcMain.handle('getProfileItem', (_e, id) => ipcErrorWrapper(getProfileItem)(id))
ipcMain.handle('getProfileStr', (_e, id) => ipcErrorWrapper(getProfileStr)(id)) ipcMain.handle('getProfileStr', (_e, id) => ipcErrorWrapper(getProfileStr)(id))
ipcMain.handle('getFileStr', (_e, path) => ipcErrorWrapper(getFileStr)(path))
ipcMain.handle('setFileStr', (_e, path, str) => ipcErrorWrapper(setFileStr)(path, str))
ipcMain.handle('setProfileStr', (_e, id, str) => ipcErrorWrapper(setProfileStr)(id, str)) ipcMain.handle('setProfileStr', (_e, id, str) => ipcErrorWrapper(setProfileStr)(id, str))
ipcMain.handle('updateProfileItem', (_e, item) => ipcErrorWrapper(updateProfileItem)(item)) ipcMain.handle('updateProfileItem', (_e, item) => ipcErrorWrapper(updateProfileItem)(item))
ipcMain.handle('changeCurrentProfile', (_e, id) => ipcErrorWrapper(changeCurrentProfile)(id)) ipcMain.handle('changeCurrentProfile', (_e, id) => ipcErrorWrapper(changeCurrentProfile)(id))
ipcMain.handle('addProfileItem', (_e, item) => ipcErrorWrapper(addProfileItem)(item)) ipcMain.handle('addProfileItem', (_e, item) => ipcErrorWrapper(addProfileItem)(item))
ipcMain.handle('removeProfileItem', (_e, id) => ipcErrorWrapper(removeProfileItem)(id)) ipcMain.handle('removeProfileItem', (_e, id) => ipcErrorWrapper(removeProfileItem)(id))
ipcMain.handle('addProfileUpdater', (_e, item) => ipcErrorWrapper(addProfileUpdater)(item))
ipcMain.handle('getOverrideConfig', (_e, force) => ipcErrorWrapper(getOverrideConfig)(force)) ipcMain.handle('getOverrideConfig', (_e, force) => ipcErrorWrapper(getOverrideConfig)(force))
ipcMain.handle('setOverrideConfig', (_e, config) => ipcErrorWrapper(setOverrideConfig)(config)) ipcMain.handle('setOverrideConfig', (_e, config) => ipcErrorWrapper(setOverrideConfig)(config))
ipcMain.handle('getOverrideItem', (_e, id) => ipcErrorWrapper(getOverrideItem)(id)) ipcMain.handle('getOverrideItem', (_e, id) => ipcErrorWrapper(getOverrideItem)(id))
@ -174,9 +158,12 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('getOverride', (_e, id, ext) => ipcErrorWrapper(getOverride)(id, ext)) ipcMain.handle('getOverride', (_e, id, ext) => ipcErrorWrapper(getOverride)(id, ext))
ipcMain.handle('setOverride', (_e, id, ext, str) => ipcErrorWrapper(setOverride)(id, ext, str)) ipcMain.handle('setOverride', (_e, id, ext, str) => ipcErrorWrapper(setOverride)(id, ext, str))
ipcMain.handle('restartCore', ipcErrorWrapper(restartCore)) ipcMain.handle('restartCore', ipcErrorWrapper(restartCore))
ipcMain.handle('startMonitor', (_e, detached) => ipcErrorWrapper(startMonitor)(detached))
ipcMain.handle('triggerSysProxy', (_e, enable) => ipcErrorWrapper(triggerSysProxy)(enable)) ipcMain.handle('triggerSysProxy', (_e, enable) => ipcErrorWrapper(triggerSysProxy)(enable))
ipcMain.handle('manualGrantCorePermition', () => ipcErrorWrapper(manualGrantCorePermition)()) ipcMain.handle('isEncryptionAvailable', isEncryptionAvailable)
ipcMain.handle('encryptString', (_e, str) => encryptString(str))
ipcMain.handle('manualGrantCorePermition', (_e, password) =>
ipcErrorWrapper(manualGrantCorePermition)(password)
)
ipcMain.handle('getFilePath', (_e, ext) => getFilePath(ext)) ipcMain.handle('getFilePath', (_e, ext) => getFilePath(ext))
ipcMain.handle('readTextFile', (_e, filePath) => ipcErrorWrapper(readTextFile)(filePath)) ipcMain.handle('readTextFile', (_e, filePath) => ipcErrorWrapper(readTextFile)(filePath))
ipcMain.handle('getRuntimeConfigStr', ipcErrorWrapper(getRuntimeConfigStr)) ipcMain.handle('getRuntimeConfigStr', ipcErrorWrapper(getRuntimeConfigStr))
@ -197,13 +184,7 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('registerShortcut', (_e, oldShortcut, newShortcut, action) => ipcMain.handle('registerShortcut', (_e, oldShortcut, newShortcut, action) =>
ipcErrorWrapper(registerShortcut)(oldShortcut, newShortcut, action) ipcErrorWrapper(registerShortcut)(oldShortcut, newShortcut, action)
) )
ipcMain.handle('startSubStoreFrontendServer', () => ipcMain.handle('startSubStoreServer', () => ipcErrorWrapper(startSubStoreServer)())
ipcErrorWrapper(startSubStoreFrontendServer)()
)
ipcMain.handle('stopSubStoreFrontendServer', () => ipcErrorWrapper(stopSubStoreFrontendServer)())
ipcMain.handle('startSubStoreBackendServer', () => ipcErrorWrapper(startSubStoreBackendServer)())
ipcMain.handle('stopSubStoreBackendServer', () => ipcErrorWrapper(stopSubStoreBackendServer)())
ipcMain.handle('downloadSubStore', () => ipcErrorWrapper(downloadSubStore)())
ipcMain.handle('subStorePort', () => subStorePort) ipcMain.handle('subStorePort', () => subStorePort)
ipcMain.handle('subStoreFrontendPort', () => subStoreFrontendPort) ipcMain.handle('subStoreFrontendPort', () => subStoreFrontendPort)
ipcMain.handle('subStoreSubs', () => ipcErrorWrapper(subStoreSubs)()) ipcMain.handle('subStoreSubs', () => ipcErrorWrapper(subStoreSubs)())
@ -214,9 +195,7 @@ export function registerIpcMainHandlers(): void {
}) })
ipcMain.handle('setTitleBarOverlay', (_e, overlay) => ipcMain.handle('setTitleBarOverlay', (_e, overlay) =>
ipcErrorWrapper(async (overlay): Promise<void> => { ipcErrorWrapper(async (overlay): Promise<void> => {
if (mainWindow && typeof mainWindow.setTitleBarOverlay === 'function') { mainWindow?.setTitleBarOverlay(overlay)
mainWindow.setTitleBarOverlay(overlay)
}
})(overlay) })(overlay)
) )
ipcMain.handle('setAlwaysOnTop', (_e, alwaysOnTop) => { ipcMain.handle('setAlwaysOnTop', (_e, alwaysOnTop) => {
@ -225,14 +204,6 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('isAlwaysOnTop', () => { ipcMain.handle('isAlwaysOnTop', () => {
return mainWindow?.isAlwaysOnTop() return mainWindow?.isAlwaysOnTop()
}) })
ipcMain.handle('showTrayIcon', () => ipcErrorWrapper(showTrayIcon)())
ipcMain.handle('closeTrayIcon', () => ipcErrorWrapper(closeTrayIcon)())
ipcMain.handle('showMainWindow', showMainWindow)
ipcMain.handle('closeMainWindow', closeMainWindow)
ipcMain.handle('triggerMainWindow', triggerMainWindow)
ipcMain.handle('showFloatingWindow', () => ipcErrorWrapper(showFloatingWindow)())
ipcMain.handle('closeFloatingWindow', () => ipcErrorWrapper(closeFloatingWindow)())
ipcMain.handle('showContextMenu', () => ipcErrorWrapper(showContextMenu)())
ipcMain.handle('openFile', (_e, type, id, ext) => openFile(type, id, ext)) ipcMain.handle('openFile', (_e, type, id, ext) => openFile(type, id, ext))
ipcMain.handle('openDevTools', () => { ipcMain.handle('openDevTools', () => {
mainWindow?.webContents.openDevTools() mainWindow?.webContents.openDevTools()
@ -240,7 +211,6 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('createHeapSnapshot', () => { ipcMain.handle('createHeapSnapshot', () => {
v8.writeHeapSnapshot(path.join(logDir(), `${Date.now()}.heapsnapshot`)) v8.writeHeapSnapshot(path.join(logDir(), `${Date.now()}.heapsnapshot`))
}) })
ipcMain.handle('getImageDataURL', (_e, url) => ipcErrorWrapper(getImageDataURL)(url))
ipcMain.handle('resolveThemes', () => ipcErrorWrapper(resolveThemes)()) ipcMain.handle('resolveThemes', () => ipcErrorWrapper(resolveThemes)())
ipcMain.handle('fetchThemes', () => ipcErrorWrapper(fetchThemes)()) ipcMain.handle('fetchThemes', () => ipcErrorWrapper(fetchThemes)())
ipcMain.handle('importThemes', (_e, file) => ipcErrorWrapper(importThemes)(file)) ipcMain.handle('importThemes', (_e, file) => ipcErrorWrapper(importThemes)(file))
@ -251,18 +221,14 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('alert', (_e, msg) => { ipcMain.handle('alert', (_e, msg) => {
dialog.showErrorBox('Mihomo Party', msg) dialog.showErrorBox('Mihomo Party', msg)
}) })
ipcMain.handle('resetAppConfig', resetAppConfig)
ipcMain.handle('relaunchApp', () => { ipcMain.handle('relaunchApp', () => {
app.relaunch() app.relaunch()
app.quit() app.quit()
}) })
ipcMain.handle('quitWithoutCore', ipcErrorWrapper(quitWithoutCore)) ipcMain.handle('quitWithoutCore', ipcErrorWrapper(quitWithoutCore))
ipcMain.handle('quitApp', () => app.quit()) ipcMain.handle('quitApp', () => app.quit())
}
// Add language change handler
ipcMain.handle('changeLanguage', async (_e, lng) => { function encryptString(str: string): number[] {
await i18next.changeLanguage(lng) return safeStorage.encryptString(str).toJSON().data
// 触发托盘菜单更新
ipcMain.emit('updateTrayMenu')
})
} }

View File

@ -3,36 +3,27 @@ function isObject(item: any): boolean {
return item && typeof item === 'object' && !Array.isArray(item) return item && typeof item === 'object' && !Array.isArray(item)
} }
function trimWrap(str: string): string {
if (str.startsWith('<') && str.endsWith('>')) {
return str.slice(1, -1)
}
return str
}
export function deepMerge<T extends object>(target: T, other: Partial<T>): T { export function deepMerge<T extends object>(target: T, other: Partial<T>): T {
for (const key in other) { for (const key in other) {
if (isObject(other[key])) { if (isObject(other[key])) {
if (key.endsWith('!')) { if (key.endsWith('!')) {
const k = trimWrap(key.slice(0, -1)) const k = key.slice(0, -1)
target[k] = other[key] target[k] = other[key]
} else { } else {
const k = trimWrap(key) if (!target[key]) Object.assign(target, { [key]: {} })
if (!target[k]) Object.assign(target, { [k]: {} }) deepMerge(target[key] as object, other[key] as object)
deepMerge(target[k] as object, other[k] as object)
} }
} else if (Array.isArray(other[key])) { } else if (Array.isArray(other[key])) {
if (key.startsWith('+')) { if (key.startsWith('+')) {
const k = trimWrap(key.slice(1)) const k = key.slice(1)
if (!target[k]) Object.assign(target, { [k]: [] }) if (!target[k]) Object.assign(target, { [k]: [] })
target[k] = [...other[key], ...(target[k] as never[])] target[k] = [...other[key], ...(target[k] as never[])]
} else if (key.endsWith('+')) { } else if (key.endsWith('+')) {
const k = trimWrap(key.slice(0, -1)) const k = key.slice(0, -1)
if (!target[k]) Object.assign(target, { [k]: [] }) if (!target[k]) Object.assign(target, { [k]: [] })
target[k] = [...(target[k] as never[]), ...other[key]] target[k] = [...(target[k] as never[]), ...other[key]]
} else { } else {
const k = trimWrap(key) Object.assign(target, { [key]: other[key] })
Object.assign(target, { [k]: other[key] })
} }
} else { } else {
Object.assign(target, { [key]: other[key] }) Object.assign(target, { [key]: other[key] })

View File

@ -32,12 +32,12 @@ export const defaultConfig: IAppConfig = {
'log', 'log',
'substore' 'substore'
], ],
siderWidth: 250,
sysProxy: { enable: false, mode: 'manual' } sysProxy: { enable: false, mode: 'manual' }
} }
export const defaultControledMihomoConfig: Partial<IMihomoConfig> = { export const defaultControledMihomoConfig: Partial<IMihomoConfig> = {
'external-controller': '', 'external-controller': '127.0.0.1:9090',
secret: '',
ipv6: true, ipv6: true,
mode: 'rule', mode: 'rule',
'mixed-port': 7890, 'mixed-port': 7890,
@ -46,7 +46,7 @@ export const defaultControledMihomoConfig: Partial<IMihomoConfig> = {
'redir-port': 0, 'redir-port': 0,
'tproxy-port': 0, 'tproxy-port': 0,
'allow-lan': false, 'allow-lan': false,
'unified-delay': true, 'unified-delay': false,
'tcp-concurrent': false, 'tcp-concurrent': false,
'log-level': 'info', 'log-level': 'info',
'find-process-mode': 'strict', 'find-process-mode': 'strict',
@ -63,7 +63,6 @@ export const defaultControledMihomoConfig: Partial<IMihomoConfig> = {
'auto-redirect': false, 'auto-redirect': false,
'auto-detect-interface': true, 'auto-detect-interface': true,
'dns-hijack': ['any:53'], 'dns-hijack': ['any:53'],
'route-exclude-address': [],
mtu: 1500 mtu: 1500
}, },
dns: { dns: {
@ -74,9 +73,8 @@ export const defaultControledMihomoConfig: Partial<IMihomoConfig> = {
'fake-ip-filter': ['*', '+.lan', '+.local', 'time.*.com', 'ntp.*.com', '+.market.xiaomi.com'], 'fake-ip-filter': ['*', '+.lan', '+.local', 'time.*.com', 'ntp.*.com', '+.market.xiaomi.com'],
'use-hosts': false, 'use-hosts': false,
'use-system-hosts': false, 'use-system-hosts': false,
nameserver: ['https://120.53.53.53/dns-query', 'https://223.5.5.5/dns-query'], nameserver: ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query'],
'proxy-server-nameserver': ['https://120.53.53.53/dns-query', 'https://223.5.5.5/dns-query'], 'proxy-server-nameserver': ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query']
'direct-nameserver': []
}, },
sniffer: { sniffer: {
enable: true, enable: true,
@ -90,23 +88,12 @@ export const defaultControledMihomoConfig: Partial<IMihomoConfig> = {
}, },
TLS: { TLS: {
ports: [443] ports: [443]
},
QUIC: {
ports: [443]
} }
}, },
'skip-domain': ['+.push.apple.com'], 'skip-domain': ['+.push.apple.com']
'skip-dst-address': [
'91.105.192.0/23',
'91.108.4.0/22',
'91.108.8.0/21',
'91.108.16.0/21',
'91.108.56.0/22',
'95.161.64.0/20',
'149.154.160.0/20',
'185.76.151.0/24',
'2001:67c:4e8::/48',
'2001:b28:f23c::/47',
'2001:b28:f23f::/48',
'2a0a:f280:203::/48'
]
}, },
profile: { profile: {
'store-selected': true, 'store-selected': true,
@ -118,7 +105,7 @@ export const defaultControledMihomoConfig: Partial<IMihomoConfig> = {
'geox-url': { 'geox-url': {
geoip: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip-lite.dat', geoip: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip-lite.dat',
geosite: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat', geosite: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat',
mmdb: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb', mmdb: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country-lite.mmdb',
asn: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb' asn: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb'
} }
} }

View File

@ -1,17 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" lang="zh" />
<title>Mihomo Party Floating</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data:; frame-src http://127.0.0.1:*;"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/floating.tsx"></script>
</body>
</html>

View File

@ -1,10 +1,10 @@
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import { NavigateFunction, useLocation, useNavigate, useRoutes } from 'react-router-dom' import { NavigateFunction, useLocation, useNavigate, useRoutes } from 'react-router-dom'
import OutboundModeSwitcher from '@renderer/components/sider/outbound-mode-switcher' import OutboundModeSwitcher from '@renderer/components/sider/outbound-mode-switcher'
import SysproxySwitcher from '@renderer/components/sider/sysproxy-switcher' import SysproxySwitcher from '@renderer/components/sider/sysproxy-switcher'
import TunSwitcher from '@renderer/components/sider/tun-switcher' import TunSwitcher from '@renderer/components/sider/tun-switcher'
import { Button, Divider } from '@heroui/react' import { Button, Divider } from '@nextui-org/react'
import { IoSettings } from 'react-icons/io5' import { IoSettings } from 'react-icons/io5'
import routes from '@renderer/routes' import routes from '@renderer/routes'
import { import {
@ -35,23 +35,15 @@ import SubStoreCard from '@renderer/components/sider/substore-card'
import MihomoIcon from './components/base/mihomo-icon' import MihomoIcon from './components/base/mihomo-icon'
import { driver } from 'driver.js' import { driver } from 'driver.js'
import 'driver.js/dist/driver.css' import 'driver.js/dist/driver.css'
import { useTranslation } from 'react-i18next'
let navigate: NavigateFunction let navigate: NavigateFunction
let driverInstance: ReturnType<typeof driver> | null = null
export function getDriver(): ReturnType<typeof driver> | null {
return driverInstance
}
const App: React.FC = () => { const App: React.FC = () => {
const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig() const { appConfig, patchAppConfig } = useAppConfig()
const { const {
appTheme = 'system', appTheme = 'system',
customTheme, customTheme,
useWindowFrame = false, useWindowFrame = false,
siderWidth = 250,
siderOrder = [ siderOrder = [
'sysproxy', 'sysproxy',
'tun', 'tun',
@ -68,223 +60,37 @@ const App: React.FC = () => {
'substore' 'substore'
] ]
} = appConfig || {} } = appConfig || {}
const narrowWidth = platform === 'darwin' ? 70 : 60
const [order, setOrder] = useState(siderOrder) const [order, setOrder] = useState(siderOrder)
const [siderWidthValue, setSiderWidthValue] = useState(siderWidth)
const siderWidthValueRef = useRef(siderWidthValue)
const [resizing, setResizing] = useState(false)
const resizingRef = useRef(resizing)
const sensors = useSensors(useSensor(PointerSensor)) const sensors = useSensors(useSensor(PointerSensor))
const { setTheme, systemTheme } = useTheme() const { setTheme, systemTheme } = useTheme()
navigate = useNavigate() navigate = useNavigate()
const location = useLocation() const location = useLocation()
const page = useRoutes(routes) const page = useRoutes(routes)
const setTitlebar = (): void => { const setTitlebar = (): void => {
if (!useWindowFrame && platform !== 'darwin') { if (!useWindowFrame) {
const options = { height: 48 } as TitleBarOverlayOptions const options = { height: 48 } as TitleBarOverlayOptions
try { try {
if (platform !== 'darwin') {
options.color = window.getComputedStyle(document.documentElement).backgroundColor options.color = window.getComputedStyle(document.documentElement).backgroundColor
options.symbolColor = window.getComputedStyle(document.documentElement).color options.symbolColor = window.getComputedStyle(document.documentElement).color
}
setTitleBarOverlay(options) setTitleBarOverlay(options)
} catch (e) { } catch (e) {
// ignore // ignore
} }
} }
} }
useEffect(() => { useEffect(() => {
setOrder(siderOrder) setOrder(siderOrder)
setSiderWidthValue(siderWidth) }, [siderOrder])
}, [siderOrder, siderWidth])
useEffect(() => { useEffect(() => {
siderWidthValueRef.current = siderWidthValue
resizingRef.current = resizing
}, [siderWidthValue, resizing])
useEffect(() => {
driverInstance = driver({
showProgress: true,
nextBtnText: t('common.next'),
prevBtnText: t('common.prev'),
doneBtnText: t('common.done'),
progressText: '{{current}} / {{total}}',
overlayOpacity: 0.9,
steps: [
{
element: 'none',
popover: {
title: t('guide.welcome.title'),
description: t('guide.welcome.description'),
side: 'over',
align: 'center'
}
},
{
element: '.side',
popover: {
title: t('guide.sider.title'),
description: t('guide.sider.description'),
side: 'right',
align: 'center'
}
},
{
element: '.sysproxy-card',
popover: {
title: t('guide.card.title'),
description: t('guide.card.description'),
side: 'right',
align: 'start'
}
},
{
element: '.main',
popover: {
title: t('guide.main.title'),
description: t('guide.main.description'),
side: 'left',
align: 'center'
}
},
{
element: '.profile-card',
popover: {
title: t('guide.profile.title'),
description: t('guide.profile.description'),
side: 'right',
align: 'start',
onNextClick: async (): Promise<void> => {
navigate('/profiles')
setTimeout(() => {
driverInstance?.moveNext()
}, 0)
}
}
},
{
element: '.profiles-sticky',
popover: {
title: t('guide.import.title'),
description: t('guide.import.description'),
side: 'bottom',
align: 'start'
}
},
{
element: '.substore-import',
popover: {
title: t('guide.substore.title'),
description: t('guide.substore.description'),
side: 'bottom',
align: 'start'
}
},
{
element: '.new-profile',
popover: {
title: t('guide.localProfile.title'),
description: t('guide.localProfile.description'),
side: 'bottom',
align: 'start'
}
},
{
element: '.sysproxy-card',
popover: {
title: t('guide.sysproxy.title'),
description: t('guide.sysproxy.description'),
side: 'right',
align: 'start',
onNextClick: async (): Promise<void> => {
navigate('/sysproxy')
setTimeout(() => {
driverInstance?.moveNext()
}, 0)
}
}
},
{
element: '.sysproxy-settings',
popover: {
title: t('guide.sysproxySetting.title'),
description: t('guide.sysproxySetting.description'),
side: 'top',
align: 'start'
}
},
{
element: '.tun-card',
popover: {
title: t('guide.tun.title'),
description: t('guide.tun.description'),
side: 'right',
align: 'start',
onNextClick: async (): Promise<void> => {
navigate('/tun')
setTimeout(() => {
driverInstance?.moveNext()
}, 0)
}
}
},
{
element: '.tun-settings',
popover: {
title: t('guide.tunSetting.title'),
description: t('guide.tunSetting.description'),
side: 'bottom',
align: 'start'
}
},
{
element: '.override-card',
popover: {
title: t('guide.override.title'),
description: t('guide.override.description'),
side: 'right',
align: 'center'
}
},
{
element: '.dns-card',
popover: {
title: t('guide.dns.title'),
description: t('guide.dns.description'),
side: 'right',
align: 'center',
onNextClick: async (): Promise<void> => {
navigate('/profiles')
setTimeout(() => {
driverInstance?.moveNext()
}, 0)
}
}
},
{
element: 'none',
popover: {
title: t('guide.end.title'),
description: t('guide.end.description'),
side: 'top',
align: 'center',
onNextClick: async (): Promise<void> => {
navigate('/profiles')
setTimeout(() => {
driverInstance?.destroy()
}, 0)
}
}
}
]
})
const tourShown = window.localStorage.getItem('tourShown') const tourShown = window.localStorage.getItem('tourShown')
if (!tourShown) { if (!tourShown) {
window.localStorage.setItem('tourShown', 'true') window.localStorage.setItem('tourShown', 'true')
driverInstance.drive() firstDriver.drive()
} }
}, [t]) }, [])
useEffect(() => { useEffect(() => {
setNativeTheme(appTheme) setNativeTheme(appTheme)
@ -298,18 +104,6 @@ const App: React.FC = () => {
}) })
}, [customTheme]) }, [customTheme])
useEffect(() => {
window.addEventListener('mouseup', onResizeEnd)
return (): void => window.removeEventListener('mouseup', onResizeEnd)
}, [])
const onResizeEnd = (): void => {
if (resizingRef.current) {
setResizing(false)
patchAppConfig({ siderWidth: siderWidthValueRef.current })
}
}
const onDragEnd = async (event: DragEndEvent): Promise<void> => { const onDragEnd = async (event: DragEndEvent): Promise<void> => {
const { active, over } = event const { active, over } = event
if (over) { if (over) {
@ -344,74 +138,24 @@ const App: React.FC = () => {
} }
const componentMap = { const componentMap = {
sysproxy: SysproxySwitcher, sysproxy: <SysproxySwitcher key="sysproxy" />,
tun: TunSwitcher, tun: <TunSwitcher key="tun" />,
profile: ProfileCard, profile: <ProfileCard key="profile" />,
proxy: ProxyCard, proxy: <ProxyCard key="proxy" />,
mihomo: MihomoCoreCard, mihomo: <MihomoCoreCard key="mihomo" />,
connection: ConnCard, connection: <ConnCard key="connection" />,
dns: DNSCard, dns: <DNSCard key="dns" />,
sniff: SniffCard, sniff: <SniffCard key="sniff" />,
log: LogCard, log: <LogCard key="log" />,
rule: RuleCard, rule: <RuleCard key="rule" />,
resource: ResourceCard, resource: <ResourceCard key="resource" />,
override: OverrideCard, override: <OverrideCard key="override" />,
substore: SubStoreCard substore: <SubStoreCard key="substore" />
} }
return ( return (
<div <div className="w-full h-[100vh] flex">
onMouseMove={(e) => { <div className="side w-[250px] h-full overflow-y-auto no-scrollbar">
if (!resizing) return
if (e.clientX <= 150) {
setSiderWidthValue(narrowWidth)
} else if (e.clientX <= 250) {
setSiderWidthValue(250)
} else if (e.clientX >= 400) {
setSiderWidthValue(400)
} else {
setSiderWidthValue(e.clientX)
}
}}
className={`w-full h-[100vh] flex ${resizing ? 'cursor-ew-resize' : ''}`}
>
{siderWidthValue === narrowWidth ? (
<div style={{ width: `${narrowWidth}px` }} className="side h-full">
<div className="app-drag flex justify-center items-center z-40 bg-transparent h-[49px]">
{platform !== 'darwin' && (
<MihomoIcon className="h-[32px] leading-[32px] text-lg mx-[1px]" />
)}
<UpdaterButton iconOnly={true} />
</div>
<div className="h-[calc(100%-110px)] overflow-y-auto no-scrollbar">
<div className="h-full w-full flex flex-col gap-2">
{order.map((key: string) => {
const Component = componentMap[key]
if (!Component) return null
return <Component key={key} iconOnly={true} />
})}
</div>
</div>
<div className="mt-2 flex justify-center items-center h-[48px]">
<Button
size="sm"
className="app-nodrag"
isIconOnly
color={location.pathname.includes('/settings') ? 'primary' : 'default'}
variant={location.pathname.includes('/settings') ? 'solid' : 'light'}
onPress={() => {
navigate('/settings')
}}
>
<IoSettings className="text-[20px]" />
</Button>
</div>
</div>
) : (
<div
style={{ width: `${siderWidthValue}px` }}
className="side h-full overflow-y-auto no-scrollbar"
>
<div className="app-drag sticky top-0 z-40 backdrop-blur bg-transparent h-[49px]"> <div className="app-drag sticky top-0 z-40 backdrop-blur bg-transparent h-[49px]">
<div <div
className={`flex justify-between p-2 ${!useWindowFrame && platform === 'darwin' ? 'ml-[60px]' : ''}`} className={`flex justify-between p-2 ${!useWindowFrame && platform === 'darwin' ? 'ml-[60px]' : ''}`}
@ -430,9 +174,8 @@ const App: React.FC = () => {
onPress={() => { onPress={() => {
navigate('/settings') navigate('/settings')
}} }}
> startContent={<IoSettings className="text-[20px]" />}
<IoSettings className="text-[20px]" /> />
</Button>
</div> </div>
</div> </div>
<div className="mt-2 mx-2"> <div className="mt-2 mx-2">
@ -442,39 +185,204 @@ const App: React.FC = () => {
<div className="grid grid-cols-2 gap-2 m-2"> <div className="grid grid-cols-2 gap-2 m-2">
<SortableContext items={order}> <SortableContext items={order}>
{order.map((key: string) => { {order.map((key: string) => {
const Component = componentMap[key] return componentMap[key]
if (!Component) return null
return <Component key={key} />
})} })}
</SortableContext> </SortableContext>
</div> </div>
</DndContext> </DndContext>
</div> </div>
)}
<div
onMouseDown={() => {
setResizing(true)
}}
style={{
position: 'fixed',
zIndex: 50,
left: `${siderWidthValue - 2}px`,
width: '5px',
height: '100vh',
cursor: 'ew-resize'
}}
className={resizing ? 'bg-primary' : ''}
/>
<Divider orientation="vertical" /> <Divider orientation="vertical" />
<div <div className="main w-[calc(100%-251px)] h-full overflow-y-auto">{page}</div>
style={{ width: `calc(100% - ${siderWidthValue + 1}px)` }}
className="main grow h-full overflow-y-auto"
>
{page}
</div>
</div> </div>
) )
} }
export default App export default App
export const firstDriver = driver({
showProgress: true,
nextBtnText: '下一步',
prevBtnText: '上一步',
doneBtnText: '完成',
progressText: '{{current}} / {{total}}',
overlayOpacity: 0.9,
steps: [
{
element: 'none',
popover: {
title: '欢迎使用 Mihomo Party',
description:
'这是一份交互式使用教程,如果您已经完全熟悉本软件的操作,可以直接点击右上角关闭按钮,后续您可以随时从设置中打开本教程',
side: 'over',
align: 'center'
}
},
{
element: '.side',
popover: {
title: '导航栏',
description:
'左侧是应用的导航栏,兼顾仪表盘功能,在这里可以切换不同页面,也可以概览常用的状态信息',
side: 'right',
align: 'center'
}
},
{
element: '.sysproxy-card',
popover: {
title: '卡片',
description: '点击导航栏卡片可以跳转到对应页面,拖动导航栏卡片可以自由排列卡片顺序',
side: 'right',
align: 'start'
}
},
{
element: '.main',
popover: {
title: '主要区域',
description: '右侧是应用的主要区域,展示了导航栏所选页面的内容',
side: 'left',
align: 'center'
}
},
{
element: '.profile-card',
popover: {
title: '订阅管理',
description:
'订阅管理卡片展示当前运行的订阅配置信息,点击进入订阅管理页面可以在这里管理订阅配置',
side: 'right',
align: 'start',
onNextClick: async (): Promise<void> => {
navigate('/profiles')
setTimeout(() => {
firstDriver.moveNext()
}, 0)
}
}
},
{
element: '.profiles-sticky',
popover: {
title: '订阅导入',
description:
'Mihomo Party 支持多种订阅导入方式,在此输入订阅链接,点击导入即可导入您的订阅配置,如果您的订阅需要代理才能更新,请勾选“代理”再点击导入,当然这需要已经有一个可以正常使用的订阅才可以',
side: 'bottom',
align: 'start'
}
},
{
element: '.substore-import',
popover: {
title: 'Sub-Store',
description:
'Mihomo Party 深度集成了 Sub-Store您可以点击该按钮进入 Sub-Store 或直接导入您通过 Sub-Store 管理的订阅Mihomo Party 默认使用内置的 Sub-Store 后端,如果您有自建的 Sub-Store 后端,可以在设置页面中配置,如果您不使用 Sub-Store 也可以在设置页面中关闭',
side: 'bottom',
align: 'start'
}
},
{
element: '.new-profile',
popover: {
title: '本地订阅',
description: '点击“+”可以选择本地文件进行导入或者直接新建空白配置进行编辑',
side: 'bottom',
align: 'start'
}
},
{
element: '.sysproxy-card',
popover: {
title: '系统代理',
description:
'导入订阅之后,内核已经开始运行并监听指定端口,此时您已经可以通过指定代理端口来使用代理了,如果您要使大部分应用自动使用该端口的代理,您还需要打开系统代理开关',
side: 'right',
align: 'start',
onNextClick: async (): Promise<void> => {
navigate('/sysproxy')
setTimeout(() => {
firstDriver.moveNext()
}, 0)
}
}
},
{
element: '.sysproxy-settings',
popover: {
title: '系统代理设置',
description:
'在此您可以进行系统代理相关设置,选择代理模式,如果某些 Windows 应用不遵循系统代理还可以使用“UWP 工具”解除本地回环限制对于“手动代理模式”和“PAC 代理模式”的区别,请自行百度',
side: 'top',
align: 'start'
}
},
{
element: '.tun-card',
popover: {
title: '虚拟网卡',
description:
'虚拟网卡即同类软件中常见的“Tun 模式”,对于某些不遵循系统代理的应用,您可以打开虚拟网卡以让内核接管所有流量',
side: 'right',
align: 'start',
onNextClick: async (): Promise<void> => {
navigate('/tun')
setTimeout(() => {
firstDriver.moveNext()
}, 0)
}
}
},
{
element: '.tun-settings',
popover: {
title: '虚拟网卡设置',
description:
'这里可以更改虚拟网卡相关设置Mihomo Party 理论上已经完全解决权限问题如果您的虚拟网卡仍然不可用可以尝试重设防火墙Windows或手动授权内核MacOS/Linux后重启内核',
side: 'bottom',
align: 'start'
}
},
{
element: '.override-card',
popover: {
title: '覆写',
description:
'Mihomo Party 提供强大的覆写功能,可以对您导入的订阅配置进行个性化修改,如添加规则、自定义代理组等,您可以直接导入别人写好的覆写文件,也可以自己动手编写,<b>编辑好覆写文件一定要记得在需要覆写的订阅上启用</b>,覆写文件的语法请参考 <a href="https://mihomo.party/docs/guide/override" target="_blank">官方文档</a>',
side: 'right',
align: 'center'
}
},
{
element: '.dns-card',
popover: {
title: 'DNS',
description:
'软件默认接管了内核的 DNS 设置,如果您需要使用订阅配置中的 DNS 设置,可以到应用设置中关闭“接管 DNS 设置”,域名嗅探同理',
side: 'right',
align: 'center',
onNextClick: async (): Promise<void> => {
navigate('/profiles')
setTimeout(() => {
firstDriver.moveNext()
}, 0)
}
}
},
{
element: 'none',
popover: {
title: '教程结束',
description:
'现在您已经了解了软件的基本用法,导入您的订阅开始使用吧,祝您使用愉快!\n您还可以加入我们的官方 <a href="https://t.me/mihomo_party_channel" target="_blank">Telegram 频道</a> 获取最新资讯',
side: 'top',
align: 'center',
onNextClick: async (): Promise<void> => {
navigate('/profiles')
setTimeout(() => {
firstDriver.destroy()
}, 0)
}
}
}
]
})

View File

@ -1,101 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import MihomoIcon from './components/base/mihomo-icon'
import { calcTraffic } from './utils/calc'
import { showContextMenu, triggerMainWindow } from './utils/ipc'
import { useAppConfig } from './hooks/use-app-config'
import { useControledMihomoConfig } from './hooks/use-controled-mihomo-config'
const FloatingApp: React.FC = () => {
const { appConfig } = useAppConfig()
const { controledMihomoConfig } = useControledMihomoConfig()
const { sysProxy, spinFloatingIcon = true } = appConfig || {}
const { tun } = controledMihomoConfig || {}
const sysProxyEnabled = sysProxy?.enable
const tunEnabled = tun?.enable
const [upload, setUpload] = useState(0)
const [download, setDownload] = useState(0)
// 根据总速率计算旋转速度
const spinSpeed = useMemo(() => {
const total = upload + download
if (total === 0) return 0
if (total < 1024) return 2
if (total < 1024 * 1024) return 3
if (total < 1024 * 1024 * 1024) return 4
return 5
}, [upload, download])
const [rotation, setRotation] = useState(0)
useEffect(() => {
if (!spinFloatingIcon) return
let animationFrameId: number
const animate = (): void => {
setRotation((prev) => {
if (prev === 360) {
return 0
}
return prev + spinSpeed
})
animationFrameId = requestAnimationFrame(animate)
}
animationFrameId = requestAnimationFrame(animate)
return (): void => {
cancelAnimationFrame(animationFrameId)
}
}, [spinSpeed, spinFloatingIcon])
useEffect(() => {
window.electron.ipcRenderer.on('mihomoTraffic', async (_e, info: IMihomoTrafficInfo) => {
setUpload(info.up)
setDownload(info.down)
})
return (): void => {
window.electron.ipcRenderer.removeAllListeners('mihomoTraffic')
}
}, [])
return (
<div className="app-drag h-[100vh] w-[100vw] overflow-hidden">
<div className="floating-bg border-1 border-divider flex rounded-full bg-content1 h-[calc(100%-2px)] w-[calc(100%-2px)]">
<div className="flex justify-center items-center h-[100%] aspect-square">
<div
onContextMenu={(e) => {
e.preventDefault()
showContextMenu()
}}
onClick={() => {
triggerMainWindow()
}}
style={
spinFloatingIcon
? {
transform: `rotate(${rotation}deg)`,
transition: 'transform 0.1s linear'
}
: {}
}
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`}
>
<MihomoIcon className="floating-icon text-primary-foreground h-full leading-full text-[22px] mx-auto" />
</div>
</div>
<div className="w-full overflow-hidden">
<div className="flex flex-col justify-center h-full w-full">
<h2 className="text-end floating-text whitespace-nowrap text-[12px] mr-2 font-bold">
{calcTraffic(upload)}/s
</h2>
<h2 className="text-end floating-text whitespace-nowrap text-[12px] mr-2 font-bold">
{calcTraffic(download)}/s
</h2>
</div>
</div>
</div>
</div>
)
}
export default FloatingApp

View File

@ -1,28 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.floating-text {
font-family:
'Microsoft YaHei',
system-ui,
-apple-system,
BlinkMacSystemFont;
}
html {
background: none !important;
background-color: transparent !important;
}
.app-nodrag {
-webkit-app-region: none;
}
.app-drag {
-webkit-app-region: drag;
}
* {
user-select: none;
}

View File

@ -8,22 +8,22 @@
} }
.driver-popover { .driver-popover {
background-color: hsl(var(--heroui-content2)) !important; background-color: hsl(var(--nextui-content2)) !important;
border-radius: 8px !important; border-radius: 8px !important;
color: hsl(var(--heroui-foreground)) !important; color: hsl(var(--nextui-foreground)) !important;
} }
.driver-popover a { .driver-popover a {
color: hsl(var(--heroui-primary)) !important; color: hsl(var(--nextui-primary)) !important;
text-decoration: underline !important; text-decoration: underline !important;
} }
.driver-popover-close-btn { .driver-popover-close-btn {
color: hsl(var(--heroui-foreground)) !important; color: hsl(var(--nextui-foreground)) !important;
} }
.driver-popover-progress-text { .driver-popover-progress-text {
color: hsl(var(--heroui-default-500)) !important; color: hsl(var(--nextui-default-500)) !important;
} }
.driver-popover-prev-btn { .driver-popover-prev-btn {
@ -33,7 +33,7 @@
padding: 8px !important; padding: 8px !important;
border-radius: 5px !important; border-radius: 5px !important;
font-size: 12px !important; font-size: 12px !important;
background-color: hsl(var(--heroui-primary)) !important; background-color: hsl(var(--nextui-primary)) !important;
} }
.driver-popover-next-btn { .driver-popover-next-btn {
@ -43,23 +43,23 @@
padding: 8px !important; padding: 8px !important;
border-radius: 5px !important; border-radius: 5px !important;
font-size: 12px !important; font-size: 12px !important;
background-color: hsl(var(--heroui-primary)) !important; background-color: hsl(var(--nextui-primary)) !important;
} }
.driver-popover-arrow-side-bottom { .driver-popover-arrow-side-bottom {
border-bottom-color: hsl(var(--heroui-content2)) !important; border-bottom-color: hsl(var(--nextui-content2)) !important;
} }
.driver-popover-arrow-side-top { .driver-popover-arrow-side-top {
border-top-color: hsl(var(--heroui-content2)) !important; border-top-color: hsl(var(--nextui-content2)) !important;
} }
.driver-popover-arrow-side-left { .driver-popover-arrow-side-left {
border-left-color: hsl(var(--heroui-content2)) !important; border-left-color: hsl(var(--nextui-content2)) !important;
} }
.driver-popover-arrow-side-right { .driver-popover-arrow-side-right {
border-right-color: hsl(var(--heroui-content2)) !important; border-right-color: hsl(var(--nextui-content2)) !important;
} }
.app-nodrag { .app-nodrag {

View File

@ -1,37 +0,0 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
title: string
content: string
onCancel: () => void
onConfirm: () => void
isOpen: boolean
}
const BaseConfirmModal: React.FC<Props> = (props) => {
const { t } = useTranslation()
const { title, content, onCancel, onConfirm, isOpen } = props
return (
<Modal backdrop="blur" classNames={{ backdrop: 'top-[48px]' }} hideCloseButton isOpen={isOpen}>
<ModalContent>
<ModalHeader className="flex app-drag">{title}</ModalHeader>
<ModalBody>
<p>{content}</p>
</ModalBody>
<ModalFooter>
<Button size="sm" variant="light" onPress={onCancel}>
{t('common.cancel')}
</Button>
<Button size="sm" color="danger" onPress={onConfirm}>
{t('common.confirm')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
export default BaseConfirmModal

View File

@ -7,7 +7,7 @@ import pac from 'types-pac/pac.d.ts?raw'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import React from 'react' import React from 'react'
type Language = 'yaml' | 'javascript' | 'css' | 'json' | 'text' type Language = 'yaml' | 'javascript' | 'css'
interface Props { interface Props {
value: string value: string
@ -89,7 +89,7 @@ export const BaseEditor: React.FC<Props> = (props) => {
const trueTheme = theme === 'system' ? systemTheme : theme const trueTheme = theme === 'system' ? systemTheme : theme
const { value, readOnly = false, language, onChange } = props const { value, readOnly = false, language, onChange } = props
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>(undefined) const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>()
const editorWillMount = (): void => { const editorWillMount = (): void => {
monacoInitialization() monacoInitialization()
@ -105,9 +105,7 @@ export const BaseEditor: React.FC<Props> = (props) => {
useEffect(() => { useEffect(() => {
window.onresize = (): void => { window.onresize = (): void => {
setTimeout(() => {
editorRef.current?.layout() editorRef.current?.layout()
}, 0)
} }
return (): void => { return (): void => {
window.onresize = null window.onresize = null
@ -125,9 +123,9 @@ export const BaseEditor: React.FC<Props> = (props) => {
options={{ options={{
tabSize: ['yaml', 'javascript', 'json'].includes(language) ? 2 : 4, // 根据语言类型设置缩进大小 tabSize: ['yaml', 'javascript', 'json'].includes(language) ? 2 : 4, // 根据语言类型设置缩进大小
minimap: { minimap: {
enabled: document.documentElement.clientWidth >= 1500 // 超过一定宽度显示 minimap 滚动条 enabled: document.documentElement.clientWidth >= 1500 // 超过一定宽度显示minimap滚动条
}, },
mouseWheelZoom: true, // 按住 Ctrl 滚轮调节缩放比例 mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
readOnly: readOnly, // 只读模式 readOnly: readOnly, // 只读模式
renderValidationDecorations: 'on', // 只读模式下显示校验信息 renderValidationDecorations: 'on', // 只读模式下显示校验信息
quickSuggestions: { quickSuggestions: {
@ -141,7 +139,6 @@ export const BaseEditor: React.FC<Props> = (props) => {
}} }}
editorWillMount={editorWillMount} editorWillMount={editorWillMount}
editorDidMount={editorDidMount} editorDidMount={editorDidMount}
editorWillUnmount={(): void => { }}
onChange={onChange} onChange={onChange}
/> />
) )

View File

@ -1,15 +1,12 @@
import { Button } from '@heroui/react' import { Button } from '@nextui-org/react'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { ErrorBoundary, FallbackProps } from 'react-error-boundary' import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
const ErrorFallback = ({ error }: FallbackProps): JSX.Element => { const ErrorFallback = ({ error }: FallbackProps): JSX.Element => {
const { t } = useTranslation()
return ( return (
<div className="p-4"> <div className="p-4">
<h2 className="my-2 text-lg font-bold"> <h2 className="my-2 text-lg font-bold">
{t('common.error.appCrash')} {'应用崩溃了 :( 请将以下信息提交给开发者以排查错误'}
</h2> </h2>
<Button <Button
@ -25,7 +22,7 @@ const ErrorFallback = ({ error }: FallbackProps): JSX.Element => {
color="primary" color="primary"
variant="flat" variant="flat"
className="ml-2" className="ml-2"
onPress={() => open('https://t.me/mihomo_party_group')} onPress={() => open('https://t.me/mihomo_party')}
> >
Telegram Telegram
</Button> </Button>
@ -38,7 +35,7 @@ const ErrorFallback = ({ error }: FallbackProps): JSX.Element => {
navigator.clipboard.writeText('```\n' + error.message + '\n' + error.stack + '\n```') navigator.clipboard.writeText('```\n' + error.message + '\n' + error.stack + '\n```')
} }
> >
{t('common.error.copyErrorMessage')}
</Button> </Button>
<p className="my-2">{error.message}</p> <p className="my-2">{error.message}</p>

View File

@ -1,11 +1,9 @@
import { Button, Divider } from '@heroui/react' import { Button, Divider } from '@nextui-org/react'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import { platform } from '@renderer/utils/init' import { platform } from '@renderer/utils/init'
import { isAlwaysOnTop, setAlwaysOnTop } from '@renderer/utils/ipc' import { isAlwaysOnTop, setAlwaysOnTop } from '@renderer/utils/ipc'
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { RiPushpin2Fill, RiPushpin2Line } from 'react-icons/ri' import { RiPushpin2Fill, RiPushpin2Line } from 'react-icons/ri'
import { useTranslation } from 'react-i18next'
interface Props { interface Props {
title?: React.ReactNode title?: React.ReactNode
header?: React.ReactNode header?: React.ReactNode
@ -15,7 +13,6 @@ interface Props {
let saveOnTop = false let saveOnTop = false
const BasePage = forwardRef<HTMLDivElement, Props>((props, ref) => { const BasePage = forwardRef<HTMLDivElement, Props>((props, ref) => {
const { t } = useTranslation()
const { appConfig } = useAppConfig() const { appConfig } = useAppConfig()
const { useWindowFrame = false } = appConfig || {} const { useWindowFrame = false } = appConfig || {}
const [overlayWidth, setOverlayWidth] = React.useState(0) const [overlayWidth, setOverlayWidth] = React.useState(0)
@ -54,7 +51,7 @@ const BasePage = forwardRef<HTMLDivElement, Props>((props, ref) => {
size="sm" size="sm"
className="app-nodrag" className="app-nodrag"
isIconOnly isIconOnly
title={t('common.pinWindow')} title="窗口置顶"
variant="light" variant="light"
color={onTop ? 'primary' : 'default'} color={onTop ? 'primary' : 'default'}
onPress={async () => { onPress={async () => {

View File

@ -0,0 +1,41 @@
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
Input
} from '@nextui-org/react'
import React, { useState } from 'react'
interface Props {
onCancel: () => void
onConfirm: (script: string) => void
}
const BasePasswordModal: React.FC<Props> = (props) => {
const { onCancel, onConfirm } = props
const [password, setPassword] = useState('')
return (
<Modal backdrop="blur" classNames={{ backdrop: 'top-[48px]' }} hideCloseButton isOpen={true}>
<ModalContent>
<ModalHeader className="flex">root密码</ModalHeader>
<ModalBody>
<Input fullWidth type="password" value={password} onValueChange={setPassword} />
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onCancel}>
</Button>
<Button color="primary" onPress={() => onConfirm(password)}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
export default BasePasswordModal

View File

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import { Accordion, AccordionItem, Card, CardBody } from '@heroui/react' import { Accordion, AccordionItem, Card, CardBody } from '@nextui-org/react'
interface Props { interface Props {
title?: string title?: string

View File

@ -1,4 +1,4 @@
import { Divider } from '@heroui/react' import { Divider } from '@nextui-org/react'
import React from 'react' import React from 'react'
@ -14,7 +14,7 @@ const SettingItem: React.FC<Props> = (props) => {
return ( return (
<> <>
<div className="select-text h-[32px] w-full flex justify-between"> <div className="h-[32px] w-full flex justify-between">
<div className="h-full flex items-center"> <div className="h-full flex items-center">
<h4 className="h-full text-md leading-[32px] whitespace-nowrap">{title}</h4> <h4 className="h-full text-md leading-[32px] whitespace-nowrap">{title}</h4>
<div>{actions}</div> <div>{actions}</div>

View File

@ -1,5 +0,0 @@
.border-switch {
input[type='checkbox'] {
width: 100%;
}
}

View File

@ -1,28 +1,24 @@
import React from 'react' import React from 'react'
import { cn, Switch, SwitchProps } from '@heroui/react' import { cn, Switch, SwitchProps } from '@nextui-org/react'
import './border-switch.css'
interface BorderSwitchProps extends Omit<SwitchProps, 'isSelected'> { interface SiderSwitchProps extends SwitchProps {
isShowBorder?: boolean isShowBorder?: boolean
isSelected?: boolean
} }
const BorderSwitch: React.FC<BorderSwitchProps> = (props) => { const BorderSwitch: React.FC<SiderSwitchProps> = (props) => {
const { isShowBorder = false, isSelected = false, classNames, ...switchProps } = props const { isShowBorder = false, classNames, ...switchProps } = props
return ( return (
<Switch <Switch
className="border-switch px-[8px]"
classNames={{ classNames={{
wrapper: cn('border-2', { wrapper: cn('border-2', {
'border-transparent': !isShowBorder, 'border-transparent': !isShowBorder,
'border-primary-foreground': isShowBorder 'border-white': isShowBorder
}), }),
thumb: cn('absolute z-4', 'transform -translate-x-[2px]'), thumb: cn('absolute z-4', 'transform -translate-x-[2px]'),
...classNames ...classNames
}} }}
size="sm" size="sm"
isSelected={isSelected}
{...switchProps} {...switchProps}
/> />
) )

View File

@ -1,5 +1,5 @@
import React, { useRef } from 'react' import React, { useRef } from 'react'
import { Input, InputProps } from '@heroui/react' import { Input, InputProps } from '@nextui-org/react'
import { FaSearch } from 'react-icons/fa' import { FaSearch } from 'react-icons/fa'
interface CollapseInputProps extends InputProps { interface CollapseInputProps extends InputProps {
@ -22,7 +22,7 @@ const CollapseInput: React.FC<CollapseInputProps> = (props) => {
}} }}
endContent={ endContent={
<div <div
className="cursor-pointer p-2 text-lg text-foreground-500" className="cursor-pointer p-2 text-lg text-default-500"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
inputRef.current?.focus() inputRef.current?.focus()
@ -31,7 +31,7 @@ const CollapseInput: React.FC<CollapseInputProps> = (props) => {
<FaSearch title={title} /> <FaSearch title={title} />
</div> </div>
} }
onPress={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
inputRef.current?.focus() inputRef.current?.focus()
}} }}

View File

@ -1,129 +1,15 @@
import { import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@nextui-org/react'
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
Dropdown,
DropdownTrigger,
DropdownMenu,
DropdownItem
} from '@heroui/react'
import React from 'react' import React from 'react'
import SettingItem from '../base/base-setting-item' import SettingItem from '../base/base-setting-item'
import { calcTraffic } from '@renderer/utils/calc' import { calcTraffic } from '@renderer/utils/calc'
import dayjs from '@renderer/utils/dayjs' import dayjs from 'dayjs'
import { BiCopy } from 'react-icons/bi'
import { useTranslation } from 'react-i18next'
interface Props { interface Props {
connection: IMihomoConnectionDetail connection: IMihomoConnectionDetail
onClose: () => void onClose: () => void
} }
const CopyableSettingItem: React.FC<{
title: string
value: string | string[]
displayName?: string
prefix?: string[]
}> = ({ title, value, displayName, prefix = [] }) => {
const { t } = useTranslation()
const getSubDomains = (domain: string): string[] =>
domain.split('.').length <= 2
? [domain]
: domain
.split('.')
.map((_, i, parts) => parts.slice(i).join('.'))
.slice(0, -1)
const isIPv6 = (ip: string) => ip.includes(':')
const menuItems = [
{ key: 'raw', text: displayName || (Array.isArray(value) ? value.join(', ') : value) },
...(Array.isArray(value)
? value.map((v, i) => {
const p = prefix[i]
if (!p || !v) return null
if (p === 'DOMAIN-SUFFIX') {
return getSubDomains(v).map((subV) => ({
key: `${p},${subV}`,
text: `${p},${subV}`
}))
}
if (p === 'IP-ASN' || p === 'SRC-IP-ASN') {
return {
key: `${p},${v.split(' ')[0]}`,
text: `${p},${v.split(' ')[0]}`
}
}
const suffix = (p === 'IP-CIDR' || p === 'SRC-IP-CIDR') ? (isIPv6(v) ? '/128' : '/32') : ''
return {
key: `${p},${v}${suffix}`,
text: `${p},${v}${suffix}`
}
}).filter(Boolean).flat()
: prefix.map(p => {
const v = value as string
if (p === 'DOMAIN-SUFFIX') {
return getSubDomains(v).map((subV) => ({
key: `${p},${subV}`,
text: `${p},${subV}`
}))
}
if (p === 'IP-ASN' || p === 'SRC-IP-ASN') {
return {
key: `${p},${v.split(' ')[0]}`,
text: `${p},${v.split(' ')[0]}`
}
}
const suffix = (p === 'IP-CIDR' || p === 'SRC-IP-CIDR') ? (isIPv6(v) ? '/128' : '/32') : ''
return {
key: `${p},${v}${suffix}`,
text: `${p},${v}${suffix}`
}
}).flat())
]
return (
<SettingItem
title={title}
actions={
<Dropdown>
<DropdownTrigger>
<Button title={t('connections.detail.copyRule')} isIconOnly size="sm" variant="light">
<BiCopy className="text-lg" />
</Button>
</DropdownTrigger>
<DropdownMenu
onAction={(key) =>
navigator.clipboard.writeText(
key === 'raw' ? (Array.isArray(value) ? value.join(', ') : value) : (key as string)
)
}
>
{menuItems
.filter((item) => item !== null)
.map(({ key, text }) => (
<DropdownItem key={key}>{text}</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
}
>
{displayName || (Array.isArray(value) ? value.join(', ') : value)}
</SettingItem>
)
}
const ConnectionDetailModal: React.FC<Props> = (props) => { const ConnectionDetailModal: React.FC<Props> = (props) => {
const { connection, onClose } = props const { connection, onClose } = props
const { t } = useTranslation()
return ( return (
<Modal <Modal
backdrop="blur" backdrop="blur"
@ -135,164 +21,83 @@ const ConnectionDetailModal: React.FC<Props> = (props) => {
scrollBehavior="inside" scrollBehavior="inside"
> >
<ModalContent className="flag-emoji break-all"> <ModalContent className="flag-emoji break-all">
<ModalHeader className="flex app-drag">{t('connections.detail.title')}</ModalHeader> <ModalHeader className="flex"></ModalHeader>
<ModalBody> <ModalBody>
<SettingItem title={t('connections.detail.establishTime')}>{dayjs(connection.start).fromNow()}</SettingItem> <SettingItem title="连接类型">
<SettingItem title={t('connections.detail.rule')}> {connection.metadata.type}({connection.metadata.network})
</SettingItem>
<SettingItem title="连接建立时间">{dayjs(connection.start).fromNow()}</SettingItem>
<SettingItem title="规则">
{connection.rule} {connection.rule}
{connection.rulePayload ? `(${connection.rulePayload})` : ''} {connection.rulePayload ? `(${connection.rulePayload})` : ''}
</SettingItem> </SettingItem>
<SettingItem title={t('connections.detail.proxyChain')}>{[...connection.chains].reverse().join('>>')}</SettingItem> <SettingItem title="代理链">{[...connection.chains].reverse().join('>>')}</SettingItem>
<SettingItem title={t('connections.uploadSpeed')}>{calcTraffic(connection.uploadSpeed || 0)}/s</SettingItem> <SettingItem title="上传速度">{calcTraffic(connection.uploadSpeed || 0)}/s</SettingItem>
<SettingItem title={t('connections.downloadSpeed')}>{calcTraffic(connection.downloadSpeed || 0)}/s</SettingItem> <SettingItem title="下载速度">{calcTraffic(connection.downloadSpeed || 0)}/s</SettingItem>
<SettingItem title={t('connections.uploadAmount')}>{calcTraffic(connection.upload)}</SettingItem> <SettingItem title="上传量">{calcTraffic(connection.upload)}</SettingItem>
<SettingItem title={t('connections.downloadAmount')}>{calcTraffic(connection.download)}</SettingItem> <SettingItem title="下载量">{calcTraffic(connection.download)}</SettingItem>
<CopyableSettingItem
title={t('connections.detail.connectionType')}
value={[connection.metadata.type, connection.metadata.network]}
displayName={`${connection.metadata.type}(${connection.metadata.network})`}
prefix={['IN-TYPE', 'NETWORK']}
/>
{connection.metadata.host && (
<CopyableSettingItem
title={t('connections.detail.host')}
value={connection.metadata.host}
prefix={['DOMAIN', 'DOMAIN-SUFFIX']}
/>
)}
{connection.metadata.sniffHost && (
<CopyableSettingItem
title={t('connections.detail.sniffHost')}
value={connection.metadata.sniffHost}
prefix={['DOMAIN', 'DOMAIN-SUFFIX']}
/>
)}
{connection.metadata.process && ( {connection.metadata.process && (
<CopyableSettingItem <SettingItem title="进程名">
title={t('connections.detail.processName')} {connection.metadata.process}
value={[ {connection.metadata.uid ? `(${connection.metadata.uid})` : ''}
connection.metadata.process, </SettingItem>
...(connection.metadata.uid ? [connection.metadata.uid.toString()] : [])
]}
displayName={`${connection.metadata.process}${connection.metadata.uid ? `(${connection.metadata.uid})` : ''}`}
prefix={['PROCESS-NAME', ...(connection.metadata.uid ? ['UID'] : [])]}
/>
)} )}
{connection.metadata.processPath && ( {connection.metadata.processPath && (
<CopyableSettingItem <SettingItem title="进程路径">{connection.metadata.processPath}</SettingItem>
title={t('connections.detail.processPath')}
value={connection.metadata.processPath}
prefix={['PROCESS-PATH']}
/>
)} )}
{connection.metadata.sourceIP && ( {connection.metadata.sourceIP && (
<CopyableSettingItem <SettingItem title="源IP">{connection.metadata.sourceIP}</SettingItem>
title={t('connections.detail.sourceIP')}
value={connection.metadata.sourceIP}
prefix={['SRC-IP-CIDR']}
/>
)}
{connection.metadata.sourceGeoIP && connection.metadata.sourceGeoIP.length > 0 && (
<CopyableSettingItem
title={t('connections.detail.sourceGeoIP')}
value={connection.metadata.sourceGeoIP}
prefix={['SRC-GEOIP']}
/>
)}
{connection.metadata.sourceIPASN && (
<CopyableSettingItem
title={t('connections.detail.sourceASN')}
value={connection.metadata.sourceIPASN}
prefix={['SRC-IP-ASN']}
/>
)} )}
{connection.metadata.destinationIP && ( {connection.metadata.destinationIP && (
<CopyableSettingItem <SettingItem title="目标IP">{connection.metadata.destinationIP}</SettingItem>
title={t('connections.detail.destinationIP')}
value={connection.metadata.destinationIP}
prefix={['IP-CIDR']}
/>
)} )}
{connection.metadata.destinationGeoIP && {connection.metadata.destinationGeoIP && (
connection.metadata.destinationGeoIP.length > 0 && ( <SettingItem title="目标GeoIP">{connection.metadata.destinationGeoIP}</SettingItem>
<CopyableSettingItem
title={t('connections.detail.destinationGeoIP')}
value={connection.metadata.destinationGeoIP}
prefix={['GEOIP']}
/>
)} )}
{connection.metadata.destinationIPASN && ( {connection.metadata.destinationIPASN && (
<CopyableSettingItem <SettingItem title="目标ASN">{connection.metadata.destinationIPASN}</SettingItem>
title={t('connections.detail.destinationASN')}
value={connection.metadata.destinationIPASN}
prefix={['IP-ASN']}
/>
)} )}
{connection.metadata.sourcePort && ( {connection.metadata.sourcePort && (
<CopyableSettingItem <SettingItem title="源端口">{connection.metadata.sourcePort}</SettingItem>
title={t('connections.detail.sourcePort')}
value={connection.metadata.sourcePort}
prefix={['SRC-PORT']}
/>
)} )}
{connection.metadata.destinationPort && ( {connection.metadata.destinationPort && (
<CopyableSettingItem <SettingItem title="目标端口">{connection.metadata.destinationPort}</SettingItem>
title={t('connections.detail.destinationPort')}
value={connection.metadata.destinationPort}
prefix={['DST-PORT']}
/>
)} )}
{connection.metadata.inboundIP && ( {connection.metadata.inboundIP && (
<CopyableSettingItem <SettingItem title="入站IP">{connection.metadata.inboundIP}</SettingItem>
title={t('connections.detail.inboundIP')}
value={connection.metadata.inboundIP}
prefix={['SRC-IP-CIDR']}
/>
)} )}
{connection.metadata.inboundPort && ( {connection.metadata.inboundPort && (
<CopyableSettingItem <SettingItem title="入站端口">{connection.metadata.inboundPort}</SettingItem>
title={t('connections.detail.inboundPort')}
value={connection.metadata.inboundPort}
prefix={['IN-PORT']}
/>
)} )}
{connection.metadata.inboundName && ( {connection.metadata.inboundName && (
<CopyableSettingItem <SettingItem title="入站名称">{connection.metadata.inboundName}</SettingItem>
title={t('connections.detail.inboundName')}
value={connection.metadata.inboundName}
prefix={['IN-NAME']}
/>
)} )}
{connection.metadata.inboundUser && ( {connection.metadata.inboundUser && (
<CopyableSettingItem <SettingItem title="入站用户">{connection.metadata.inboundUser}</SettingItem>
title={t('connections.detail.inboundUser')}
value={connection.metadata.inboundUser}
prefix={['IN-USER']}
/>
)} )}
{connection.metadata.host && (
<CopyableSettingItem <SettingItem title="主机">{connection.metadata.host}</SettingItem>
title={t('connections.detail.dscp')}
value={connection.metadata.dscp.toString()}
prefix={['DSCP']}
/>
{connection.metadata.remoteDestination && (
<SettingItem title={t('connections.detail.remoteDestination')}>{connection.metadata.remoteDestination}</SettingItem>
)} )}
{connection.metadata.dnsMode && ( {connection.metadata.dnsMode && (
<SettingItem title={t('connections.detail.dnsMode')}>{connection.metadata.dnsMode}</SettingItem> <SettingItem title="DNS模式">{connection.metadata.dnsMode}</SettingItem>
)} )}
{connection.metadata.specialProxy && ( {connection.metadata.specialProxy && (
<SettingItem title={t('connections.detail.specialProxy')}>{connection.metadata.specialProxy}</SettingItem> <SettingItem title="特殊代理">{connection.metadata.specialProxy}</SettingItem>
)} )}
{connection.metadata.specialRules && ( {connection.metadata.specialRules && (
<SettingItem title={t('connections.detail.specialRules')}>{connection.metadata.specialRules}</SettingItem> <SettingItem title="特殊规则">{connection.metadata.specialRules}</SettingItem>
)}
{connection.metadata.remoteDestination && (
<SettingItem title="远程目标">{connection.metadata.remoteDestination}</SettingItem>
)}
<SettingItem title="DSCP">{connection.metadata.dscp}</SettingItem>
{connection.metadata.sniffHost && (
<SettingItem title="嗅探主机">{connection.metadata.sniffHost}</SettingItem>
)} )}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button size="sm" variant="light" onPress={onClose}> <Button variant="light" onPress={onClose}>
{t('connections.detail.close')}
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View File

@ -1,6 +1,6 @@
import { Button, Card, CardFooter, CardHeader, Chip } from '@heroui/react' import { Button, Card, CardFooter, CardHeader, Chip } from '@nextui-org/react'
import { calcTraffic } from '@renderer/utils/calc' import { calcTraffic } from '@renderer/utils/calc'
import dayjs from '@renderer/utils/dayjs' import dayjs from 'dayjs'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { CgClose, CgTrash } from 'react-icons/cg' import { CgClose, CgTrash } from 'react-icons/cg'
@ -24,7 +24,6 @@ const ConnectionItem: React.FC<Props> = (props) => {
return ( return (
<div className={`px-2 pb-2 ${index === 0 ? 'pt-2' : ''}`}> <div className={`px-2 pb-2 ${index === 0 ? 'pt-2' : ''}`}>
<div className="relative">
<Card <Card
isPressable isPressable
className="w-full" className="w-full"
@ -33,15 +32,10 @@ const ConnectionItem: React.FC<Props> = (props) => {
setIsDetailModalOpen(true) setIsDetailModalOpen(true)
}} }}
> >
<div className="w-full"> <div className="w-full flex justify-between">
<div className="w-full pr-12"> <div className="w-[calc(100%-48px)]">
<CardHeader className="pb-0 gap-1"> <CardHeader className="pb-0 gap-1">
<Chip <Chip color={`${info.isActive ? "primary": "danger"}`} size="sm" radius="sm" variant="dot">
color={`${info.isActive ? 'primary' : 'danger'}`}
size="sm"
radius="sm"
variant="dot"
>
{info.metadata.type}({info.metadata.network.toUpperCase()}) {info.metadata.type}({info.metadata.network.toUpperCase()})
</Chip> </Chip>
<div className="text-ellipsis whitespace-nowrap overflow-hidden"> <div className="text-ellipsis whitespace-nowrap overflow-hidden">
@ -52,7 +46,7 @@ const ConnectionItem: React.FC<Props> = (props) => {
info.metadata.destinationIP || info.metadata.destinationIP ||
info.metadata.remoteDestination} info.metadata.remoteDestination}
</div> </div>
<small className="whitespace-nowrap text-foreground-500"> <small className="whitespace-nowrap text-default-500">
{dayjs(info.start).fromNow()} {dayjs(info.start).fromNow()}
</small> </small>
</CardHeader> </CardHeader>
@ -81,20 +75,19 @@ const ConnectionItem: React.FC<Props> = (props) => {
) : null} ) : null}
</CardFooter> </CardFooter>
</div> </div>
</div>
</Card>
<Button <Button
color={`${info.isActive ? 'warning' : 'danger'}`} color={`${info.isActive ? "warning" : "danger"}`}
variant="light" variant="light"
isIconOnly isIconOnly
className="absolute right-2 top-1/2 -translate-y-1/2" className="mr-2 my-auto"
onPress={() => { onPress={() => {
close(info.id) close(info.id)
}} }}
> >
{info.isActive ? <CgClose className="text-lg" /> : <CgTrash className="text-lg" />} {info.isActive ? (<CgClose className="text-lg"/>) : (<CgTrash className="text-lg"/>)}
</Button> </Button>
</div> </div>
</Card>
</div> </div>
) )
} }

View File

@ -1,4 +1,4 @@
import { Card, CardBody, CardHeader } from '@heroui/react' import { Card, CardBody, CardHeader } from '@nextui-org/react'
import React from 'react' import React from 'react'
const colorMap = { const colorMap = {
@ -16,9 +16,9 @@ const LogItem: React.FC<IMihomoLogInfo & { index: number }> = (props) => {
<div className={`mr-2 text-lg font-bold text-${colorMap[type]}`}> <div className={`mr-2 text-lg font-bold text-${colorMap[type]}`}>
{props.type.toUpperCase()} {props.type.toUpperCase()}
</div> </div>
<small className="text-foreground-500">{time}</small> <small className="text-default-500">{time}</small>
</CardHeader> </CardHeader>
<CardBody className="select-text pt-0 text-sm">{payload}</CardBody> <CardBody className="pt-0 text-sm">{payload}</CardBody>
</Card> </Card>
</div> </div>
) )

View File

@ -6,18 +6,14 @@ import {
ModalFooter, ModalFooter,
Button, Button,
Snippet Snippet
} from '@heroui/react' } from '@nextui-org/react'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { getInterfaces } from '@renderer/utils/ipc' import { getInterfaces } from '@renderer/utils/ipc'
import { useTranslation } from 'react-i18next'
interface Props { interface Props {
onClose: () => void onClose: () => void
} }
const InterfaceModal: React.FC<Props> = (props) => { const InterfaceModal: React.FC<Props> = (props) => {
const { onClose } = props const { onClose } = props
const { t } = useTranslation()
const [info, setInfo] = useState<Record<string, NetworkInterfaceInfo[]>>({}) const [info, setInfo] = useState<Record<string, NetworkInterfaceInfo[]>>({})
const getInfo = async (): Promise<void> => { const getInfo = async (): Promise<void> => {
setInfo(await getInterfaces()) setInfo(await getInterfaces())
@ -37,7 +33,7 @@ const InterfaceModal: React.FC<Props> = (props) => {
scrollBehavior="inside" scrollBehavior="inside"
> >
<ModalContent> <ModalContent>
<ModalHeader className="flex app-drag">{t('mihomo.interface.title')}</ModalHeader> <ModalHeader className="flex"></ModalHeader>
<ModalBody> <ModalBody>
{Object.entries(info).map(([key, value]) => { {Object.entries(info).map(([key, value]) => {
return ( return (
@ -60,8 +56,8 @@ const InterfaceModal: React.FC<Props> = (props) => {
})} })}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button size="sm" variant="light" onPress={onClose}> <Button variant="light" onPress={onClose}>
{t('common.close')}
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View File

@ -1,9 +1,7 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react' import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@nextui-org/react'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { BaseEditor } from '../base/base-editor' import { BaseEditor } from '../base/base-editor'
import { getOverride, restartCore, setOverride } from '@renderer/utils/ipc' import { getOverride, restartCore, setOverride } from '@renderer/utils/ipc'
import { useTranslation } from 'react-i18next'
interface Props { interface Props {
id: string id: string
language: 'javascript' | 'yaml' language: 'javascript' | 'yaml'
@ -12,7 +10,6 @@ interface Props {
const EditFileModal: React.FC<Props> = (props) => { const EditFileModal: React.FC<Props> = (props) => {
const { id, language, onClose } = props const { id, language, onClose } = props
const [currData, setCurrData] = useState('') const [currData, setCurrData] = useState('')
const { t } = useTranslation()
const getContent = async (): Promise<void> => { const getContent = async (): Promise<void> => {
setCurrData(await getOverride(id, language === 'javascript' ? 'js' : 'yaml')) setCurrData(await getOverride(id, language === 'javascript' ? 'js' : 'yaml'))
@ -33,10 +30,8 @@ const EditFileModal: React.FC<Props> = (props) => {
scrollBehavior="inside" scrollBehavior="inside"
> >
<ModalContent className="h-full w-[calc(100%-100px)]"> <ModalContent className="h-full w-[calc(100%-100px)]">
<ModalHeader className="flex pb-0 app-drag"> <ModalHeader className="flex pb-0">
{t('override.editFile.title', { {language === 'javascript' ? '脚本' : '配置'}
type: language === 'javascript' ? t('override.editFile.script') : t('override.editFile.config')
})}
</ModalHeader> </ModalHeader>
<ModalBody className="h-full"> <ModalBody className="h-full">
<BaseEditor <BaseEditor
@ -47,7 +42,7 @@ const EditFileModal: React.FC<Props> = (props) => {
</ModalBody> </ModalBody>
<ModalFooter className="pt-0"> <ModalFooter className="pt-0">
<Button size="sm" variant="light" onPress={onClose}> <Button size="sm" variant="light" onPress={onClose}>
{t('common.cancel')}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -62,7 +57,7 @@ const EditFileModal: React.FC<Props> = (props) => {
} }
}} }}
> >
{t('common.confirm')}
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View File

@ -7,12 +7,10 @@ import {
Button, Button,
Input, Input,
Switch Switch
} from '@heroui/react' } from '@nextui-org/react'
import React, { useState } from 'react' import React, { useState } from 'react'
import SettingItem from '../base/base-setting-item' import SettingItem from '../base/base-setting-item'
import { restartCore } from '@renderer/utils/ipc' import { restartCore } from '@renderer/utils/ipc'
import { useTranslation } from 'react-i18next'
interface Props { interface Props {
item: IOverrideItem item: IOverrideItem
updateOverrideItem: (item: IOverrideItem) => Promise<void> updateOverrideItem: (item: IOverrideItem) => Promise<void>
@ -21,7 +19,6 @@ interface Props {
const EditInfoModal: React.FC<Props> = (props) => { const EditInfoModal: React.FC<Props> = (props) => {
const { item, updateOverrideItem, onClose } = props const { item, updateOverrideItem, onClose } = props
const [values, setValues] = useState(item) const [values, setValues] = useState(item)
const { t } = useTranslation()
const onSave = async (): Promise<void> => { const onSave = async (): Promise<void> => {
await updateOverrideItem(values) await updateOverrideItem(values)
@ -39,9 +36,9 @@ const EditInfoModal: React.FC<Props> = (props) => {
scrollBehavior="inside" scrollBehavior="inside"
> >
<ModalContent> <ModalContent>
<ModalHeader className="flex app-drag">{t('override.editInfo.title')}</ModalHeader> <ModalHeader className="flex"></ModalHeader>
<ModalBody> <ModalBody>
<SettingItem title={t('override.editInfo.name')}> <SettingItem title="名称">
<Input <Input
size="sm" size="sm"
className="w-[200px]" className="w-[200px]"
@ -52,7 +49,7 @@ const EditInfoModal: React.FC<Props> = (props) => {
/> />
</SettingItem> </SettingItem>
{values.type === 'remote' && ( {values.type === 'remote' && (
<SettingItem title={t('override.editInfo.url')}> <SettingItem title="地址">
<Input <Input
size="sm" size="sm"
className="w-[200px]" className="w-[200px]"
@ -63,7 +60,7 @@ const EditInfoModal: React.FC<Props> = (props) => {
/> />
</SettingItem> </SettingItem>
)} )}
<SettingItem title={t('override.editInfo.global')}> <SettingItem title="全局启用">
<Switch <Switch
size="sm" size="sm"
isSelected={values.global} isSelected={values.global}
@ -74,11 +71,11 @@ const EditInfoModal: React.FC<Props> = (props) => {
</SettingItem> </SettingItem>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button size="sm" variant="light" onPress={onClose}> <Button variant="light" onPress={onClose}>
{t('common.cancel')}
</Button> </Button>
<Button size="sm" color="primary" onPress={onSave}> <Button color="primary" onPress={onSave}>
{t('common.save')}
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View File

@ -6,11 +6,9 @@ import {
ModalFooter, ModalFooter,
Button, Button,
Divider Divider
} from '@heroui/react' } from '@nextui-org/react'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { getOverride } from '@renderer/utils/ipc' import { getOverride } from '@renderer/utils/ipc'
import { useTranslation } from 'react-i18next'
interface Props { interface Props {
id: string id: string
onClose: () => void onClose: () => void
@ -18,7 +16,6 @@ interface Props {
const ExecLogModal: React.FC<Props> = (props) => { const ExecLogModal: React.FC<Props> = (props) => {
const { id, onClose } = props const { id, onClose } = props
const [logs, setLogs] = useState<string[]>([]) const [logs, setLogs] = useState<string[]>([])
const { t } = useTranslation()
const getLog = async (): Promise<void> => { const getLog = async (): Promise<void> => {
setLogs((await getOverride(id, 'log')).split('\n').filter(Boolean)) setLogs((await getOverride(id, 'log')).split('\n').filter(Boolean))
@ -38,7 +35,7 @@ const ExecLogModal: React.FC<Props> = (props) => {
scrollBehavior="inside" scrollBehavior="inside"
> >
<ModalContent> <ModalContent>
<ModalHeader className="flex app-drag">{t('override.execLog.title')}</ModalHeader> <ModalHeader className="flex"></ModalHeader>
<ModalBody> <ModalBody>
{logs.map((log) => { {logs.map((log) => {
return ( return (
@ -50,8 +47,8 @@ const ExecLogModal: React.FC<Props> = (props) => {
})} })}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button size="sm" variant="light" onPress={onClose}> <Button variant="light" onPress={onClose}>
{t('override.execLog.close')}
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View File

@ -7,9 +7,9 @@ import {
DropdownItem, DropdownItem,
DropdownMenu, DropdownMenu,
DropdownTrigger DropdownTrigger
} from '@heroui/react' } from '@nextui-org/react'
import { IoMdMore, IoMdRefresh } from 'react-icons/io' import { IoMdMore, IoMdRefresh } from 'react-icons/io'
import dayjs from '@renderer/utils/dayjs' import dayjs from 'dayjs'
import React, { Key, useEffect, useMemo, useState } from 'react' import React, { Key, useEffect, useMemo, useState } from 'react'
import EditFileModal from './edit-file-modal' import EditFileModal from './edit-file-modal'
import EditInfoModal from './edit-info-modal' import EditInfoModal from './edit-info-modal'
@ -17,7 +17,6 @@ import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import ExecLogModal from './exec-log-modal' import ExecLogModal from './exec-log-modal'
import { openFile, restartCore } from '@renderer/utils/ipc' import { openFile, restartCore } from '@renderer/utils/ipc'
import { useTranslation } from 'react-i18next'
interface Props { interface Props {
info: IOverrideItem info: IOverrideItem
@ -36,7 +35,6 @@ interface MenuItem {
} }
const OverrideItem: React.FC<Props> = (props) => { const OverrideItem: React.FC<Props> = (props) => {
const { t } = useTranslation()
const { info, addOverrideItem, removeOverrideItem, mutateOverrideConfig, updateOverrideItem } = const { info, addOverrideItem, removeOverrideItem, mutateOverrideConfig, updateOverrideItem } =
props props
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
@ -59,35 +57,35 @@ const OverrideItem: React.FC<Props> = (props) => {
const list = [ const list = [
{ {
key: 'edit-info', key: 'edit-info',
label: t('override.menuItems.editInfo'), label: '编辑信息',
showDivider: false, showDivider: false,
color: 'default', color: 'default',
className: '' className: ''
} as MenuItem, } as MenuItem,
{ {
key: 'edit-file', key: 'edit-file',
label: t('override.menuItems.editFile'), label: '编辑文件',
showDivider: false, showDivider: false,
color: 'default', color: 'default',
className: '' className: ''
} as MenuItem, } as MenuItem,
{ {
key: 'open-file', key: 'open-file',
label: t('override.menuItems.openFile'), label: '打开文件',
showDivider: false, showDivider: false,
color: 'default', color: 'default',
className: '' className: ''
} as MenuItem, } as MenuItem,
{ {
key: 'exec-log', key: 'exec-log',
label: t('override.menuItems.execLog'), label: '执行日志',
showDivider: true, showDivider: true,
color: 'default', color: 'default',
className: '' className: ''
} as MenuItem, } as MenuItem,
{ {
key: 'delete', key: 'delete',
label: t('override.menuItems.delete'), label: '删除',
showDivider: false, showDivider: false,
color: 'danger', color: 'danger',
className: 'text-danger' className: 'text-danger'
@ -97,7 +95,7 @@ const OverrideItem: React.FC<Props> = (props) => {
list.splice(3, 1) list.splice(3, 1)
} }
return list return list
}, [info, t]) }, [info])
const onMenuAction = (key: Key): void => { const onMenuAction = (key: Key): void => {
switch (key) { switch (key) {
case 'edit-info': { case 'edit-info': {
@ -162,7 +160,6 @@ const OverrideItem: React.FC<Props> = (props) => {
)} )}
{openLog && <ExecLogModal id={info.id} onClose={() => setOpenLog(false)} />} {openLog && <ExecLogModal id={info.id} onClose={() => setOpenLog(false)} />}
<Card <Card
as="div"
fullWidth fullWidth
isPressable isPressable
onPress={() => { onPress={() => {
@ -170,10 +167,12 @@ const OverrideItem: React.FC<Props> = (props) => {
setOpenFileEditor(true) setOpenFileEditor(true)
}} }}
> >
<div ref={setNodeRef} {...attributes} {...listeners} className="h-full w-full">
<CardBody> <CardBody>
<div className="flex justify-between h-[32px]"> <div className="flex justify-between h-[32px]">
<h3 <h3
ref={setNodeRef}
{...attributes}
{...listeners}
title={info?.name} title={info?.name}
className={`text-ellipsis whitespace-nowrap overflow-hidden text-md font-bold leading-[32px] text-foreground`} className={`text-ellipsis whitespace-nowrap overflow-hidden text-md font-bold leading-[32px] text-foreground`}
> >
@ -231,9 +230,12 @@ const OverrideItem: React.FC<Props> = (props) => {
<div className={`mt-2 flex justify-start`}> <div className={`mt-2 flex justify-start`}>
{info.global && ( {info.global && (
<Chip size="sm" variant="dot" color="primary" className="mr-2"> <Chip size="sm" variant="dot" color="primary" className="mr-2">
{t('override.labels.global')}
</Chip> </Chip>
)} )}
<Chip size="sm" variant="bordered" className="mr-2">
{info.type === 'local' ? '本地' : '远程'}
</Chip>
<Chip size="sm" variant="bordered"> <Chip size="sm" variant="bordered">
{info.ext === 'yaml' ? 'YAML' : 'JavaScript'} {info.ext === 'yaml' ? 'YAML' : 'JavaScript'}
</Chip> </Chip>
@ -245,7 +247,6 @@ const OverrideItem: React.FC<Props> = (props) => {
)} )}
</div> </div>
</CardBody> </CardBody>
</div>
</Card> </Card>
</div> </div>
) )

View File

@ -1,20 +1,16 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react' import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@nextui-org/react'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { BaseEditor } from '../base/base-editor' import { BaseEditor } from '../base/base-editor'
import { getProfileStr, setProfileStr } from '@renderer/utils/ipc' import { getProfileStr, setProfileStr } from '@renderer/utils/ipc'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
interface Props { interface Props {
id: string id: string
onClose: () => void onClose: () => void
} }
const EditFileModal: React.FC<Props> = (props) => { const EditFileModal: React.FC<Props> = (props) => {
const { id, onClose } = props const { id, onClose } = props
const [currData, setCurrData] = useState('') const [currData, setCurrData] = useState('')
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation()
const getContent = async (): Promise<void> => { const getContent = async (): Promise<void> => {
setCurrData(await getProfileStr(id)) setCurrData(await getProfileStr(id))
@ -35,23 +31,22 @@ const EditFileModal: React.FC<Props> = (props) => {
scrollBehavior="inside" scrollBehavior="inside"
> >
<ModalContent className="h-full w-[calc(100%-100px)]"> <ModalContent className="h-full w-[calc(100%-100px)]">
<ModalHeader className="flex pb-0 app-drag"> <ModalHeader className="flex pb-0">
<div className="flex justify-start"> <div className="flex justify-start">
<div className="flex items-center">{t('profiles.editFile.title')}</div> <div className="flex items-center"></div>
<small className="ml-2 text-foreground-500"> <small className="ml-2 text-default-500">
{t('profiles.editFile.notice')} 使
<Button <Button
size="sm" size="sm"
color="primary" color="primary"
variant="light" variant="light"
className="app-nodrag"
onPress={() => { onPress={() => {
navigate('/override') navigate('/override')
}} }}
> >
{t('profiles.editFile.override')}
</Button> </Button>
{t('profiles.editFile.feature')}
</small> </small>
</div> </div>
</ModalHeader> </ModalHeader>
@ -60,7 +55,7 @@ const EditFileModal: React.FC<Props> = (props) => {
</ModalBody> </ModalBody>
<ModalFooter className="pt-0"> <ModalFooter className="pt-0">
<Button size="sm" variant="light" onPress={onClose}> <Button size="sm" variant="light" onPress={onClose}>
{t('common.cancel')}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -70,7 +65,7 @@ const EditFileModal: React.FC<Props> = (props) => {
onClose() onClose()
}} }}
> >
{t('common.confirm')}
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View File

@ -1,5 +1,4 @@
import { import {
cn,
Modal, Modal,
ModalContent, ModalContent,
ModalHeader, ModalHeader,
@ -12,15 +11,13 @@ import {
DropdownTrigger, DropdownTrigger,
DropdownMenu, DropdownMenu,
DropdownItem DropdownItem
} from '@heroui/react' } from '@nextui-org/react'
import React, { useState } from 'react' import React, { useState } from 'react'
import SettingItem from '../base/base-setting-item' import SettingItem from '../base/base-setting-item'
import { useOverrideConfig } from '@renderer/hooks/use-override-config' import { useOverrideConfig } from '@renderer/hooks/use-override-config'
import { restartCore, addProfileUpdater } from '@renderer/utils/ipc' import { restartCore } from '@renderer/utils/ipc'
import { MdDeleteForever } from 'react-icons/md' import { MdDeleteForever } from 'react-icons/md'
import { FaPlus } from 'react-icons/fa6' import { FaPlus } from 'react-icons/fa6'
import { useTranslation } from 'react-i18next'
interface Props { interface Props {
item: IProfileItem item: IProfileItem
updateProfileItem: (item: IProfileItem) => Promise<void> updateProfileItem: (item: IProfileItem) => Promise<void>
@ -31,20 +28,16 @@ const EditInfoModal: React.FC<Props> = (props) => {
const { overrideConfig } = useOverrideConfig() const { overrideConfig } = useOverrideConfig()
const { items: overrideItems = [] } = overrideConfig || {} const { items: overrideItems = [] } = overrideConfig || {}
const [values, setValues] = useState(item) const [values, setValues] = useState(item)
const inputWidth = 'w-[400px] md:w-[400px] lg:w-[600px] xl:w-[800px]'
const { t } = useTranslation()
const onSave = async (): Promise<void> => { const onSave = async (): Promise<void> => {
try { try {
const updatedItem = { await updateProfileItem({
...values, ...values,
override: values.override?.filter( override: values.override?.filter(
(i) => (i) =>
overrideItems.find((t) => t.id === i) && !overrideItems.find((t) => t.id === i)?.global overrideItems.find((t) => t.id === i) && !overrideItems.find((t) => t.id === i)?.global
) )
}; })
await updateProfileItem(updatedItem)
await addProfileUpdater(updatedItem)
await restartCore() await restartCore()
onClose() onClose()
} catch (e) { } catch (e) {
@ -55,23 +48,19 @@ const EditInfoModal: React.FC<Props> = (props) => {
return ( return (
<Modal <Modal
backdrop="blur" backdrop="blur"
size="5xl" classNames={{ backdrop: 'top-[48px]' }}
classNames={{
backdrop: 'top-[48px]',
base: 'w-[600px] md:w-[600px] lg:w-[800px] xl:w-[1024px]'
}}
hideCloseButton hideCloseButton
isOpen={true} isOpen={true}
onOpenChange={onClose} onOpenChange={onClose}
scrollBehavior="inside" scrollBehavior="inside"
> >
<ModalContent> <ModalContent>
<ModalHeader className="flex app-drag">{t('profiles.editInfo.title')}</ModalHeader> <ModalHeader className="flex"></ModalHeader>
<ModalBody> <ModalBody>
<SettingItem title={t('profiles.editInfo.name')}> <SettingItem title="名称">
<Input <Input
size="sm" size="sm"
className={cn(inputWidth)} className="w-[200px]"
value={values.name} value={values.name}
onValueChange={(v) => { onValueChange={(v) => {
setValues({ ...values, name: v }) setValues({ ...values, name: v })
@ -80,17 +69,17 @@ const EditInfoModal: React.FC<Props> = (props) => {
</SettingItem> </SettingItem>
{values.type === 'remote' && ( {values.type === 'remote' && (
<> <>
<SettingItem title={t('profiles.editInfo.url')}> <SettingItem title="订阅地址">
<Input <Input
size="sm" size="sm"
className={cn(inputWidth)} className="w-[200px]"
value={values.url} value={values.url}
onValueChange={(v) => { onValueChange={(v) => {
setValues({ ...values, url: v }) setValues({ ...values, url: v })
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('profiles.editInfo.useProxy')}> <SettingItem title="使用代理更新">
<Switch <Switch
size="sm" size="sm"
isSelected={values.useProxy ?? false} isSelected={values.useProxy ?? false}
@ -99,29 +88,20 @@ const EditInfoModal: React.FC<Props> = (props) => {
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('profiles.editInfo.interval')}> <SettingItem title="更新间隔(分钟)">
<Input <Input
size="sm" size="sm"
type="number" type="number"
className={cn(inputWidth)} className="w-[200px]"
value={values.interval?.toString() ?? ''} value={values.interval?.toString() ?? ''}
onValueChange={(v) => { onValueChange={(v) => {
setValues({ ...values, interval: parseInt(v) }) setValues({ ...values, interval: parseInt(v) })
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('profiles.editInfo.fixedInterval')}>
<Switch
size="sm"
isSelected={values.allowFixedInterval ?? false}
onValueChange={(v) => {
setValues({ ...values, allowFixedInterval: v })
}}
/>
</SettingItem>
</> </>
)} )}
<SettingItem title={t('profiles.editInfo.override.title')}> <SettingItem title="覆写">
<div> <div>
{overrideItems {overrideItems
.filter((i) => i.global) .filter((i) => i.global)
@ -129,7 +109,7 @@ const EditInfoModal: React.FC<Props> = (props) => {
return ( return (
<div className="flex mb-2" key={i.id}> <div className="flex mb-2" key={i.id}>
<Button disabled fullWidth variant="flat" size="sm"> <Button disabled fullWidth variant="flat" size="sm">
{i.name} ({t('profiles.editInfo.override.global')}) {i.name} ()
</Button> </Button>
</div> </div>
) )
@ -166,7 +146,7 @@ const EditInfoModal: React.FC<Props> = (props) => {
</Button> </Button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu <DropdownMenu
emptyContent={t('profiles.editInfo.override.noAvailable')} emptyContent="没有可用的覆写"
onAction={(key) => { onAction={(key) => {
setValues({ setValues({
...values, ...values,
@ -185,11 +165,11 @@ const EditInfoModal: React.FC<Props> = (props) => {
</SettingItem> </SettingItem>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button size="sm" variant="light" onPress={onClose}> <Button variant="light" onPress={onClose}>
{t('common.cancel')}
</Button> </Button>
<Button size="sm" color="primary" onPress={onSave}> <Button color="primary" onPress={onSave}>
{t('common.save')}
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View File

@ -8,20 +8,17 @@ import {
DropdownItem, DropdownItem,
DropdownMenu, DropdownMenu,
DropdownTrigger, DropdownTrigger,
Progress, Progress
Tooltip } from '@nextui-org/react'
} from '@heroui/react'
import { calcPercent, calcTraffic } from '@renderer/utils/calc' import { calcPercent, calcTraffic } from '@renderer/utils/calc'
import { IoMdMore, IoMdRefresh } from 'react-icons/io' import { IoMdMore, IoMdRefresh } from 'react-icons/io'
import dayjs from '@renderer/utils/dayjs' import dayjs from 'dayjs'
import React, { Key, useEffect, useMemo, useState } from 'react' import React, { Key, useEffect, useMemo, useState } from 'react'
import EditFileModal from './edit-file-modal' import EditFileModal from './edit-file-modal'
import EditInfoModal from './edit-info-modal' import EditInfoModal from './edit-info-modal'
import { useSortable } from '@dnd-kit/sortable' import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { openFile } from '@renderer/utils/ipc' import { openFile } from '@renderer/utils/ipc'
import { useAppConfig } from '@renderer/hooks/use-app-config'
import { useTranslation } from 'react-i18next'
interface Props { interface Props {
info: IProfileItem info: IProfileItem
@ -30,7 +27,7 @@ interface Props {
updateProfileItem: (item: IProfileItem) => Promise<void> updateProfileItem: (item: IProfileItem) => Promise<void>
removeProfileItem: (id: string) => Promise<void> removeProfileItem: (id: string) => Promise<void>
mutateProfileConfig: () => void mutateProfileConfig: () => void
onPress: () => Promise<void> onClick: () => Promise<void>
} }
interface MenuItem { interface MenuItem {
@ -41,26 +38,22 @@ interface MenuItem {
className: string className: string
} }
const ProfileItem: React.FC<Props> = (props) => { const ProfileItem: React.FC<Props> = (props) => {
const { t } = useTranslation()
const { const {
info, info,
addProfileItem, addProfileItem,
removeProfileItem, removeProfileItem,
mutateProfileConfig, mutateProfileConfig,
updateProfileItem, updateProfileItem,
onPress, onClick,
isCurrent isCurrent
} = props } = props
const extra = info?.extra const extra = info?.extra
const usage = (extra?.upload ?? 0) + (extra?.download ?? 0) const usage = (extra?.upload ?? 0) + (extra?.download ?? 0)
const total = extra?.total ?? 0 const total = extra?.total ?? 0
const { appConfig, patchAppConfig } = useAppConfig()
const { profileDisplayDate = 'expire' } = appConfig || {}
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const [selecting, setSelecting] = useState(false) const [selecting, setSelecting] = useState(false)
const [openInfoEditor, setOpenInfoEditor] = useState(false) const [openInfoEditor, setOpenInfoEditor] = useState(false)
const [openFileEditor, setOpenFileEditor] = useState(false) const [openFileEditor, setOpenFileEditor] = useState(false)
const [dropdownOpen, setDropdownOpen] = useState(false)
const { const {
attributes, attributes,
listeners, listeners,
@ -78,28 +71,28 @@ const ProfileItem: React.FC<Props> = (props) => {
const list = [ const list = [
{ {
key: 'edit-info', key: 'edit-info',
label: t('profiles.editInfo.title'), label: '编辑信息',
showDivider: false, showDivider: false,
color: 'default', color: 'default',
className: '' className: ''
} as MenuItem, } as MenuItem,
{ {
key: 'edit-file', key: 'edit-file',
label: t('profiles.editFile.title'), label: '编辑文件',
showDivider: false, showDivider: false,
color: 'default', color: 'default',
className: '' className: ''
} as MenuItem, } as MenuItem,
{ {
key: 'open-file', key: 'open-file',
label: t('profiles.openFile'), label: '打开文件',
showDivider: true, showDivider: true,
color: 'default', color: 'default',
className: '' className: ''
} as MenuItem, } as MenuItem,
{ {
key: 'delete', key: 'delete',
label: t('common.delete'), label: '删除',
showDivider: false, showDivider: false,
color: 'danger', color: 'danger',
className: 'text-danger' className: 'text-danger'
@ -108,14 +101,14 @@ const ProfileItem: React.FC<Props> = (props) => {
if (info.home) { if (info.home) {
list.unshift({ list.unshift({
key: 'home', key: 'home',
label: t('profiles.home'), label: '主页',
showDivider: false, showDivider: false,
color: 'default', color: 'default',
className: '' className: ''
} as MenuItem) } as MenuItem)
} }
return list return list
}, [info, t]) }, [info])
const onMenuAction = async (key: Key): Promise<void> => { const onMenuAction = async (key: Key): Promise<void> => {
switch (key) { switch (key) {
@ -144,12 +137,6 @@ const ProfileItem: React.FC<Props> = (props) => {
} }
} }
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setDropdownOpen(true)
}
useEffect(() => { useEffect(() => {
if (isDragging) { if (isDragging) {
setTimeout(() => { setTimeout(() => {
@ -162,8 +149,6 @@ const ProfileItem: React.FC<Props> = (props) => {
} }
}, [isDragging]) }, [isDragging])
return ( return (
<div <div
className="grid col-span-1" className="grid col-span-1"
@ -182,38 +167,37 @@ const ProfileItem: React.FC<Props> = (props) => {
updateProfileItem={updateProfileItem} updateProfileItem={updateProfileItem}
/> />
)} )}
<Card <Card
as="div"
fullWidth fullWidth
isPressable isPressable
onPress={() => { onPress={() => {
if (disableSelect) return if (disableSelect) return
setSelecting(true) setSelecting(true)
onPress().finally(() => { onClick().finally(() => {
setSelecting(false) setSelecting(false)
}) })
}} }}
onContextMenu={handleContextMenu}
className={`${isCurrent ? 'bg-primary' : ''} ${selecting ? 'blur-sm' : ''}`} className={`${isCurrent ? 'bg-primary' : ''} ${selecting ? 'blur-sm' : ''}`}
> >
<div ref={setNodeRef} {...attributes} {...listeners} className="w-full h-full">
<CardBody className="pb-1"> <CardBody className="pb-1">
<div className="flex justify-between h-[32px]"> <div className="flex justify-between h-[32px]">
<h3 <h3
ref={setNodeRef}
{...attributes}
{...listeners}
title={info?.name} title={info?.name}
className={`text-ellipsis whitespace-nowrap overflow-hidden text-md font-bold leading-[32px] ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`} className={`text-ellipsis whitespace-nowrap overflow-hidden text-md font-bold leading-[32px] ${isCurrent ? 'text-white' : 'text-foreground'}`}
> >
{info?.name} {info?.name}
</h3> </h3>
<div className="flex"> <div className="flex">
{info.type === 'remote' && ( {info.type === 'remote' && (
<Tooltip placement="left" content={dayjs(info.updated).fromNow()}>
<Button <Button
isIconOnly isIconOnly
size="sm" size="sm"
variant="light" variant="light"
color="default" color="default"
title={dayjs(info.updated).fromNow()}
disabled={updating} disabled={updating}
onPress={async () => { onPress={async () => {
setUpdating(true) setUpdating(true)
@ -223,21 +207,17 @@ const ProfileItem: React.FC<Props> = (props) => {
> >
<IoMdRefresh <IoMdRefresh
color="default" color="default"
className={`${isCurrent ? 'text-primary-foreground' : 'text-foreground'} text-[24px] ${updating ? 'animate-spin' : ''}`} className={`${isCurrent ? 'text-white' : 'text-foreground'} text-[24px] ${updating ? 'animate-spin' : ''}`}
/> />
</Button> </Button>
</Tooltip>
)} )}
<Dropdown <Dropdown>
isOpen={dropdownOpen}
onOpenChange={setDropdownOpen}
>
<DropdownTrigger> <DropdownTrigger>
<Button isIconOnly size="sm" variant="light" color="default"> <Button isIconOnly size="sm" variant="light" color="default">
<IoMdMore <IoMdMore
color="default" color="default"
className={`text-[24px] ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`} className={`text-[24px] ${isCurrent ? 'text-white' : 'text-foreground'}`}
/> />
</Button> </Button>
</DropdownTrigger> </DropdownTrigger>
@ -258,75 +238,39 @@ const ProfileItem: React.FC<Props> = (props) => {
</div> </div>
{info.type === 'remote' && extra && ( {info.type === 'remote' && extra && (
<div <div
className={`mt-2 flex justify-between ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`} className={`mt-2 flex justify-between ${isCurrent ? 'text-white' : 'text-foreground'}`}
> >
<small>{`${calcTraffic(usage)}/${calcTraffic(total)}`}</small> <small>{`${calcTraffic(usage)}/${calcTraffic(total)}`}</small>
{profileDisplayDate === 'expire' ? ( <small>
<Button {extra.expire ? dayjs.unix(extra.expire).format('YYYY-MM-DD') : '长期有效'}
size="sm" </small>
variant="light"
className={`h-[20px] p-1 m-0 ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
onPress={async () => {
await patchAppConfig({ profileDisplayDate: 'update' })
}}
>
{extra.expire ? dayjs.unix(extra.expire).format('YYYY-MM-DD') : t('profiles.neverExpire')}
</Button>
) : (
<Button
size="sm"
variant="light"
className={`h-[20px] p-1 m-0 ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
onPress={async () => {
await patchAppConfig({ profileDisplayDate: 'expire' })
}}
>
{dayjs(info.updated).fromNow()}
</Button>
)}
</div>
)}
</CardBody>
<CardFooter className="pt-0">
{info.type === 'remote' && !extra && (
<div
className={`w-full mt-2 flex justify-between ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
>
<Chip
size="sm"
variant="bordered"
className={`${isCurrent ? 'text-primary-foreground border-primary-foreground' : 'border-primary text-primary'}`}
>
{t('profiles.remote')}
</Chip>
<small>{dayjs(info.updated).fromNow()}</small>
</div> </div>
)} )}
{info.type === 'local' && ( {info.type === 'local' && (
<div <div
className={`mt-2 flex justify-between ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`} className={`mt-2 flex justify-between ${isCurrent ? 'text-white' : 'text-foreground'}`}
> >
<Chip <Chip
size="sm" size="sm"
variant="bordered" variant="bordered"
className={`${isCurrent ? 'text-primary-foreground border-primary-foreground' : 'border-primary text-primary'}`} className={`${isCurrent ? 'text-white border-white' : 'border-primary text-primary'}`}
> >
{t('profiles.local')}
</Chip> </Chip>
</div> </div>
)} )}
</CardBody>
<CardFooter className="pt-0">
{extra && ( {extra && (
<Progress <Progress
className="w-full" className="w-full"
aria-label={t('profiles.trafficUsage')}
classNames={{ classNames={{
indicator: isCurrent ? 'bg-primary-foreground' : 'bg-foreground' indicator: isCurrent ? 'bg-white' : 'bg-foreground'
}} }}
value={calcPercent(extra?.upload, extra?.download, extra?.total)} value={calcPercent(extra?.upload, extra?.download, extra?.total)}
/> />
)} )}
</CardFooter> </CardFooter>
</div>
</Card> </Card>
</div> </div>
) )

View File

@ -1,9 +1,5 @@
import { Button, Card, CardBody } from '@heroui/react' import { Button, Card, CardBody } from '@nextui-org/react'
import { mihomoUnfixedProxy } from '@renderer/utils/ipc' import React, { useMemo, useState } from 'react'
import React from 'react'
import { useMemo, useState } from 'react'
import { FaMapPin } from 'react-icons/fa6'
import { useTranslation } from 'react-i18next'
interface Props { interface Props {
mutateProxies: () => void mutateProxies: () => void
@ -13,12 +9,10 @@ interface Props {
group: IMihomoMixedGroup group: IMihomoMixedGroup
onSelect: (group: string, proxy: string) => void onSelect: (group: string, proxy: string) => void
selected: boolean selected: boolean
isGroupTesting?: boolean
} }
const ProxyItem: React.FC<Props> = (props) => { const ProxyItem: React.FC<Props> = (props) => {
const { t } = useTranslation() const { mutateProxies, proxyDisplayMode, group, proxy, selected, onSelect, onProxyDelay } = props
const { mutateProxies, proxyDisplayMode, group, proxy, selected, onSelect, onProxyDelay, isGroupTesting = false } = props
const delay = useMemo(() => { const delay = useMemo(() => {
if (proxy.history.length > 0) { if (proxy.history.length > 0) {
@ -29,8 +23,6 @@ const ProxyItem: React.FC<Props> = (props) => {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const isLoading = loading || isGroupTesting
function delayColor(delay: number): 'primary' | 'success' | 'warning' | 'danger' { function delayColor(delay: number): 'primary' | 'success' | 'warning' | 'danger' {
if (delay === -1) return 'primary' if (delay === -1) return 'primary'
if (delay === 0) return 'danger' if (delay === 0) return 'danger'
@ -39,8 +31,8 @@ const ProxyItem: React.FC<Props> = (props) => {
} }
function delayText(delay: number): string { function delayText(delay: number): string {
if (delay === -1) return t('proxies.delay.test') if (delay === -1) return '测试'
if (delay === 0) return t('proxies.delay.timeout') if (delay === 0) return '超时'
return delay.toString() return delay.toString()
} }
@ -56,112 +48,36 @@ const ProxyItem: React.FC<Props> = (props) => {
return ( return (
<Card <Card
as="div"
onPress={() => onSelect(group.name, proxy.name)} onPress={() => onSelect(group.name, proxy.name)}
isPressable isPressable
fullWidth fullWidth
shadow="sm" shadow="sm"
className={`${ className={`${fixed ? 'bg-secondary/30' : selected ? 'bg-primary/30' : 'bg-content2'}`}
fixed
? 'bg-secondary/30 border-r-2 border-r-secondary border-l-2 border-l-secondary'
: selected
? 'bg-primary/30 border-r-2 border-r-primary border-l-2 border-l-primary'
: 'bg-content2'
}`}
radius="sm" radius="sm"
> >
<CardBody className="p-1"> <CardBody className="p-2">
{proxyDisplayMode === 'full' ? ( <div className="flex justify-between items-center">
<div className="flex flex-col gap-1">
<div className="flex justify-between items-center pl-1">
<div className="text-ellipsis overflow-hidden whitespace-nowrap"> <div className="text-ellipsis overflow-hidden whitespace-nowrap">
<div className="flag-emoji inline" title={proxy.name}> <div className="flag-emoji inline" title={proxy.name}>
{proxy.name} {proxy.name}
</div> </div>
</div> {proxyDisplayMode === 'full' && (
{fixed && ( <div className="inline ml-2 text-default-500" title={proxy.type}>
<Button
isIconOnly
title={t('proxies.unpin')}
color="danger"
onPress={async () => {
await mihomoUnfixedProxy(group.name)
mutateProxies()
}}
variant="light"
className="h-[20px] p-0 text-sm"
>
<FaMapPin className="text-md le" />
</Button>
)}
</div>
<div className="flex justify-between items-center pl-1">
<div className="flex gap-1 items-center">
<div className="text-foreground-400 text-xs bg-default-100 px-1 rounded-md">
{proxy.type} {proxy.type}
</div> </div>
{['tfo', 'udp', 'xudp', 'mptcp', 'smux'].map(protocol =>
proxy[protocol as keyof IMihomoProxy] && (
<div key={protocol} className="text-foreground-400 text-xs bg-default-100 px-1 rounded-md">
{protocol}
</div>
)
)} )}
</div> </div>
<Button <Button
isIconOnly
title={proxy.type} title={proxy.type}
isLoading={isLoading} isLoading={loading}
color={delayColor(delay)} color={delayColor(delay)}
onPress={onDelay} onPress={onDelay}
variant="light" variant="light"
className="h-full text-sm ml-auto -mt-0.5 px-2 relative w-min whitespace-nowrap" className="h-full min-w-[50px] p-0 mx-2 text-sm hover:bg-content"
> >
<div className="w-full h-full flex items-center justify-end">
{delayText(delay)} {delayText(delay)}
</div>
</Button> </Button>
</div> </div>
</div>
) : (
<div className="flex justify-between items-center pl-1">
<div className="text-ellipsis overflow-hidden whitespace-nowrap">
<div className="flag-emoji inline" title={proxy.name}>
{proxy.name}
</div>
</div>
<div className="flex justify-end">
{fixed && (
<Button
isIconOnly
title={t('proxies.unpin')}
color="danger"
onPress={async () => {
await mihomoUnfixedProxy(group.name)
mutateProxies()
}}
variant="light"
className="h-[20px] p-0 text-sm"
>
<FaMapPin className="text-md le" />
</Button>
)}
<Button
isIconOnly
title={proxy.type}
isLoading={isLoading}
color={delayColor(delay)}
onPress={onDelay}
variant="light"
className="h-full text-sm px-2 relative w-min whitespace-nowrap"
>
<div className="w-full h-full flex items-center justify-end">
{delayText(delay)}
</div>
</Button>
</div>
</div>
)}
</CardBody> </CardBody>
</Card> </Card>
) )

View File

@ -1,20 +1,18 @@
import { Button, Input, Switch, Tab, Tabs } from '@heroui/react' import { Button, Input, Switch, Tab, Tabs } from '@nextui-org/react'
import SettingCard from '@renderer/components/base/base-setting-card' import SettingCard from '@renderer/components/base/base-setting-card'
import SettingItem from '@renderer/components/base/base-setting-item' import SettingItem from '@renderer/components/base/base-setting-item'
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config' import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
import { mihomoUpgradeGeo } from '@renderer/utils/ipc' import { mihomoUpgradeGeo } from '@renderer/utils/ipc'
import { useState } from 'react' import { useState } from 'react'
import { IoMdRefresh } from 'react-icons/io' import { IoMdRefresh } from 'react-icons/io'
import { useTranslation } from 'react-i18next'
const GeoData: React.FC = () => { const GeoData: React.FC = () => {
const { t } = useTranslation()
const { controledMihomoConfig, patchControledMihomoConfig } = useControledMihomoConfig() const { controledMihomoConfig, patchControledMihomoConfig } = useControledMihomoConfig()
const { const {
'geox-url': geoxUrl = { 'geox-url': geoxUrl = {
geoip: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip-lite.dat', geoip: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip-lite.dat',
geosite: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat', geosite: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat',
mmdb: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb', mmdb: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country-lite.mmdb',
asn: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb' asn: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb'
}, },
'geodata-mode': geoMode = false, 'geodata-mode': geoMode = false,
@ -29,7 +27,7 @@ const GeoData: React.FC = () => {
return ( return (
<SettingCard> <SettingCard>
<SettingItem title={t('resources.geoData.geoip')} divider> <SettingItem title="GeoIP 数据库" divider>
<div className="flex w-[70%]"> <div className="flex w-[70%]">
{geoipInput !== geoxUrl.geoip && ( {geoipInput !== geoxUrl.geoip && (
<Button <Button
@ -40,13 +38,13 @@ const GeoData: React.FC = () => {
patchControledMihomoConfig({ 'geox-url': { ...geoxUrl, geoip: geoipInput } }) patchControledMihomoConfig({ 'geox-url': { ...geoxUrl, geoip: geoipInput } })
}} }}
> >
{t('common.confirm')}
</Button> </Button>
)} )}
<Input size="sm" value={geoipInput} onValueChange={setGeoIpInput} /> <Input size="sm" value={geoipInput} onValueChange={setGeoIpInput} />
</div> </div>
</SettingItem> </SettingItem>
<SettingItem title={t('resources.geoData.geosite')} divider> <SettingItem title="GeoSite 数据库" divider>
<div className="flex w-[70%]"> <div className="flex w-[70%]">
{geositeInput !== geoxUrl.geosite && ( {geositeInput !== geoxUrl.geosite && (
<Button <Button
@ -57,13 +55,13 @@ const GeoData: React.FC = () => {
patchControledMihomoConfig({ 'geox-url': { ...geoxUrl, geosite: geositeInput } }) patchControledMihomoConfig({ 'geox-url': { ...geoxUrl, geosite: geositeInput } })
}} }}
> >
{t('common.confirm')}
</Button> </Button>
)} )}
<Input size="sm" value={geositeInput} onValueChange={setGeositeInput} /> <Input size="sm" value={geositeInput} onValueChange={setGeositeInput} />
</div> </div>
</SettingItem> </SettingItem>
<SettingItem title={t('resources.geoData.mmdb')} divider> <SettingItem title="MMDB 数据库" divider>
<div className="flex w-[70%]"> <div className="flex w-[70%]">
{mmdbInput !== geoxUrl.mmdb && ( {mmdbInput !== geoxUrl.mmdb && (
<Button <Button
@ -74,13 +72,13 @@ const GeoData: React.FC = () => {
patchControledMihomoConfig({ 'geox-url': { ...geoxUrl, mmdb: mmdbInput } }) patchControledMihomoConfig({ 'geox-url': { ...geoxUrl, mmdb: mmdbInput } })
}} }}
> >
{t('common.confirm')}
</Button> </Button>
)} )}
<Input size="sm" value={mmdbInput} onValueChange={setMmdbInput} /> <Input size="sm" value={mmdbInput} onValueChange={setMmdbInput} />
</div> </div>
</SettingItem> </SettingItem>
<SettingItem title={t('resources.geoData.asn')} divider> <SettingItem title="ASN 数据库" divider>
<div className="flex w-[70%]"> <div className="flex w-[70%]">
{asnInput !== geoxUrl.asn && ( {asnInput !== geoxUrl.asn && (
<Button <Button
@ -91,13 +89,13 @@ const GeoData: React.FC = () => {
patchControledMihomoConfig({ 'geox-url': { ...geoxUrl, asn: asnInput } }) patchControledMihomoConfig({ 'geox-url': { ...geoxUrl, asn: asnInput } })
}} }}
> >
{t('common.confirm')}
</Button> </Button>
)} )}
<Input size="sm" value={asnInput} onValueChange={setAsnInput} /> <Input size="sm" value={asnInput} onValueChange={setAsnInput} />
</div> </div>
</SettingItem> </SettingItem>
<SettingItem title={t('resources.geoData.mode')} divider> <SettingItem title="GeoIP 数据模式" divider>
<Tabs <Tabs
size="sm" size="sm"
color="primary" color="primary"
@ -111,7 +109,7 @@ const GeoData: React.FC = () => {
</Tabs> </Tabs>
</SettingItem> </SettingItem>
<SettingItem <SettingItem
title={t('resources.geoData.autoUpdate')} title="自动更新 Geo 数据库"
actions={ actions={
<Button <Button
size="sm" size="sm"
@ -121,7 +119,7 @@ const GeoData: React.FC = () => {
setUpdating(true) setUpdating(true)
try { try {
await mihomoUpgradeGeo() await mihomoUpgradeGeo()
new Notification(t('resources.geoData.updateSuccess')) new Notification('Geo 数据库更新成功')
} catch (e) { } catch (e) {
alert(e) alert(e)
} finally { } finally {
@ -143,7 +141,7 @@ const GeoData: React.FC = () => {
/> />
</SettingItem> </SettingItem>
{geoAutoUpdate && ( {geoAutoUpdate && (
<SettingItem title={t('resources.geoData.updateInterval')}> <SettingItem title="更新间隔(小时)">
<Input <Input
size="sm" size="sm"
type="number" type="number"

View File

@ -1,59 +1,22 @@
import { import { mihomoProxyProviders, mihomoUpdateProxyProviders } from '@renderer/utils/ipc'
mihomoProxyProviders, import { Fragment, useMemo, useState } from 'react'
mihomoUpdateProxyProviders,
getRuntimeConfig
} from '@renderer/utils/ipc'
import { Fragment, useEffect, useMemo, useState } from 'react'
import Viewer from './viewer'
import useSWR from 'swr' import useSWR from 'swr'
import SettingCard from '../base/base-setting-card' import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item' import SettingItem from '../base/base-setting-item'
import { Button, Chip } from '@heroui/react' import { Button, Chip } from '@nextui-org/react'
import { IoMdRefresh } from 'react-icons/io' import { IoMdRefresh } from 'react-icons/io'
import { CgLoadbarDoc } from 'react-icons/cg' import dayjs from 'dayjs'
import { MdEditDocument } from 'react-icons/md'
import dayjs from '@renderer/utils/dayjs'
import { calcTraffic } from '@renderer/utils/calc' import { calcTraffic } from '@renderer/utils/calc'
import { getHash } from '@renderer/utils/hash'
import { useTranslation } from 'react-i18next'
const ProxyProvider: React.FC = () => {
const { t } = useTranslation()
const [showDetails, setShowDetails] = useState({
show: false,
path: '',
type: '',
title: '',
privderType: ''
})
useEffect(() => {
if (showDetails.title) {
const fetchProviderPath = async (name: string): Promise<void> => {
try {
const providers= await getRuntimeConfig()
const provider = providers['proxy-providers'][name]
if (provider) {
setShowDetails((prev) => ({
...prev,
show: true,
path: provider?.path || `proxies/${getHash(provider?.url)}`
}))
}
} catch {
setShowDetails((prev) => ({ ...prev, path: '' }))
}
}
fetchProviderPath(showDetails.title)
}
}, [showDetails.title])
const ProxyProvider: React.FC = () => {
const { data, mutate } = useSWR('mihomoProxyProviders', mihomoProxyProviders) const { data, mutate } = useSWR('mihomoProxyProviders', mihomoProxyProviders)
const providers = useMemo(() => { const providers = useMemo(() => {
if (!data) return [] if (!data) return []
return Object.values(data.providers) if (!data.providers) return []
.filter((provider) => provider.vehicleType !== 'Compatible') return Object.keys(data.providers)
.sort((a, b) => { .map((key) => data.providers[key])
const order = { File: 1, Inline: 2, HTTP: 3 } .filter((provider) => {
return (order[a.vehicleType] || 4) - (order[b.vehicleType] || 4) return 'subscriptionInfo' in provider
}) })
}, [data]) }, [data])
const [updating, setUpdating] = useState(Array(providers.length).fill(false)) const [updating, setUpdating] = useState(Array(providers.length).fill(false))
@ -82,16 +45,7 @@ const ProxyProvider: React.FC = () => {
return ( return (
<SettingCard> <SettingCard>
{showDetails.show && ( <SettingItem title="代理集合" divider>
<Viewer
path={showDetails.path}
type={showDetails.type}
title={showDetails.title}
privderType={showDetails.privderType}
onClose={() => setShowDetails({ show: false, path: '', type: '', title: '', privderType: '' })}
/>
)}
<SettingItem title={t('resources.proxyProviders.title')} divider>
<Button <Button
size="sm" size="sm"
color="primary" color="primary"
@ -101,10 +55,11 @@ const ProxyProvider: React.FC = () => {
}) })
}} }}
> >
{t('resources.proxyProviders.updateAll')}
</Button> </Button>
</SettingItem> </SettingItem>
{providers.map((provider, index) => ( {providers.map((provider, index) => {
return (
<Fragment key={provider.name}> <Fragment key={provider.name}>
<SettingItem <SettingItem
title={provider.name} title={provider.name}
@ -115,35 +70,11 @@ const ProxyProvider: React.FC = () => {
} }
divider={!provider.subscriptionInfo && index !== providers.length - 1} divider={!provider.subscriptionInfo && index !== providers.length - 1}
> >
<div className="flex h-[32px] leading-[32px] text-foreground-500"> {
<div className="flex h-[32px] leading-[32px] text-default-500">
<div>{dayjs(provider.updatedAt).fromNow()}</div> <div>{dayjs(provider.updatedAt).fromNow()}</div>
{/* <Button isIconOnly className="ml-2" size="sm">
<IoMdEye className="text-lg" />
</Button> */}
<Button <Button
isIconOnly isIconOnly
title={provider.vehicleType == 'File' ? t('common.editor.edit') : t('common.viewer.view')}
className="ml-2"
size="sm"
onPress={() => {
setShowDetails({
show: false,
privderType: 'proxy-providers',
path: provider.name,
type: provider.vehicleType,
title: provider.name
})
}}
>
{provider.vehicleType == 'File' ? (
<MdEditDocument className={`text-lg`} />
) : (
<CgLoadbarDoc className={`text-lg`} />
)}
</Button>
<Button
isIconOnly
title={t('common.updater.update')}
className="ml-2" className="ml-2"
size="sm" size="sm"
onPress={() => { onPress={() => {
@ -153,27 +84,30 @@ const ProxyProvider: React.FC = () => {
<IoMdRefresh className={`text-lg ${updating[index] ? 'animate-spin' : ''}`} /> <IoMdRefresh className={`text-lg ${updating[index] ? 'animate-spin' : ''}`} />
</Button> </Button>
</div> </div>
}
</SettingItem> </SettingItem>
{provider.subscriptionInfo && ( {provider.subscriptionInfo && (
<SettingItem <SettingItem
divider={index !== providers.length - 1} divider={index !== providers.length - 1}
title={ title={
<div className="text-foreground-500"> <div className="text-default-500">{`${calcTraffic(
{`${calcTraffic(
provider.subscriptionInfo.Upload + provider.subscriptionInfo.Download provider.subscriptionInfo.Upload + provider.subscriptionInfo.Download
)} / ${calcTraffic(provider.subscriptionInfo.Total)}`} )}
</div> /${calcTraffic(provider.subscriptionInfo.Total)}`}</div>
} }
> >
<div className="h-[32px] leading-[32px] text-foreground-500"> {provider.subscriptionInfo && (
<div className="h-[32px] leading-[32px] text-default-500">
{provider.subscriptionInfo.Expire {provider.subscriptionInfo.Expire
? dayjs.unix(provider.subscriptionInfo.Expire).format('YYYY-MM-DD') ? dayjs.unix(provider.subscriptionInfo.Expire).format('YYYY-MM-DD')
: t('profiles.neverExpire')} : '长期有效'}
</div> </div>
)}
</SettingItem> </SettingItem>
)} )}
</Fragment> </Fragment>
))} )
})}
</SettingCard> </SettingCard>
) )
} }

View File

@ -1,64 +1,18 @@
import { import { mihomoRuleProviders, mihomoUpdateRuleProviders } from '@renderer/utils/ipc'
mihomoRuleProviders, import { Fragment, useMemo, useState } from 'react'
mihomoUpdateRuleProviders,
getRuntimeConfig
} from '@renderer/utils/ipc'
import { getHash } from '@renderer/utils/hash'
import Viewer from './viewer'
import { Fragment, useEffect, useMemo, useState } from 'react'
import useSWR from 'swr' import useSWR from 'swr'
import SettingCard from '../base/base-setting-card' import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item' import SettingItem from '../base/base-setting-item'
import { Button, Chip } from '@heroui/react' import { Button, Chip } from '@nextui-org/react'
import { IoMdRefresh } from 'react-icons/io' import { IoMdRefresh } from 'react-icons/io'
import { CgLoadbarDoc } from 'react-icons/cg' import dayjs from 'dayjs'
import { MdEditDocument } from 'react-icons/md'
import dayjs from '@renderer/utils/dayjs'
import { useTranslation } from 'react-i18next'
const RuleProvider: React.FC = () => { const RuleProvider: React.FC = () => {
const { t } = useTranslation()
const [showDetails, setShowDetails] = useState({
show: false,
path: '',
type: '',
title: '',
format: '',
privderType: ''
})
useEffect(() => {
if (showDetails.title) {
const fetchProviderPath = async (name: string): Promise<void> => {
try {
const providers= await getRuntimeConfig()
const provider = providers['rule-providers'][name]
if (provider) {
setShowDetails((prev) => ({
...prev,
show: true,
path: provider?.path || `rules/${getHash(provider?.url)}`
}))
}
} catch {
setShowDetails((prev) => ({ ...prev, path: '' }))
}
}
fetchProviderPath(showDetails.title)
}
}, [showDetails.title])
const { data, mutate } = useSWR('mihomoRuleProviders', mihomoRuleProviders) const { data, mutate } = useSWR('mihomoRuleProviders', mihomoRuleProviders)
const providers = useMemo(() => { const providers = useMemo(() => {
if (!data) return [] if (!data) return []
return Object.values(data.providers).sort((a, b) => { if (!data.providers) return []
if (a.vehicleType === 'File' && b.vehicleType !== 'File') { return Object.keys(data.providers).map((key) => data.providers[key])
return -1
}
if (a.vehicleType !== 'File' && b.vehicleType === 'File') {
return 1
}
return 0
})
}, [data]) }, [data])
const [updating, setUpdating] = useState(Array(providers.length).fill(false)) const [updating, setUpdating] = useState(Array(providers.length).fill(false))
@ -86,17 +40,7 @@ const RuleProvider: React.FC = () => {
return ( return (
<SettingCard> <SettingCard>
{showDetails.show && ( <SettingItem title="规则集合" divider>
<Viewer
path={showDetails.path}
type={showDetails.type}
title={showDetails.title}
format={showDetails.format}
privderType={showDetails.privderType}
onClose={() => setShowDetails({ show: false, path: '', type: '', title: '', format: '', privderType: '' })}
/>
)}
<SettingItem title={t('resources.ruleProviders.title')} divider>
<Button <Button
size="sm" size="sm"
color="primary" color="primary"
@ -106,10 +50,11 @@ const RuleProvider: React.FC = () => {
}) })
}} }}
> >
{t('resources.ruleProviders.updateAll')}
</Button> </Button>
</SettingItem> </SettingItem>
{providers.map((provider, index) => ( {providers.map((provider, index) => {
return (
<Fragment key={provider.name}> <Fragment key={provider.name}>
<SettingItem <SettingItem
title={provider.name} title={provider.name}
@ -119,35 +64,11 @@ const RuleProvider: React.FC = () => {
</Chip> </Chip>
} }
> >
<div className="flex h-[32px] leading-[32px] text-foreground-500"> {
<div className="flex h-[32px] leading-[32px] text-default-500">
<div>{dayjs(provider.updatedAt).fromNow()}</div> <div>{dayjs(provider.updatedAt).fromNow()}</div>
{provider.format !== 'MrsRule' && (
<Button <Button
isIconOnly isIconOnly
title={provider.vehicleType == 'File' ? t('common.editor.edit') : t('common.viewer.view')}
className="ml-2"
size="sm"
onPress={() => {
setShowDetails({
show: false,
privderType: 'rule-providers',
path: provider.name,
type: provider.vehicleType,
title: provider.name,
format: provider.format
})
}}
>
{provider.vehicleType == 'File' ? (
<MdEditDocument className={`text-lg`} />
) : (
<CgLoadbarDoc className={`text-lg`} />
)}
</Button>
)}
<Button
isIconOnly
title={t('common.updater.update')}
className="ml-2" className="ml-2"
size="sm" size="sm"
onPress={() => { onPress={() => {
@ -157,17 +78,19 @@ const RuleProvider: React.FC = () => {
<IoMdRefresh className={`text-lg ${updating[index] ? 'animate-spin' : ''}`} /> <IoMdRefresh className={`text-lg ${updating[index] ? 'animate-spin' : ''}`} />
</Button> </Button>
</div> </div>
}
</SettingItem> </SettingItem>
<SettingItem <SettingItem
title={<div className="text-foreground-500">{provider.format}</div>} title={<div className="text-default-500">{provider.format}</div>}
divider={index !== providers.length - 1} divider={index !== providers.length - 1}
> >
<div className="h-[32px] leading-[32px] text-foreground-500"> <div className="h-[32px] leading-[32px] text-default-500">
{provider.vehicleType}::{provider.behavior} {provider.vehicleType}::{provider.behavior}
</div> </div>
</SettingItem> </SettingItem>
</Fragment> </Fragment>
))} )
})}
</SettingCard> </SettingCard>
) )
} }

View File

@ -1,93 +0,0 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react'
import React, { useEffect, useState } from 'react'
import { BaseEditor } from '../base/base-editor'
import { getFileStr, setFileStr } from '@renderer/utils/ipc'
import yaml from 'js-yaml'
import { useTranslation } from 'react-i18next'
type Language = 'yaml' | 'javascript' | 'css' | 'json' | 'text'
interface Props {
onClose: () => void
path: string
type: string
title: string
privderType: string
format?: string
}
const Viewer: React.FC<Props> = (props) => {
const { t } = useTranslation()
const { type, path, title, format, privderType, onClose } = props
const [currData, setCurrData] = useState('')
let language: Language = !format || format === 'YamlRule' ? 'yaml' : 'text'
const getContent = async (): Promise<void> => {
let fileContent: React.SetStateAction<string>
if (type === 'Inline') {
fileContent = await getFileStr('config.yaml')
language = 'yaml'
} else {
fileContent = await getFileStr(path)
}
try {
const parsedYaml = yaml.load(fileContent)
if (privderType === 'proxy-providers') {
setCurrData(yaml.dump({
'proxies': parsedYaml[privderType][title].payload
}))
} else {
setCurrData(yaml.dump({
'rules': parsedYaml[privderType][title].payload
}))
}
} catch (error) {
setCurrData(fileContent)
}
}
useEffect(() => {
getContent()
}, [])
return (
<Modal
backdrop="blur"
classNames={{ backdrop: 'top-[48px]' }}
size="5xl"
hideCloseButton
isOpen={true}
onOpenChange={onClose}
scrollBehavior="inside"
>
<ModalContent className="h-full w-[calc(100%-100px)]">
<ModalHeader className="flex pb-0 app-drag">{title}</ModalHeader>
<ModalBody className="h-full">
<BaseEditor
language={language}
value={currData}
readOnly={type != 'File'}
onChange={(value) => setCurrData(value)}
/>
</ModalBody>
<ModalFooter className="pt-0">
<Button size="sm" variant="light" onPress={onClose}>
{t('common.close')}
</Button>
{type == 'File' && (
<Button
size="sm"
color="primary"
onPress={async () => {
await setFileStr(path, currData)
onClose()
}}
>
{t('common.save')}
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
)
}
export default Viewer

View File

@ -1,4 +1,4 @@
import { Card, CardBody } from '@heroui/react' import { Card, CardBody } from '@nextui-org/react'
import React from 'react' import React from 'react'
const RuleItem: React.FC<IMihomoRulesDetail & { index: number }> = (props) => { const RuleItem: React.FC<IMihomoRulesDetail & { index: number }> = (props) => {
@ -10,7 +10,7 @@ const RuleItem: React.FC<IMihomoRulesDetail & { index: number }> = (props) => {
<div title={payload} className="text-ellipsis whitespace-nowrap overflow-hidden"> <div title={payload} className="text-ellipsis whitespace-nowrap overflow-hidden">
{payload} {payload}
</div> </div>
<div className="flex justify-start text-foreground-500"> <div className="flex justify-start text-default-500">
<div>{type}</div> <div>{type}</div>
<div className="ml-2">{proxy}</div> <div className="ml-2">{proxy}</div>
</div> </div>

View File

@ -1,28 +1,18 @@
import { Button, Tooltip } from '@heroui/react' import { Button, Tooltip } from '@nextui-org/react'
import SettingCard from '../base/base-setting-card' import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item' import SettingItem from '../base/base-setting-item'
import { import { checkUpdate, createHeapSnapshot, quitApp, quitWithoutCore } from '@renderer/utils/ipc'
checkUpdate,
createHeapSnapshot,
quitApp,
quitWithoutCore,
resetAppConfig
} from '@renderer/utils/ipc'
import { useState } from 'react' import { useState } from 'react'
import UpdaterModal from '../updater/updater-modal' import UpdaterModal from '../updater/updater-modal'
import { version } from '@renderer/utils/init' import { version } from '@renderer/utils/init'
import { IoIosHelpCircle } from 'react-icons/io' import { IoIosHelpCircle } from 'react-icons/io'
import { getDriver } from '@renderer/App' import { firstDriver } from '@renderer/App'
import { useTranslation } from 'react-i18next'
import BaseConfirmModal from '../base/base-confirm-modal'
const Actions: React.FC = () => { const Actions: React.FC = () => {
const { t } = useTranslation()
const [newVersion, setNewVersion] = useState('') const [newVersion, setNewVersion] = useState('')
const [changelog, setChangelog] = useState('') const [changelog, setChangelog] = useState('')
const [openUpdate, setOpenUpdate] = useState(false) const [openUpdate, setOpenUpdate] = useState(false)
const [checkingUpdate, setCheckingUpdate] = useState(false) const [checkingUpdate, setCheckingUpdate] = useState(false)
const [showResetConfirm, setShowResetConfirm] = useState(false)
return ( return (
<> <>
@ -33,25 +23,13 @@ const Actions: React.FC = () => {
changelog={changelog} changelog={changelog}
/> />
)} )}
{showResetConfirm && (
<BaseConfirmModal
isOpen={showResetConfirm}
title={t('actions.reset.confirm.title')}
content={t('actions.reset.confirm.content')}
onCancel={() => setShowResetConfirm(false)}
onConfirm={() => {
resetAppConfig()
setShowResetConfirm(false)
}}
/>
)}
<SettingCard> <SettingCard>
<SettingItem title={t('actions.guide.title')} divider> <SettingItem title="打开引导页面" divider>
<Button size="sm" onPress={() => getDriver()?.drive()}> <Button size="sm" onPress={() => firstDriver.drive()}>
{t('actions.guide.button')}
</Button> </Button>
</SettingItem> </SettingItem>
<SettingItem title={t('actions.update.title')} divider> <SettingItem title="检查更新" divider>
<Button <Button
size="sm" size="sm"
isLoading={checkingUpdate} isLoading={checkingUpdate}
@ -64,9 +42,7 @@ const Actions: React.FC = () => {
setChangelog(version.changelog) setChangelog(version.changelog)
setOpenUpdate(true) setOpenUpdate(true)
} else { } else {
new window.Notification(t('actions.update.upToDate.title'), { new window.Notification('当前已是最新版本', { body: '无需更新' })
body: t('actions.update.upToDate.body')
})
} }
} catch (e) { } catch (e) {
alert(e) alert(e)
@ -75,28 +51,13 @@ const Actions: React.FC = () => {
} }
}} }}
> >
{t('actions.update.button')}
</Button> </Button>
</SettingItem> </SettingItem>
<SettingItem <SettingItem
title={t('actions.reset.title')} title="创建堆快照"
actions={ actions={
<Tooltip content={t('actions.reset.tooltip')}> <Tooltip content="创建主进程堆快照,用于排查内存问题">
<Button isIconOnly size="sm" variant="light">
<IoIosHelpCircle className="text-lg" />
</Button>
</Tooltip>
}
divider
>
<Button size="sm" onPress={() => setShowResetConfirm(true)}>
{t('actions.reset.button')}
</Button>
</SettingItem>
<SettingItem
title={t('actions.heapSnapshot.title')}
actions={
<Tooltip content={t('actions.heapSnapshot.tooltip')}>
<Button isIconOnly size="sm" variant="light"> <Button isIconOnly size="sm" variant="light">
<IoIosHelpCircle className="text-lg" /> <IoIosHelpCircle className="text-lg" />
</Button> </Button>
@ -105,13 +66,13 @@ const Actions: React.FC = () => {
divider divider
> >
<Button size="sm" onPress={createHeapSnapshot}> <Button size="sm" onPress={createHeapSnapshot}>
{t('actions.heapSnapshot.button')}
</Button> </Button>
</SettingItem> </SettingItem>
<SettingItem <SettingItem
title={t('actions.lightMode.title')} title="轻量模式"
actions={ actions={
<Tooltip content={t('actions.lightMode.tooltip')}> <Tooltip content="完全退出软件,只保留内核进程">
<Button isIconOnly size="sm" variant="light"> <Button isIconOnly size="sm" variant="light">
<IoIosHelpCircle className="text-lg" /> <IoIosHelpCircle className="text-lg" />
</Button> </Button>
@ -120,15 +81,15 @@ const Actions: React.FC = () => {
divider divider
> >
<Button size="sm" onPress={quitWithoutCore}> <Button size="sm" onPress={quitWithoutCore}>
{t('actions.lightMode.button')}
</Button> </Button>
</SettingItem> </SettingItem>
<SettingItem title={t('actions.quit.title')} divider> <SettingItem title="退出应用" divider>
<Button size="sm" onPress={quitApp}> <Button size="sm" onPress={quitApp}>
{t('actions.quit.button')} 退
</Button> </Button>
</SettingItem> </SettingItem>
<SettingItem title={t('actions.version.title')}> <SettingItem title="应用版本">
<div>v{version}</div> <div>v{version}</div>
</SettingItem> </SettingItem>
</SettingCard> </SettingCard>

View File

@ -1,17 +1,13 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react' import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@nextui-org/react'
import { BaseEditor } from '@renderer/components/base/base-editor' import { BaseEditor } from '@renderer/components/base/base-editor'
import { readTheme } from '@renderer/utils/ipc' import { readTheme } from '@renderer/utils/ipc'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface Props { interface Props {
theme: string theme: string
onCancel: () => void onCancel: () => void
onConfirm: (script: string) => void onConfirm: (script: string) => void
} }
const CSSEditorModal: React.FC<Props> = (props) => { const CSSEditorModal: React.FC<Props> = (props) => {
const { t } = useTranslation()
const { theme, onCancel, onConfirm } = props const { theme, onCancel, onConfirm } = props
const [currData, setCurrData] = useState('') const [currData, setCurrData] = useState('')
@ -34,7 +30,7 @@ const CSSEditorModal: React.FC<Props> = (props) => {
scrollBehavior="inside" scrollBehavior="inside"
> >
<ModalContent className="h-full w-[calc(100%-100px)]"> <ModalContent className="h-full w-[calc(100%-100px)]">
<ModalHeader className="flex pb-0 app-drag">{t('theme.editor.title')}</ModalHeader> <ModalHeader className="flex pb-0"></ModalHeader>
<ModalBody className="h-full"> <ModalBody className="h-full">
<BaseEditor <BaseEditor
language="css" language="css"
@ -44,10 +40,10 @@ const CSSEditorModal: React.FC<Props> = (props) => {
</ModalBody> </ModalBody>
<ModalFooter className="pt-0"> <ModalFooter className="pt-0">
<Button size="sm" variant="light" onPress={onCancel}> <Button size="sm" variant="light" onPress={onCancel}>
{t('common.cancel')}
</Button> </Button>
<Button size="sm" color="primary" onPress={() => onConfirm(currData)}> <Button size="sm" color="primary" onPress={() => onConfirm(currData)}>
{t('common.confirm')}
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View File

@ -1,14 +1,12 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import SettingCard from '../base/base-setting-card' import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item' import SettingItem from '../base/base-setting-item'
import { Button, Input, Select, SelectItem, Switch, Tab, Tabs, Tooltip } from '@heroui/react' import { Button, Input, Select, SelectItem, Switch, Tab, Tabs, Tooltip } from '@nextui-org/react'
import { BiCopy, BiSolidFileImport } from 'react-icons/bi' import { BiCopy, BiSolidFileImport } from 'react-icons/bi'
import useSWR from 'swr' import useSWR from 'swr'
import { import {
applyTheme, applyTheme,
checkAutoRun, checkAutoRun,
closeFloatingWindow,
closeTrayIcon,
copyEnv, copyEnv,
disableAutoRun, disableAutoRun,
enableAutoRun, enableAutoRun,
@ -17,45 +15,35 @@ import {
importThemes, importThemes,
relaunchApp, relaunchApp,
resolveThemes, resolveThemes,
showFloatingWindow, restartCore,
showTrayIcon,
startMonitor,
writeTheme writeTheme
} from '@renderer/utils/ipc' } from '@renderer/utils/ipc'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import debounce from '@renderer/utils/debounce'
import { platform } from '@renderer/utils/init' import { platform } from '@renderer/utils/init'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { IoIosHelpCircle, IoMdCloudDownload } from 'react-icons/io' import { IoIosHelpCircle, IoMdCloudDownload } from 'react-icons/io'
import { MdEditDocument } from 'react-icons/md' import { MdEditDocument } from 'react-icons/md'
import CSSEditorModal from './css-editor-modal' import CSSEditorModal from './css-editor-modal'
import { useTranslation } from 'react-i18next'
const GeneralConfig: React.FC = () => { const GeneralConfig: React.FC = () => {
const { t, i18n } = useTranslation() const { data: enable, mutate: mutateEnable } = useSWR('checkAutoRun', checkAutoRun)
const { data: enable = false, mutate: mutateEnable } = useSWR('checkAutoRun', checkAutoRun)
const { appConfig, patchAppConfig } = useAppConfig() const { appConfig, patchAppConfig } = useAppConfig()
const [customThemes, setCustomThemes] = useState<{ key: string; label: string }[]>() const [customThemes, setCustomThemes] = useState<{ key: string; label: string }[]>()
const [openCSSEditor, setOpenCSSEditor] = useState(false) const [openCSSEditor, setOpenCSSEditor] = useState(false)
const [fetching, setFetching] = useState(false) const [fetching, setFetching] = useState(false)
const [isRelaunching, setIsRelaunching] = useState(false)
const { setTheme } = useTheme() const { setTheme } = useTheme()
const { const {
silentStart = false, silentStart = false,
useDockIcon = true, useDockIcon = true,
showTraffic = false, showTraffic = true,
proxyInTray = true, proxyInTray = true,
disableTray = false,
showFloatingWindow: showFloating = false,
spinFloatingIcon = true,
useWindowFrame = false, useWindowFrame = false,
autoQuitWithoutCore = false, autoQuitWithoutCore = false,
autoQuitWithoutCoreDelay = 60, autoQuitWithoutCoreDelay = 60,
customTheme = 'default.css', customTheme = 'default.css',
envType = [platform === 'win32' ? 'powershell' : 'bash'], envType = [platform === 'win32' ? 'powershell' : 'bash'],
autoCheckUpdate, autoCheckUpdate,
appTheme = 'system', appTheme = 'system'
language = 'zh-CN'
} = appConfig || {} } = appConfig || {}
useEffect(() => { useEffect(() => {
@ -78,26 +66,7 @@ const GeneralConfig: React.FC = () => {
/> />
)} )}
<SettingCard> <SettingCard>
<SettingItem title={t('settings.language')} divider> <SettingItem title="开机自启" divider>
<Select
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
className="w-[150px]"
size="sm"
selectedKeys={[language]}
aria-label={t('settings.language')}
onSelectionChange={async (v) => {
const newLang = Array.from(v)[0] as 'zh-CN' | 'en-US' | 'ru-RU' | 'fa-IR'
await patchAppConfig({ language: newLang })
i18n.changeLanguage(newLang)
}}
>
<SelectItem key="zh-CN"></SelectItem>
<SelectItem key="en-US">English</SelectItem>
<SelectItem key="ru-RU">Русский</SelectItem>
<SelectItem key="fa-IR">فارسی</SelectItem>
</Select>
</SettingItem>
<SettingItem title={t('settings.autoStart')} divider>
<Switch <Switch
size="sm" size="sm"
isSelected={enable} isSelected={enable}
@ -116,7 +85,7 @@ const GeneralConfig: React.FC = () => {
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('settings.autoCheckUpdate')} divider> <SettingItem title="自动检查更新" divider>
<Switch <Switch
size="sm" size="sm"
isSelected={autoCheckUpdate} isSelected={autoCheckUpdate}
@ -125,7 +94,7 @@ const GeneralConfig: React.FC = () => {
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('settings.silentStart')} divider> <SettingItem title="静默启动" divider>
<Switch <Switch
size="sm" size="sm"
isSelected={silentStart} isSelected={silentStart}
@ -135,9 +104,9 @@ const GeneralConfig: React.FC = () => {
/> />
</SettingItem> </SettingItem>
<SettingItem <SettingItem
title={t('settings.autoQuitWithoutCore')} title="自动开启轻量模式"
actions={ actions={
<Tooltip content={t('settings.autoQuitWithoutCoreTooltip')}> <Tooltip content="关闭窗口指定时间后自动进入轻量模式">
<Button isIconOnly size="sm" variant="light"> <Button isIconOnly size="sm" variant="light">
<IoIosHelpCircle className="text-lg" /> <IoIosHelpCircle className="text-lg" />
</Button> </Button>
@ -154,12 +123,12 @@ const GeneralConfig: React.FC = () => {
/> />
</SettingItem> </SettingItem>
{autoQuitWithoutCore && ( {autoQuitWithoutCore && (
<SettingItem title={t('settings.autoQuitWithoutCoreDelay')} divider> <SettingItem title="自动开启轻量模式延时" divider>
<Input <Input
size="sm" size="sm"
className="w-[100px]" className="w-[100px]"
type="number" type="number"
endContent={t('common.seconds')} endContent="秒"
value={autoQuitWithoutCoreDelay.toString()} value={autoQuitWithoutCoreDelay.toString()}
onValueChange={async (v: string) => { onValueChange={async (v: string) => {
let num = parseInt(v) let num = parseInt(v)
@ -171,7 +140,7 @@ const GeneralConfig: React.FC = () => {
</SettingItem> </SettingItem>
)} )}
<SettingItem <SettingItem
title={t('settings.envType')} title="复制环境变量类型"
actions={envType.map((type) => ( actions={envType.map((type) => (
<Button <Button
key={type} key={type}
@ -187,13 +156,10 @@ const GeneralConfig: React.FC = () => {
divider divider
> >
<Select <Select
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
className="w-[150px]" className="w-[150px]"
size="sm" size="sm"
selectionMode="multiple" selectionMode="multiple"
selectedKeys={new Set(envType)} selectedKeys={new Set(envType)}
aria-label={t('settings.envType')}
disallowEmptySelection={true}
onSelectionChange={async (v) => { onSelectionChange={async (v) => {
try { try {
await patchAppConfig({ await patchAppConfig({
@ -209,52 +175,8 @@ const GeneralConfig: React.FC = () => {
<SelectItem key="powershell">PowerShell</SelectItem> <SelectItem key="powershell">PowerShell</SelectItem>
</Select> </Select>
</SettingItem> </SettingItem>
<SettingItem title={t('settings.showFloatingWindow')} divider>
<Switch
size="sm"
isSelected={showFloating}
onValueChange={async (v) => {
await patchAppConfig({ showFloatingWindow: v })
if (v) {
showFloatingWindow()
} else {
closeFloatingWindow()
}
}}
/>
</SettingItem>
{showFloating && (
<>
<SettingItem title={t('settings.spinFloatingIcon')} divider>
<Switch
size="sm"
isSelected={spinFloatingIcon}
onValueChange={async (v) => {
await patchAppConfig({ spinFloatingIcon: v })
window.electron.ipcRenderer.send('updateFloatingWindow')
}}
/>
</SettingItem>
<SettingItem title={t('settings.disableTray')} divider>
<Switch
size="sm"
isSelected={disableTray}
onValueChange={async (v) => {
await patchAppConfig({ disableTray: v })
if (v) {
closeTrayIcon()
} else {
showTrayIcon()
}
}}
/>
</SettingItem>
</>
)}
{platform !== 'linux' && ( {platform !== 'linux' && (
<> <SettingItem title="托盘菜单显示节点信息" divider>
<SettingItem title={t('settings.proxyInTray')} divider>
<Switch <Switch
size="sm" size="sm"
isSelected={proxyInTray} isSelected={proxyInTray}
@ -263,26 +185,10 @@ const GeneralConfig: React.FC = () => {
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem
title={t('settings.showTraffic', {
context: platform === 'win32' ? 'windows' : 'mac'
})}
divider
>
<Switch
size="sm"
isSelected={showTraffic}
onValueChange={async (v) => {
await patchAppConfig({ showTraffic: v })
await startMonitor()
}}
/>
</SettingItem>
</>
)} )}
{platform === 'darwin' && ( {platform === 'darwin' && (
<> <>
<SettingItem title={t('settings.showDockIcon')} divider> <SettingItem title="显示 Dock 图标" divider>
<Switch <Switch
size="sm" size="sm"
isSelected={useDockIcon} isSelected={useDockIcon}
@ -291,28 +197,30 @@ const GeneralConfig: React.FC = () => {
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title="显示网速信息" divider>
<Switch
size="sm"
isSelected={showTraffic}
onValueChange={async (v) => {
await patchAppConfig({ showTraffic: v })
await restartCore()
}}
/>
</SettingItem>
</> </>
)} )}
<SettingItem title={t('settings.useWindowFrame')} divider> <SettingItem title="使用系统标题栏" divider>
<Switch <Switch
size="sm" size="sm"
isSelected={useWindowFrame} isSelected={useWindowFrame}
isDisabled={isRelaunching} onValueChange={async (v) => {
onValueChange={debounce(async (v) => {
if (isRelaunching) return
setIsRelaunching(true)
try {
await patchAppConfig({ useWindowFrame: v }) await patchAppConfig({ useWindowFrame: v })
await relaunchApp() await relaunchApp()
} catch (e) { }}
alert(e)
setIsRelaunching(false)
}
}, 1000)}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('settings.backgroundColor')} divider> <SettingItem title="背景色" divider>
<Tabs <Tabs
size="sm" size="sm"
color="primary" color="primary"
@ -322,20 +230,20 @@ const GeneralConfig: React.FC = () => {
patchAppConfig({ appTheme: key as AppTheme }) patchAppConfig({ appTheme: key as AppTheme })
}} }}
> >
<Tab key="system" title={t('settings.backgroundAuto')} /> <Tab key="system" title="自动" />
<Tab key="dark" title={t('settings.backgroundDark')} /> <Tab key="dark" title="深色" />
<Tab key="light" title={t('settings.backgroundLight')} /> <Tab key="light" title="浅色" />
</Tabs> </Tabs>
</SettingItem> </SettingItem>
<SettingItem <SettingItem
title={t('settings.theme')} title="主题"
actions={ actions={
<> <>
<Button <Button
size="sm" size="sm"
isLoading={fetching} isLoading={fetching}
isIconOnly isIconOnly
title={t('settings.fetchTheme')} title="拉取主题"
variant="light" variant="light"
onPress={async () => { onPress={async () => {
setFetching(true) setFetching(true)
@ -354,7 +262,7 @@ const GeneralConfig: React.FC = () => {
<Button <Button
size="sm" size="sm"
isIconOnly isIconOnly
title={t('settings.importTheme')} title="导入主题"
variant="light" variant="light"
onPress={async () => { onPress={async () => {
const files = await getFilePath(['css']) const files = await getFilePath(['css'])
@ -372,7 +280,7 @@ const GeneralConfig: React.FC = () => {
<Button <Button
size="sm" size="sm"
isIconOnly isIconOnly
title={t('settings.editTheme')} title="编辑主题"
variant="light" variant="light"
onPress={async () => { onPress={async () => {
setOpenCSSEditor(true) setOpenCSSEditor(true)
@ -385,12 +293,9 @@ const GeneralConfig: React.FC = () => {
> >
{customThemes && ( {customThemes && (
<Select <Select
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
className="w-[60%]" className="w-[60%]"
size="sm" size="sm"
selectedKeys={new Set([customTheme])} selectedKeys={new Set([customTheme])}
aria-label={t('settings.selectTheme')}
disallowEmptySelection={true}
onSelectionChange={async (v) => { onSelectionChange={async (v) => {
try { try {
await patchAppConfig({ customTheme: v.currentKey as string }) await patchAppConfig({ customTheme: v.currentKey as string })

View File

@ -1,21 +1,16 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import SettingCard from '../base/base-setting-card' import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item' import SettingItem from '../base/base-setting-item'
import { Button, Input, Select, SelectItem, Switch, Tooltip } from '@heroui/react' import { Button, Input, Select, SelectItem, Switch } from '@nextui-org/react'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import debounce from '@renderer/utils/debounce' import debounce from '@renderer/utils/debounce'
import { getGistUrl, patchControledMihomoConfig, restartCore } from '@renderer/utils/ipc' import { getGistUrl, patchControledMihomoConfig, restartCore } from '@renderer/utils/ipc'
import { MdDeleteForever } from 'react-icons/md' import { MdDeleteForever } from 'react-icons/md'
import { BiCopy } from 'react-icons/bi' import { BiCopy } from 'react-icons/bi'
import { IoIosHelpCircle } from 'react-icons/io'
import { platform, version } from '@renderer/utils/init'
import { useTranslation } from 'react-i18next'
const MihomoConfig: React.FC = () => { const MihomoConfig: React.FC = () => {
const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig() const { appConfig, patchAppConfig } = useAppConfig()
const { const {
diffWorkDir = false,
controlDns = true, controlDns = true,
controlSniff = true, controlSniff = true,
delayTestConcurrency, delayTestConcurrency,
@ -25,7 +20,6 @@ const MihomoConfig: React.FC = () => {
pauseSSID = [], pauseSSID = [],
delayTestUrl, delayTestUrl,
userAgent, userAgent,
mihomoCpuPriority = 'PRIORITY_NORMAL',
proxyCols = 'auto' proxyCols = 'auto'
} = appConfig || {} } = appConfig || {}
const [url, setUrl] = useState(delayTestUrl) const [url, setUrl] = useState(delayTestUrl)
@ -39,59 +33,59 @@ const MihomoConfig: React.FC = () => {
}, 500) }, 500)
return ( return (
<SettingCard> <SettingCard>
<SettingItem title={t('mihomo.userAgent')} divider> <SettingItem title="订阅拉取 UA" divider>
<Input <Input
size="sm" size="sm"
className="w-[60%]" className="w-[60%]"
value={ua} value={ua}
placeholder={t('mihomo.userAgentPlaceholder', { version })} placeholder="默认 clash.meta"
onValueChange={(v) => { onValueChange={(v) => {
setUa(v) setUa(v)
setUaDebounce(v) setUaDebounce(v)
}} }}
></Input> ></Input>
</SettingItem> </SettingItem>
<SettingItem title={t('mihomo.delayTest.url')} divider> <SettingItem title="延迟测试地址" divider>
<Input <Input
size="sm" size="sm"
className="w-[60%]" className="w-[60%]"
value={url} value={url}
placeholder={t('mihomo.delayTest.urlPlaceholder')} placeholder="默认 https://www.gstatic.com/generate_204"
onValueChange={(v) => { onValueChange={(v) => {
setUrl(v) setUrl(v)
setUrlDebounce(v) setUrlDebounce(v)
}} }}
></Input> ></Input>
</SettingItem> </SettingItem>
<SettingItem title={t('mihomo.delayTest.concurrency')} divider> <SettingItem title="延迟测试并发数量" divider>
<Input <Input
type="number" type="number"
size="sm" size="sm"
className="w-[60%]" className="w-[60%]"
value={delayTestConcurrency?.toString()} value={delayTestConcurrency?.toString()}
placeholder={t('mihomo.delayTest.concurrencyPlaceholder')} placeholder="默认 50"
onValueChange={(v) => { onValueChange={(v) => {
patchAppConfig({ delayTestConcurrency: parseInt(v) }) patchAppConfig({ delayTestConcurrency: parseInt(v) })
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('mihomo.delayTest.timeout')} divider> <SettingItem title="延迟测试超时时间" divider>
<Input <Input
type="number" type="number"
size="sm" size="sm"
className="w-[60%]" className="w-[60%]"
value={delayTestTimeout?.toString()} value={delayTestTimeout?.toString()}
placeholder={t('mihomo.delayTest.timeoutPlaceholder')} placeholder="默认 5000"
onValueChange={(v) => { onValueChange={(v) => {
patchAppConfig({ delayTestTimeout: parseInt(v) }) patchAppConfig({ delayTestTimeout: parseInt(v) })
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem <SettingItem
title={t('mihomo.gist.title')} title="同步运行时配置到 Gist"
actions={ actions={
<Button <Button
title={t('mihomo.gist.copyUrl')} title="复制 Gist URL"
isIconOnly isIconOnly
size="sm" size="sm"
variant="light" variant="light"
@ -116,84 +110,29 @@ const MihomoConfig: React.FC = () => {
size="sm" size="sm"
className="w-[60%]" className="w-[60%]"
value={githubToken} value={githubToken}
placeholder={t('mihomo.gist.token')} placeholder="GitHub Token"
onValueChange={(v) => { onValueChange={(v) => {
patchAppConfig({ githubToken: v }) patchAppConfig({ githubToken: v })
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('mihomo.proxyColumns.title')} divider> <SettingItem title="代理节点展示列数" divider>
<Select <Select
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
className="w-[150px]" className="w-[150px]"
size="sm" size="sm"
selectedKeys={new Set([proxyCols])} selectedKeys={new Set([proxyCols])}
aria-label={t('mihomo.proxyColumns.title')}
disallowEmptySelection={true}
onSelectionChange={async (v) => { onSelectionChange={async (v) => {
await patchAppConfig({ proxyCols: v.currentKey as 'auto' | '1' | '2' | '3' | '4' }) await patchAppConfig({ proxyCols: v.currentKey as 'auto' | '1' | '2' | '3' | '4' })
}} }}
> >
<SelectItem key="auto">{t('mihomo.proxyColumns.auto')}</SelectItem> <SelectItem key="auto"></SelectItem>
<SelectItem key="1">{t('mihomo.proxyColumns.one')}</SelectItem> <SelectItem key="1"></SelectItem>
<SelectItem key="2">{t('mihomo.proxyColumns.two')}</SelectItem> <SelectItem key="2"></SelectItem>
<SelectItem key="3">{t('mihomo.proxyColumns.three')}</SelectItem> <SelectItem key="3"></SelectItem>
<SelectItem key="4">{t('mihomo.proxyColumns.four')}</SelectItem> <SelectItem key="4"></SelectItem>
</Select> </Select>
</SettingItem> </SettingItem>
{platform === 'win32' && ( <SettingItem title="接管 DNS 设置" divider>
<SettingItem title={t('mihomo.cpuPriority.title')} divider>
<Select
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
className="w-[150px]"
size="sm"
selectedKeys={new Set([mihomoCpuPriority])}
disallowEmptySelection={true}
onSelectionChange={async (v) => {
try {
await patchAppConfig({
mihomoCpuPriority: v.currentKey as Priority
})
await restartCore()
} catch (e) {
alert(e)
}
}}
>
<SelectItem key="PRIORITY_HIGHEST">{t('mihomo.cpuPriority.realtime')}</SelectItem>
<SelectItem key="PRIORITY_HIGH">{t('mihomo.cpuPriority.high')}</SelectItem>
<SelectItem key="PRIORITY_ABOVE_NORMAL">{t('mihomo.cpuPriority.aboveNormal')}</SelectItem>
<SelectItem key="PRIORITY_NORMAL">{t('mihomo.cpuPriority.normal')}</SelectItem>
<SelectItem key="PRIORITY_BELOW_NORMAL">{t('mihomo.cpuPriority.belowNormal')}</SelectItem>
<SelectItem key="PRIORITY_LOW">{t('mihomo.cpuPriority.low')}</SelectItem>
</Select>
</SettingItem>
)}
<SettingItem
title={t('mihomo.workDir.title')}
actions={
<Tooltip content={t('mihomo.workDir.tooltip')}>
<Button isIconOnly size="sm" variant="light">
<IoIosHelpCircle className="text-lg" />
</Button>
</Tooltip>
}
divider
>
<Switch
size="sm"
isSelected={diffWorkDir}
onValueChange={async (v) => {
try {
await patchAppConfig({ diffWorkDir: v })
await restartCore()
} catch (e) {
alert(e)
}
}}
/>
</SettingItem>
<SettingItem title={t('mihomo.controlDns')} divider>
<Switch <Switch
size="sm" size="sm"
isSelected={controlDns} isSelected={controlDns}
@ -208,7 +147,7 @@ const MihomoConfig: React.FC = () => {
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('mihomo.controlSniff')} divider> <SettingItem title="接管域名嗅探设置" divider>
<Switch <Switch
size="sm" size="sm"
isSelected={controlSniff} isSelected={controlSniff}
@ -223,7 +162,7 @@ const MihomoConfig: React.FC = () => {
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('mihomo.autoCloseConnection')} divider> <SettingItem title="自动断开连接" divider>
<Switch <Switch
size="sm" size="sm"
isSelected={autoCloseConnection} isSelected={autoCloseConnection}
@ -232,7 +171,7 @@ const MihomoConfig: React.FC = () => {
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('mihomo.pauseSSID.title')}> <SettingItem title="在特定的 WiFi SSID 下直连">
{pauseSSIDInput.join('') !== pauseSSID.join('') && ( {pauseSSIDInput.join('') !== pauseSSID.join('') && (
<Button <Button
size="sm" size="sm"
@ -241,7 +180,7 @@ const MihomoConfig: React.FC = () => {
patchAppConfig({ pauseSSID: pauseSSIDInput }) patchAppConfig({ pauseSSID: pauseSSIDInput })
}} }}
> >
{t('common.confirm')}
</Button> </Button>
)} )}
</SettingItem> </SettingItem>
@ -268,7 +207,7 @@ const MihomoConfig: React.FC = () => {
size="sm" size="sm"
variant="flat" variant="flat"
color="warning" color="warning"
onPress={() => setPauseSSIDInput(pauseSSIDInput.filter((_, i) => i !== index))} onClick={() => setPauseSSIDInput(pauseSSIDInput.filter((_, i) => i !== index))}
> >
<MdDeleteForever className="text-lg" /> <MdDeleteForever className="text-lg" />
</Button> </Button>

View File

@ -1,11 +1,10 @@
import { Button, Input } from '@heroui/react' import { Button, Input } from '@nextui-org/react'
import SettingCard from '../base/base-setting-card' import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item' import SettingItem from '../base/base-setting-item'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import React, { KeyboardEvent, useState } from 'react' import React, { KeyboardEvent, useState } from 'react'
import { platform } from '@renderer/utils/init' import { platform } from '@renderer/utils/init'
import { registerShortcut } from '@renderer/utils/ipc' import { registerShortcut } from '@renderer/utils/ipc'
import { useTranslation } from 'react-i18next'
const keyMap = { const keyMap = {
Backquote: '`', Backquote: '`',
@ -41,11 +40,9 @@ const keyMap = {
} }
const ShortcutConfig: React.FC = () => { const ShortcutConfig: React.FC = () => {
const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig() const { appConfig, patchAppConfig } = useAppConfig()
const { const {
showWindowShortcut = '', showWindowShortcut = '',
showFloatingWindowShortcut = '',
triggerSysProxyShortcut = '', triggerSysProxyShortcut = '',
triggerTunShortcut = '', triggerTunShortcut = '',
ruleModeShortcut = '', ruleModeShortcut = '',
@ -56,8 +53,8 @@ const ShortcutConfig: React.FC = () => {
} = appConfig || {} } = appConfig || {}
return ( return (
<SettingCard title={t('shortcuts.title')}> <SettingCard title="快捷键设置">
<SettingItem title={t('shortcuts.toggleWindow')} divider> <SettingItem title="打开/关闭窗口" divider>
<div className="flex justify-end w-[60%]"> <div className="flex justify-end w-[60%]">
<ShortcutInput <ShortcutInput
value={showWindowShortcut} value={showWindowShortcut}
@ -66,16 +63,7 @@ const ShortcutConfig: React.FC = () => {
/> />
</div> </div>
</SettingItem> </SettingItem>
<SettingItem title={t('shortcuts.toggleFloatingWindow')} divider> <SettingItem title="打开/关闭系统代理" divider>
<div className="flex justify-end w-[60%]">
<ShortcutInput
value={showFloatingWindowShortcut}
patchAppConfig={patchAppConfig}
action="showFloatingWindowShortcut"
/>
</div>
</SettingItem>
<SettingItem title={t('shortcuts.toggleSystemProxy')} divider>
<div className="flex justify-end w-[60%]"> <div className="flex justify-end w-[60%]">
<ShortcutInput <ShortcutInput
value={triggerSysProxyShortcut} value={triggerSysProxyShortcut}
@ -84,7 +72,7 @@ const ShortcutConfig: React.FC = () => {
/> />
</div> </div>
</SettingItem> </SettingItem>
<SettingItem title={t('shortcuts.toggleTun')} divider> <SettingItem title="打开/关闭虚拟网卡" divider>
<div className="flex justify-end w-[60%]"> <div className="flex justify-end w-[60%]">
<ShortcutInput <ShortcutInput
value={triggerTunShortcut} value={triggerTunShortcut}
@ -93,7 +81,7 @@ const ShortcutConfig: React.FC = () => {
/> />
</div> </div>
</SettingItem> </SettingItem>
<SettingItem title={t('shortcuts.toggleRuleMode')} divider> <SettingItem title="切换规则模式" divider>
<div className="flex justify-end w-[60%]"> <div className="flex justify-end w-[60%]">
<ShortcutInput <ShortcutInput
value={ruleModeShortcut} value={ruleModeShortcut}
@ -102,7 +90,7 @@ const ShortcutConfig: React.FC = () => {
/> />
</div> </div>
</SettingItem> </SettingItem>
<SettingItem title={t('shortcuts.toggleGlobalMode')} divider> <SettingItem title="切换全局模式" divider>
<div className="flex justify-end w-[60%]"> <div className="flex justify-end w-[60%]">
<ShortcutInput <ShortcutInput
value={globalModeShortcut} value={globalModeShortcut}
@ -111,7 +99,7 @@ const ShortcutConfig: React.FC = () => {
/> />
</div> </div>
</SettingItem> </SettingItem>
<SettingItem title={t('shortcuts.toggleDirectMode')} divider> <SettingItem title="切换直连模式" divider>
<div className="flex justify-end w-[60%]"> <div className="flex justify-end w-[60%]">
<ShortcutInput <ShortcutInput
value={directModeShortcut} value={directModeShortcut}
@ -120,7 +108,7 @@ const ShortcutConfig: React.FC = () => {
/> />
</div> </div>
</SettingItem> </SettingItem>
<SettingItem title={t('shortcuts.toggleLightMode')} divider> <SettingItem title="轻量模式" divider>
<div className="flex justify-end w-[60%]"> <div className="flex justify-end w-[60%]">
<ShortcutInput <ShortcutInput
value={quitWithoutCoreShortcut} value={quitWithoutCoreShortcut}
@ -129,7 +117,7 @@ const ShortcutConfig: React.FC = () => {
/> />
</div> </div>
</SettingItem> </SettingItem>
<SettingItem title={t('shortcuts.restartApp')}> <SettingItem title="重启应用">
<div className="flex justify-end w-[60%]"> <div className="flex justify-end w-[60%]">
<ShortcutInput <ShortcutInput
value={restartAppShortcut} value={restartAppShortcut}
@ -147,7 +135,6 @@ const ShortcutInput: React.FC<{
action: string action: string
patchAppConfig: (value: Partial<IAppConfig>) => Promise<void> patchAppConfig: (value: Partial<IAppConfig>) => Promise<void>
}> = (props) => { }> = (props) => {
const { t } = useTranslation()
const { value, action, patchAppConfig } = props const { value, action, patchAppConfig } = props
const [inputValue, setInputValue] = useState(value) const [inputValue, setInputValue] = useState(value)
@ -213,18 +200,18 @@ const ShortcutInput: React.FC<{
await patchAppConfig({ [action]: inputValue }) await patchAppConfig({ [action]: inputValue })
window.electron.ipcRenderer.send('updateTrayMenu') window.electron.ipcRenderer.send('updateTrayMenu')
} else { } else {
alert(t('common.error.shortcutRegistrationFailed')) alert('快捷键注册失败')
} }
} catch (e) { } catch (e) {
alert(t('common.error.shortcutRegistrationFailedWithError', { error: e })) alert(`快捷键注册失败: ${e}`)
} }
}} }}
> >
{t('common.confirm')}
</Button> </Button>
)} )}
<Input <Input
placeholder={t('shortcuts.input.placeholder')} placeholder="点击输入快捷键"
onKeyDown={(e: KeyboardEvent): void => { onKeyDown={(e: KeyboardEvent): void => {
parseShortcut(e, setInputValue) parseShortcut(e, setInputValue)
}} }}

View File

@ -1,73 +1,76 @@
import SettingCard from '@renderer/components/base/base-setting-card' import React from 'react'
import SettingItem from '@renderer/components/base/base-setting-item' import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item'
import { RadioGroup, Radio } from '@nextui-org/react'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import { Radio, RadioGroup } from '@heroui/react' const titleMap = {
import { useTranslation } from 'react-i18next' sysproxyCardStatus: '系统代理',
import type { FC } from 'react' tunCardStatus: '虚拟网卡',
profileCardStatus: '订阅管理',
const titleMap: Record<string, string> = { proxyCardStatus: '代理组',
sysproxyCardStatus: 'sider.cards.systemProxy', ruleCardStatus: '规则',
tunCardStatus: 'sider.cards.tun', resourceCardStatus: '外部资源',
profileCardStatus: 'sider.cards.profiles', overrideCardStatus: '覆写',
proxyCardStatus: 'sider.cards.proxies', connectionCardStatus: '连接',
ruleCardStatus: 'sider.cards.rules', mihomoCoreCardStatus: '内核',
resourceCardStatus: 'sider.cards.resources', dnsCardStatus: 'DNS',
overrideCardStatus: 'sider.cards.override', sniffCardStatus: '域名嗅探',
connectionCardStatus: 'sider.cards.connections', logCardStatus: '日志',
mihomoCoreCardStatus: 'sider.cards.core', substoreCardStatus: 'Sub-Store'
dnsCardStatus: 'sider.cards.dns',
sniffCardStatus: 'sider.cards.sniff',
logCardStatus: 'sider.cards.logs',
substoreCardStatus: 'sider.cards.substore'
} }
const SiderConfig: React.FC = () => {
const sizeMap: Record<string, string> = {
'col-span-2': 'sider.size.large',
'col-span-1': 'sider.size.small',
hidden: 'sider.size.hidden'
}
const SiderConfig: FC = () => {
const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig() const { appConfig, patchAppConfig } = useAppConfig()
const {
sysproxyCardStatus = 'col-span-1',
tunCardStatus = 'col-span-1',
profileCardStatus = 'col-span-2',
proxyCardStatus = 'col-span-1',
ruleCardStatus = 'col-span-1',
resourceCardStatus = 'col-span-1',
overrideCardStatus = 'col-span-1',
connectionCardStatus = 'col-span-2',
mihomoCoreCardStatus = 'col-span-2',
dnsCardStatus = 'col-span-1',
sniffCardStatus = 'col-span-1',
logCardStatus = 'col-span-1',
substoreCardStatus = 'col-span-1'
} = appConfig || {}
const cardStatus = { const cardStatus = {
sysproxyCardStatus: appConfig?.sysproxyCardStatus || 'col-span-1', sysproxyCardStatus,
tunCardStatus: appConfig?.tunCardStatus || 'col-span-1', tunCardStatus,
profileCardStatus: appConfig?.profileCardStatus || 'col-span-2', profileCardStatus,
proxyCardStatus: appConfig?.proxyCardStatus || 'col-span-1', proxyCardStatus,
ruleCardStatus: appConfig?.ruleCardStatus || 'col-span-1', ruleCardStatus,
resourceCardStatus: appConfig?.resourceCardStatus || 'col-span-1', resourceCardStatus,
overrideCardStatus: appConfig?.overrideCardStatus || 'col-span-1', overrideCardStatus,
connectionCardStatus: appConfig?.connectionCardStatus || 'col-span-2', connectionCardStatus,
mihomoCoreCardStatus: appConfig?.mihomoCoreCardStatus || 'col-span-2', mihomoCoreCardStatus,
dnsCardStatus: appConfig?.dnsCardStatus || 'col-span-1', dnsCardStatus,
sniffCardStatus: appConfig?.sniffCardStatus || 'col-span-1', sniffCardStatus,
logCardStatus: appConfig?.logCardStatus || 'col-span-1', logCardStatus,
substoreCardStatus: appConfig?.substoreCardStatus || 'col-span-1' substoreCardStatus
} }
return ( return (
<SettingCard title={t('sider.title')}> <SettingCard title="侧边栏设置">
{Object.entries(cardStatus).map(([key, value]) => ( {Object.keys(cardStatus).map((key, index, array) => {
<SettingItem key={key} title={t(titleMap[key])}> return (
<SettingItem title={titleMap[key]} key={key} divider={index !== array.length - 1}>
<RadioGroup <RadioGroup
orientation="horizontal" orientation="horizontal"
value={value} value={cardStatus[key]}
onValueChange={(v: string) => { onValueChange={(v) => {
if (v === 'col-span-1' || v === 'col-span-2' || v === 'hidden') { patchAppConfig({ [key]: v as CardStatus })
patchAppConfig({ [key]: v })
}
}} }}
> >
{Object.entries(sizeMap).map(([size, label]) => ( <Radio value="col-span-2"></Radio>
<Radio key={size} value={size}> <Radio value="col-span-1"></Radio>
{t(label)} <Radio value="hidden"></Radio>
</Radio>
))}
</RadioGroup> </RadioGroup>
</SettingItem> </SettingItem>
))} )
})}
</SettingCard> </SettingCard>
) )
} }

View File

@ -1,26 +1,17 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import SettingCard from '@renderer/components/base/base-setting-card' import SettingCard from '@renderer/components/base/base-setting-card'
import SettingItem from '@renderer/components/base/base-setting-item' import SettingItem from '@renderer/components/base/base-setting-item'
import { Button, Input, Switch } from '@heroui/react' import { Button, Input, Switch } from '@nextui-org/react'
import { import { startSubStoreServer } from '@renderer/utils/ipc'
startSubStoreFrontendServer,
startSubStoreBackendServer,
stopSubStoreFrontendServer,
stopSubStoreBackendServer
} from '@renderer/utils/ipc'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import debounce from '@renderer/utils/debounce' import debounce from '@renderer/utils/debounce'
import { isValidCron } from 'cron-validator' import { isValidCron } from 'cron-validator'
import { useTranslation } from 'react-i18next'
const SubStoreConfig: React.FC = () => { const SubStoreConfig: React.FC = () => {
const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig() const { appConfig, patchAppConfig } = useAppConfig()
const { const {
useSubStore = true, useSubStore = true,
useCustomSubStore = false, useCustomSubStore = false,
useProxyInSubStore = false,
subStoreHost = '127.0.0.1',
customSubStoreUrl, customSubStoreUrl,
subStoreBackendSyncCron, subStoreBackendSyncCron,
subStoreBackendDownloadCron, subStoreBackendDownloadCron,
@ -39,21 +30,15 @@ const SubStoreConfig: React.FC = () => {
const [subStoreBackendUploadCronValue, setSubStoreBackendUploadCronValue] = const [subStoreBackendUploadCronValue, setSubStoreBackendUploadCronValue] =
useState(subStoreBackendUploadCron) useState(subStoreBackendUploadCron)
return ( return (
<SettingCard title={t('substore.title')}> <SettingCard title="Sub-Store 设置">
<SettingItem title={t('substore.enable')} divider={useSubStore}> <SettingItem title="启用 Sub-Store" divider>
<Switch <Switch
size="sm" size="sm"
isSelected={useSubStore} isSelected={useSubStore}
onValueChange={async (v) => { onValueChange={async (v) => {
try { try {
await patchAppConfig({ useSubStore: v }) await patchAppConfig({ useSubStore: v })
if (v) { if (v) await startSubStoreServer()
await startSubStoreFrontendServer()
await startSubStoreBackendServer()
} else {
await stopSubStoreFrontendServer()
await stopSubStoreBackendServer()
}
} catch (e) { } catch (e) {
alert(e) alert(e)
} }
@ -61,51 +46,28 @@ const SubStoreConfig: React.FC = () => {
/> />
</SettingItem> </SettingItem>
{useSubStore && ( {useSubStore && (
<> <SettingItem title="使用自建 Sub-Store 后端" divider>
<SettingItem title={t('substore.allowLan')} divider>
<Switch
size="sm"
isSelected={subStoreHost === '0.0.0.0'}
onValueChange={async (v) => {
try {
if (v) {
await patchAppConfig({ subStoreHost: '0.0.0.0' })
} else {
await patchAppConfig({ subStoreHost: '127.0.0.1' })
}
await startSubStoreFrontendServer()
await startSubStoreBackendServer()
} catch (e) {
alert(e)
}
}}
/>
</SettingItem>
<SettingItem title={t('substore.useCustomBackend')} divider>
<Switch <Switch
size="sm" size="sm"
isSelected={useCustomSubStore} isSelected={useCustomSubStore}
onValueChange={async (v) => { onValueChange={async (v) => {
try { try {
await patchAppConfig({ useCustomSubStore: v }) await patchAppConfig({ useCustomSubStore: v })
if (v) { if (!v) await startSubStoreServer()
await stopSubStoreBackendServer()
} else {
await startSubStoreBackendServer()
}
} catch (e) { } catch (e) {
alert(e) alert(e)
} }
}} }}
/> />
</SettingItem> </SettingItem>
)}
{useCustomSubStore ? ( {useCustomSubStore ? (
<SettingItem title={t('substore.customBackendUrl.title')}> <SettingItem title="自建 Sub-Store 后端地址">
<Input <Input
size="sm" size="sm"
className="w-[60%]" className="w-[60%]"
value={customSubStoreUrlValue} value={customSubStoreUrlValue}
placeholder={t('substore.customBackendUrl.placeholder')} placeholder="必须包含协议头"
onValueChange={(v: string) => { onValueChange={(v: string) => {
setCustomSubStoreUrlValue(v) setCustomSubStoreUrlValue(v)
setCustomSubStoreUrl(v) setCustomSubStoreUrl(v)
@ -114,21 +76,7 @@ const SubStoreConfig: React.FC = () => {
</SettingItem> </SettingItem>
) : ( ) : (
<> <>
<SettingItem title={t('substore.useProxy')} divider> <SettingItem title="定时同步订阅/文件" divider>
<Switch
size="sm"
isSelected={useProxyInSubStore}
onValueChange={async (v) => {
try {
await patchAppConfig({ useProxyInSubStore: v })
await startSubStoreBackendServer()
} catch (e) {
alert(e)
}
}}
/>
</SettingItem>
<SettingItem title={t('substore.sync.title')} divider>
<div className="flex w-[60%] gap-2"> <div className="flex w-[60%] gap-2">
{subStoreBackendSyncCronValue !== subStoreBackendSyncCron && ( {subStoreBackendSyncCronValue !== subStoreBackendSyncCron && (
<Button <Button
@ -142,26 +90,27 @@ const SubStoreConfig: React.FC = () => {
await patchAppConfig({ await patchAppConfig({
subStoreBackendSyncCron: subStoreBackendSyncCronValue subStoreBackendSyncCron: subStoreBackendSyncCronValue
}) })
new Notification(t('common.notification.restartRequired')) new Notification('重启应用生效')
} else { } else {
alert(t('common.error.invalidCron')) alert('Cron 表达式无效')
} }
}} }}
> >
{t('common.confirm')}
</Button> </Button>
)} )}
<Input <Input
size="sm" size="sm"
className="flex-grown"
value={subStoreBackendSyncCronValue} value={subStoreBackendSyncCronValue}
placeholder={t('substore.sync.placeholder')} placeholder="Cron 表达式"
onValueChange={(v: string) => { onValueChange={(v: string) => {
setSubStoreBackendSyncCronValue(v) setSubStoreBackendSyncCronValue(v)
}} }}
/> />
</div> </div>
</SettingItem> </SettingItem>
<SettingItem title={t('substore.restore.title')} divider> <SettingItem title="定时恢复配置" divider>
<div className="flex w-[60%] gap-2"> <div className="flex w-[60%] gap-2">
{subStoreBackendDownloadCronValue !== subStoreBackendDownloadCron && ( {subStoreBackendDownloadCronValue !== subStoreBackendDownloadCron && (
<Button <Button
@ -175,26 +124,27 @@ const SubStoreConfig: React.FC = () => {
await patchAppConfig({ await patchAppConfig({
subStoreBackendDownloadCron: subStoreBackendDownloadCronValue subStoreBackendDownloadCron: subStoreBackendDownloadCronValue
}) })
new Notification(t('common.notification.restartRequired')) new Notification('重启应用生效')
} else { } else {
alert(t('common.error.invalidCron')) alert('Cron 表达式无效')
} }
}} }}
> >
{t('common.confirm')}
</Button> </Button>
)} )}
<Input <Input
size="sm" size="sm"
className="flex-grown"
value={subStoreBackendDownloadCronValue} value={subStoreBackendDownloadCronValue}
placeholder={t('substore.restore.placeholder')} placeholder="Cron 表达式"
onValueChange={(v: string) => { onValueChange={(v: string) => {
setSubStoreBackendDownloadCronValue(v) setSubStoreBackendDownloadCronValue(v)
}} }}
/> />
</div> </div>
</SettingItem> </SettingItem>
<SettingItem title={t('substore.backup.title')}> <SettingItem title="定时备份配置">
<div className="flex w-[60%] gap-2"> <div className="flex w-[60%] gap-2">
{subStoreBackendUploadCronValue !== subStoreBackendUploadCron && ( {subStoreBackendUploadCronValue !== subStoreBackendUploadCron && (
<Button <Button
@ -208,19 +158,20 @@ const SubStoreConfig: React.FC = () => {
await patchAppConfig({ await patchAppConfig({
subStoreBackendUploadCron: subStoreBackendUploadCronValue subStoreBackendUploadCron: subStoreBackendUploadCronValue
}) })
new Notification(t('common.notification.restartRequired')) new Notification('重启应用生效')
} else { } else {
alert(t('common.error.invalidCron')) alert('Cron 表达式无效')
} }
}} }}
> >
{t('common.confirm')}
</Button> </Button>
)} )}
<Input <Input
size="sm" size="sm"
className="flex-grown"
value={subStoreBackendUploadCronValue} value={subStoreBackendUploadCronValue}
placeholder={t('substore.backup.placeholder')} placeholder="Cron 表达式"
onValueChange={(v: string) => { onValueChange={(v: string) => {
setSubStoreBackendUploadCronValue(v) setSubStoreBackendUploadCronValue(v)
}} }}
@ -229,8 +180,6 @@ const SubStoreConfig: React.FC = () => {
</SettingItem> </SettingItem>
</> </>
)} )}
</>
)}
</SettingCard> </SettingCard>
) )
} }

View File

@ -1,48 +1,29 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import SettingCard from '../base/base-setting-card' import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item' import SettingItem from '../base/base-setting-item'
import { Button, Input, Select, SelectItem } from '@heroui/react' import { Button, Input } from '@nextui-org/react'
import { listWebdavBackups, webdavBackup } from '@renderer/utils/ipc' import { listWebdavBackups, webdavBackup } from '@renderer/utils/ipc'
import WebdavRestoreModal from './webdav-restore-modal' import WebdavRestoreModal from './webdav-restore-modal'
import debounce from '@renderer/utils/debounce' import debounce from '@renderer/utils/debounce'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import { useTranslation } from 'react-i18next'
const WebdavConfig: React.FC = () => { const WebdavConfig: React.FC = () => {
const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig() const { appConfig, patchAppConfig } = useAppConfig()
const { const { webdavUrl, webdavUsername, webdavPassword } = appConfig || {}
webdavUrl,
webdavUsername,
webdavPassword,
webdavDir = 'mihomo-party',
webdavMaxBackups = 0
} = appConfig || {}
const [backuping, setBackuping] = useState(false) const [backuping, setBackuping] = useState(false)
const [restoring, setRestoring] = useState(false) const [restoring, setRestoring] = useState(false)
const [filenames, setFilenames] = useState<string[]>([]) const [filenames, setFilenames] = useState<string[]>([])
const [restoreOpen, setRestoreOpen] = useState(false) const [restoreOpen, setRestoreOpen] = useState(false)
const [webdav, setWebdav] = useState({ const [webdav, setWebdav] = useState({ webdavUrl, webdavUsername, webdavPassword })
webdavUrl, const setWebdavDebounce = debounce(({ webdavUrl, webdavUsername, webdavPassword }) => {
webdavUsername, patchAppConfig({ webdavUrl, webdavUsername, webdavPassword })
webdavPassword, }, 500)
webdavDir,
webdavMaxBackups
})
const setWebdavDebounce = debounce(
({ webdavUrl, webdavUsername, webdavPassword, webdavDir, webdavMaxBackups }) => {
patchAppConfig({ webdavUrl, webdavUsername, webdavPassword, webdavDir, webdavMaxBackups })
},
500
)
const handleBackup = async (): Promise<void> => { const handleBackup = async (): Promise<void> => {
setBackuping(true) setBackuping(true)
try { try {
await webdavBackup() await webdavBackup()
new window.Notification(t('webdav.notification.backupSuccess.title'), { new window.Notification('备份成功', { body: '备份文件已上传至 WebDav' })
body: t('webdav.notification.backupSuccess.body')
})
} catch (e) { } catch (e) {
alert(e) alert(e)
} finally { } finally {
@ -57,7 +38,7 @@ const WebdavConfig: React.FC = () => {
setFilenames(filenames) setFilenames(filenames)
setRestoreOpen(true) setRestoreOpen(true)
} catch (e) { } catch (e) {
alert(t('common.error.getBackupListFailed', { error: e })) alert(`获取备份列表失败: ${e}`)
} finally { } finally {
setRestoring(false) setRestoring(false)
} }
@ -67,8 +48,8 @@ const WebdavConfig: React.FC = () => {
{restoreOpen && ( {restoreOpen && (
<WebdavRestoreModal filenames={filenames} onClose={() => setRestoreOpen(false)} /> <WebdavRestoreModal filenames={filenames} onClose={() => setRestoreOpen(false)} />
)} )}
<SettingCard title={t('webdav.title')}> <SettingCard title="WebDav 备份">
<SettingItem title={t('webdav.url')} divider> <SettingItem title="WebDav 地址" divider>
<Input <Input
size="sm" size="sm"
className="w-[60%]" className="w-[60%]"
@ -79,18 +60,7 @@ const WebdavConfig: React.FC = () => {
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('webdav.dir')} divider> <SettingItem title="WebDav 用户名" divider>
<Input
size="sm"
className="w-[60%]"
value={webdav.webdavDir}
onValueChange={(v) => {
setWebdav({ ...webdav, webdavDir: v })
setWebdavDebounce({ ...webdav, webdavDir: v })
}}
/>
</SettingItem>
<SettingItem title={t('webdav.username')} divider>
<Input <Input
size="sm" size="sm"
className="w-[60%]" className="w-[60%]"
@ -101,7 +71,7 @@ const WebdavConfig: React.FC = () => {
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('webdav.password')} divider> <SettingItem title="WebDav 密码" divider>
<Input <Input
size="sm" size="sm"
className="w-[60%]" className="w-[60%]"
@ -113,31 +83,9 @@ const WebdavConfig: React.FC = () => {
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('webdav.maxBackups')} divider>
<Select
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
className="w-[150px]"
size="sm"
selectedKeys={new Set([webdav.webdavMaxBackups.toString()])}
aria-label={t('webdav.maxBackups')}
onSelectionChange={(v) => {
const value = Number.parseInt(Array.from(v)[0] as string, 10)
setWebdav({ ...webdav, webdavMaxBackups: value })
setWebdavDebounce({ ...webdav, webdavMaxBackups: value })
}}
>
<SelectItem key="0">{t('webdav.noLimit')}</SelectItem>
<SelectItem key="1">1</SelectItem>
<SelectItem key="3">3</SelectItem>
<SelectItem key="5">5</SelectItem>
<SelectItem key="10">10</SelectItem>
<SelectItem key="15">15</SelectItem>
<SelectItem key="20">20</SelectItem>
</Select>
</SettingItem>
<div className="flex justify0between"> <div className="flex justify0between">
<Button isLoading={backuping} fullWidth size="sm" className="mr-1" onPress={handleBackup}> <Button isLoading={backuping} fullWidth size="sm" className="mr-1" onPress={handleBackup}>
{t('webdav.backup')}
</Button> </Button>
<Button <Button
isLoading={restoring} isLoading={restoring}
@ -146,7 +94,7 @@ const WebdavConfig: React.FC = () => {
className="ml-1" className="ml-1"
onPress={handleRestore} onPress={handleRestore}
> >
{t('webdav.restore.title')}
</Button> </Button>
</div> </div>
</SettingCard> </SettingCard>

View File

@ -1,16 +1,12 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react' import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@nextui-org/react'
import { relaunchApp, webdavDelete, webdavRestore } from '@renderer/utils/ipc' import { relaunchApp, webdavDelete, webdavRestore } from '@renderer/utils/ipc'
import React, { useState } from 'react' import React, { useState } from 'react'
import { MdDeleteForever } from 'react-icons/md' import { MdDeleteForever } from 'react-icons/md'
import { useTranslation } from 'react-i18next'
interface Props { interface Props {
filenames: string[] filenames: string[]
onClose: () => void onClose: () => void
} }
const WebdavRestoreModal: React.FC<Props> = (props) => { const WebdavRestoreModal: React.FC<Props> = (props) => {
const { t } = useTranslation()
const { filenames: names, onClose } = props const { filenames: names, onClose } = props
const [filenames, setFilenames] = useState<string[]>(names) const [filenames, setFilenames] = useState<string[]>(names)
const [restoring, setRestoring] = useState(false) const [restoring, setRestoring] = useState(false)
@ -25,10 +21,10 @@ const WebdavRestoreModal: React.FC<Props> = (props) => {
scrollBehavior="inside" scrollBehavior="inside"
> >
<ModalContent> <ModalContent>
<ModalHeader className="flex app-drag">{t('webdav.restore.title')}</ModalHeader> <ModalHeader className="flex"></ModalHeader>
<ModalBody> <ModalBody>
{filenames.length === 0 ? ( {filenames.length === 0 ? (
<div className="flex justify-center">{t('webdav.restore.noBackups')}</div> <div className="flex justify-center"></div>
) : ( ) : (
filenames.map((filename) => ( filenames.map((filename) => (
<div className="flex" key={filename}> <div className="flex" key={filename}>
@ -43,7 +39,7 @@ const WebdavRestoreModal: React.FC<Props> = (props) => {
await webdavRestore(filename) await webdavRestore(filename)
await relaunchApp() await relaunchApp()
} catch (e) { } catch (e) {
alert(t('common.error.restoreFailed', { error: e })) alert(`恢复失败: ${e}`)
} finally { } finally {
setRestoring(false) setRestoring(false)
} }
@ -56,12 +52,12 @@ const WebdavRestoreModal: React.FC<Props> = (props) => {
color="warning" color="warning"
variant="flat" variant="flat"
className="ml-2" className="ml-2"
onPress={async () => { onClick={async () => {
try { try {
await webdavDelete(filename) await webdavDelete(filename)
setFilenames(filenames.filter((name) => name !== filename)) setFilenames(filenames.filter((name) => name !== filename))
} catch (e) { } catch (e) {
alert(t('common.error.deleteFailed', { error: e })) alert(`删除失败: ${e}`)
} }
}} }}
> >
@ -72,8 +68,8 @@ const WebdavRestoreModal: React.FC<Props> = (props) => {
)} )}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button size="sm" variant="light" onPress={onClose}> <Button variant="light" onPress={onClose}>
{t('common.close')}
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View File

@ -1,14 +1,11 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react' import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@nextui-org/react'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { BaseEditor } from '../base/base-editor' import { BaseEditor } from '../base/base-editor'
import { getRuntimeConfigStr } from '@renderer/utils/ipc' import { getRuntimeConfigStr } from '@renderer/utils/ipc'
import { useTranslation } from 'react-i18next'
interface Props { interface Props {
onClose: () => void onClose: () => void
} }
const ConfigViewer: React.FC<Props> = (props) => { const ConfigViewer: React.FC<Props> = (props) => {
const { t } = useTranslation()
const { onClose } = props const { onClose } = props
const [currData, setCurrData] = useState('') const [currData, setCurrData] = useState('')
@ -31,13 +28,13 @@ const ConfigViewer: React.FC<Props> = (props) => {
scrollBehavior="inside" scrollBehavior="inside"
> >
<ModalContent className="h-full w-[calc(100%-100px)]"> <ModalContent className="h-full w-[calc(100%-100px)]">
<ModalHeader className="flex pb-0 app-drag">{t('sider.cards.config')}</ModalHeader> <ModalHeader className="flex pb-0"></ModalHeader>
<ModalBody className="h-full"> <ModalBody className="h-full">
<BaseEditor language="yaml" value={currData} readOnly={true} /> <BaseEditor language="yaml" value={currData} readOnly={true} />
</ModalBody> </ModalBody>
<ModalFooter className="pt-0"> <ModalFooter className="pt-0">
<Button size="sm" variant="light" onPress={onClose}> <Button size="sm" variant="light" onPress={onClose}>
{t('common.close')}
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View File

@ -1,8 +1,8 @@
import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react' import { Button, Card, CardBody, CardFooter } from '@nextui-org/react'
import { FaCircleArrowDown, FaCircleArrowUp } from 'react-icons/fa6' import { FaCircleArrowDown, FaCircleArrowUp } from 'react-icons/fa6'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { calcTraffic } from '@renderer/utils/calc' import { calcTraffic } from '@renderer/utils/calc'
import React, { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable' import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { IoLink } from 'react-icons/io5' import { IoLink } from 'react-icons/io5'
@ -10,25 +10,18 @@ import { useTheme } from 'next-themes'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import { platform } from '@renderer/utils/init' import { platform } from '@renderer/utils/init'
import { Area, AreaChart, ResponsiveContainer } from 'recharts' import { Area, AreaChart, ResponsiveContainer } from 'recharts'
import { useTranslation } from 'react-i18next'
let currentUpload: number | undefined = undefined let currentUpload: number | undefined = undefined
let currentDownload: number | undefined = undefined let currentDownload: number | undefined = undefined
let hasShowTraffic = false let hasShowTraffic = false
let drawing = false let drawing = false
interface Props { const ConnCard: React.FC = () => {
iconOnly?: boolean
}
const ConnCard: React.FC<Props> = (props) => {
const { theme = 'system', systemTheme = 'dark' } = useTheme() const { theme = 'system', systemTheme = 'dark' } = useTheme()
const { iconOnly } = props
const { appConfig } = useAppConfig() const { appConfig } = useAppConfig()
const { showTraffic = false, connectionCardStatus = 'col-span-2', customTheme } = appConfig || {} const { showTraffic, connectionCardStatus = 'col-span-2', customTheme } = appConfig || {}
const location = useLocation() const location = useLocation()
const navigate = useNavigate()
const match = location.pathname.includes('/connections') const match = location.pathname.includes('/connections')
const { t } = useTranslation()
const [upload, setUpload] = useState(0) const [upload, setUpload] = useState(0)
const [download, setDownload] = useState(0) const [download, setDownload] = useState(0)
@ -45,23 +38,18 @@ const ConnCard: React.FC<Props> = (props) => {
const [series, setSeries] = useState(Array(10).fill(0)) const [series, setSeries] = useState(Array(10).fill(0))
const [chartColor, setChartColor] = useState('rgba(255,255,255)') const [chartColor, setChartColor] = useState('rgba(255,255,255)')
useEffect(() => {
setChartColor(
match
? `hsla(${getComputedStyle(document.documentElement).getPropertyValue('--heroui-primary-foreground')})`
: `hsla(${getComputedStyle(document.documentElement).getPropertyValue('--heroui-foreground')})`
)
}, [theme, systemTheme, match])
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
const islight = theme === 'system' ? systemTheme === 'light' : theme.includes('light')
setChartColor( setChartColor(
match match
? `hsla(${getComputedStyle(document.documentElement).getPropertyValue('--heroui-primary-foreground')})` ? 'rgba(255,255,255)'
: `hsla(${getComputedStyle(document.documentElement).getPropertyValue('--heroui-foreground')})` : islight
? window.getComputedStyle(document.documentElement).color
: 'rgb(255,255,255)'
) )
}, 200) }, 1000)
}, [customTheme]) }, [theme, systemTheme, match, customTheme])
const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null
useEffect(() => { useEffect(() => {
@ -94,26 +82,6 @@ const ConnCard: React.FC<Props> = (props) => {
} }
}, [showTraffic]) }, [showTraffic])
if (iconOnly) {
return (
<div className={`${connectionCardStatus} flex justify-center`}>
<Tooltip content={t('sider.cards.connections')} placement="right">
<Button
size="sm"
isIconOnly
color={match ? 'primary' : 'default'}
variant={match ? 'solid' : 'light'}
onPress={() => {
navigate('/connections')
}}
>
<IoLink className="text-[20px]" />
</Button>
</Tooltip>
</div>
)
}
return ( return (
<div <div
style={{ style={{
@ -122,7 +90,7 @@ const ConnCard: React.FC<Props> = (props) => {
transition, transition,
zIndex: isDragging ? 'calc(infinity)' : undefined zIndex: isDragging ? 'calc(infinity)' : undefined
}} }}
className={`${connectionCardStatus} conn-card`} className={connectionCardStatus}
> >
{connectionCardStatus === 'col-span-2' ? ( {connectionCardStatus === 'col-span-2' ? (
<> <>
@ -143,12 +111,10 @@ const ConnCard: React.FC<Props> = (props) => {
> >
<IoLink <IoLink
color="default" color="default"
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px]`} className={`${match ? 'text-white' : 'text-foreground'} text-[24px]`}
/> />
</Button> </Button>
<div <div className={`p-2 w-full ${match ? 'text-white' : 'text-foreground'} `}>
className={`p-2 w-full ${match ? 'text-primary-foreground' : 'text-foreground'} `}
>
<div className="flex justify-between"> <div className="flex justify-between">
<div className="w-full text-right mr-2">{calcTraffic(upload)}/s</div> <div className="w-full text-right mr-2">{calcTraffic(upload)}/s</div>
<FaCircleArrowUp className="h-[24px] leading-[24px]" /> <FaCircleArrowUp className="h-[24px] leading-[24px]" />
@ -161,10 +127,8 @@ const ConnCard: React.FC<Props> = (props) => {
</div> </div>
</CardBody> </CardBody>
<CardFooter className="pt-1"> <CardFooter className="pt-1">
<h3 <h3 className={`text-md font-bold ${match ? 'text-white' : 'text-foreground'}`}>
className={`text-md font-bold ${match ? 'text-primary-foreground' : 'text-foreground'}`}
>
{t('sider.cards.connections')}
</h3> </h3>
</CardFooter> </CardFooter>
</Card> </Card>
@ -211,16 +175,14 @@ const ConnCard: React.FC<Props> = (props) => {
> >
<IoLink <IoLink
color="default" color="default"
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px] font-bold`} className={`${match ? 'text-white' : 'text-foreground'} text-[24px] font-bold`}
/> />
</Button> </Button>
</div> </div>
</CardBody> </CardBody>
<CardFooter className="pt-1"> <CardFooter className="pt-1">
<h3 <h3 className={`text-md font-bold ${match ? 'text-white' : 'text-foreground'}`}>
className={`text-md font-bold ${match ? 'text-primary-foreground' : 'text-foreground'}`}
>
{t('sider.cards.connections')}
</h3> </h3>
</CardFooter> </CardFooter>
</Card> </Card>

View File

@ -1,25 +1,16 @@
import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react' import { Button, Card, CardBody, CardFooter } from '@nextui-org/react'
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config' import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
import BorderSwitch from '@renderer/components/base/border-swtich' import BorderSwitch from '@renderer/components/base/border-swtich'
import { LuServer } from 'react-icons/lu' import { LuServer } from 'react-icons/lu'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { patchMihomoConfig } from '@renderer/utils/ipc' import { patchMihomoConfig } from '@renderer/utils/ipc'
import { useSortable } from '@dnd-kit/sortable' import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import React from 'react' const DNSCard: React.FC = () => {
import { useTranslation } from 'react-i18next'
interface Props {
iconOnly?: boolean
}
const DNSCard: React.FC<Props> = (props) => {
const { t } = useTranslation()
const { appConfig } = useAppConfig() const { appConfig } = useAppConfig()
const { iconOnly } = props
const { dnsCardStatus = 'col-span-1', controlDns = true } = appConfig || {} const { dnsCardStatus = 'col-span-1', controlDns = true } = appConfig || {}
const location = useLocation() const location = useLocation()
const navigate = useNavigate()
const match = location.pathname.includes('/dns') const match = location.pathname.includes('/dns')
const { controledMihomoConfig, patchControledMihomoConfig } = useControledMihomoConfig() const { controledMihomoConfig, patchControledMihomoConfig } = useControledMihomoConfig()
const { dns, tun } = controledMihomoConfig || {} const { dns, tun } = controledMihomoConfig || {}
@ -40,26 +31,6 @@ const DNSCard: React.FC<Props> = (props) => {
await patchMihomoConfig({ dns: { enable } }) await patchMihomoConfig({ dns: { enable } })
} }
if (iconOnly) {
return (
<div className={`${dnsCardStatus} ${!controlDns ? 'hidden' : ''} flex justify-center`}>
<Tooltip content={t('sider.cards.dns')} placement="right">
<Button
size="sm"
isIconOnly
color={match ? 'primary' : 'default'}
variant={match ? 'solid' : 'light'}
onPress={() => {
navigate('/dns')
}}
>
<LuServer className="text-[20px]" />
</Button>
</Tooltip>
</div>
)
}
return ( return (
<div <div
style={{ style={{
@ -86,7 +57,7 @@ const DNSCard: React.FC<Props> = (props) => {
color="default" color="default"
> >
<LuServer <LuServer
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px] font-bold`} className={`${match ? 'text-white' : 'text-foreground'} text-[24px] font-bold`}
/> />
</Button> </Button>
<BorderSwitch <BorderSwitch
@ -98,11 +69,7 @@ const DNSCard: React.FC<Props> = (props) => {
</div> </div>
</CardBody> </CardBody>
<CardFooter className="pt-1"> <CardFooter className="pt-1">
<h3 <h3 className={`text-md font-bold ${match ? 'text-white' : 'text-foreground'}`}>DNS</h3>
className={`text-md font-bold ${match ? 'text-primary-foreground' : 'text-foreground'}`}
>
{t('sider.cards.dns')}
</h3>
</CardFooter> </CardFooter>
</Card> </Card>
</div> </div>

View File

@ -1,23 +1,13 @@
import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react' import { Button, Card, CardBody, CardFooter } from '@nextui-org/react'
import { IoJournalOutline } from 'react-icons/io5' import { IoJournalOutline } from 'react-icons/io5'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useSortable } from '@dnd-kit/sortable' import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import React from 'react' const LogCard: React.FC = () => {
import { useTranslation } from 'react-i18next'
interface Props {
iconOnly?: boolean
}
const LogCard: React.FC<Props> = (props) => {
const { t } = useTranslation()
const { appConfig } = useAppConfig() const { appConfig } = useAppConfig()
const { iconOnly } = props
const { logCardStatus = 'col-span-1' } = appConfig || {} const { logCardStatus = 'col-span-1' } = appConfig || {}
const location = useLocation() const location = useLocation()
const navigate = useNavigate()
const match = location.pathname.includes('/logs') const match = location.pathname.includes('/logs')
const { const {
attributes, attributes,
@ -30,26 +20,6 @@ const LogCard: React.FC<Props> = (props) => {
id: 'log' id: 'log'
}) })
const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null
if (iconOnly) {
return (
<div className={`${logCardStatus} flex justify-center`}>
<Tooltip content={t('sider.cards.logs')} placement="right">
<Button
size="sm"
isIconOnly
color={match ? 'primary' : 'default'}
variant={match ? 'solid' : 'light'}
onPress={() => {
navigate('/logs')
}}
>
<IoJournalOutline className="text-[20px]" />
</Button>
</Tooltip>
</div>
)
}
return ( return (
<div <div
style={{ style={{
@ -77,17 +47,13 @@ const LogCard: React.FC<Props> = (props) => {
> >
<IoJournalOutline <IoJournalOutline
color="default" color="default"
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px] font-bold`} className={`${match ? 'text-white' : 'text-foreground'} text-[24px] font-bold`}
/> />
</Button> </Button>
</div> </div>
</CardBody> </CardBody>
<CardFooter className="pt-1"> <CardFooter className="pt-1">
<h3 <h3 className={`text-md font-bold ${match ? 'text-white' : 'text-foreground'}`}></h3>
className={`text-md font-bold ${match ? 'text-primary-foreground' : 'text-foreground'}`}
>
{t('sider.cards.logs')}
</h3>
</CardFooter> </CardFooter>
</Card> </Card>
</div> </div>

View File

@ -1,28 +1,21 @@
import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react' import { Button, Card, CardBody, CardFooter } from '@nextui-org/react'
import { calcTraffic } from '@renderer/utils/calc' import { calcTraffic } from '@renderer/utils/calc'
import { mihomoVersion, restartCore } from '@renderer/utils/ipc' import { mihomoVersion, restartCore } from '@renderer/utils/ipc'
import React, { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { IoMdRefresh } from 'react-icons/io' import { IoMdRefresh } from 'react-icons/io'
import { useSortable } from '@dnd-kit/sortable' import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import PubSub from 'pubsub-js' import PubSub from 'pubsub-js'
import useSWR from 'swr' import useSWR from 'swr'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import { LuCpu } from 'react-icons/lu' import { LuCpu } from 'react-icons/lu'
import { useTranslation } from 'react-i18next'
interface Props { const MihomoCoreCard: React.FC = () => {
iconOnly?: boolean
}
const MihomoCoreCard: React.FC<Props> = (props) => {
const { appConfig } = useAppConfig() const { appConfig } = useAppConfig()
const { iconOnly } = props
const { mihomoCoreCardStatus = 'col-span-2' } = appConfig || {} const { mihomoCoreCardStatus = 'col-span-2' } = appConfig || {}
const { data: version, mutate } = useSWR('mihomoVersion', mihomoVersion) const { data: version, mutate } = useSWR('mihomoVersion', mihomoVersion)
const location = useLocation() const location = useLocation()
const navigate = useNavigate()
const match = location.pathname.includes('/mihomo') const match = location.pathname.includes('/mihomo')
const { const {
attributes, attributes,
@ -36,7 +29,6 @@ const MihomoCoreCard: React.FC<Props> = (props) => {
}) })
const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null
const [mem, setMem] = useState(0) const [mem, setMem] = useState(0)
const { t } = useTranslation()
useEffect(() => { useEffect(() => {
const token = PubSub.subscribe('mihomo-core-changed', () => { const token = PubSub.subscribe('mihomo-core-changed', () => {
@ -51,26 +43,6 @@ const MihomoCoreCard: React.FC<Props> = (props) => {
} }
}, []) }, [])
if (iconOnly) {
return (
<div className={`${mihomoCoreCardStatus} flex justify-center`}>
<Tooltip content={t('sider.cards.core')} placement="right">
<Button
size="sm"
isIconOnly
color={match ? 'primary' : 'default'}
variant={match ? 'solid' : 'light'}
onPress={() => {
navigate('/mihomo')
}}
>
<LuCpu className="text-[20px]" />
</Button>
</Tooltip>
</div>
)
}
return ( return (
<div <div
style={{ style={{
@ -79,7 +51,7 @@ const MihomoCoreCard: React.FC<Props> = (props) => {
transition, transition,
zIndex: isDragging ? 'calc(infinity)' : undefined zIndex: isDragging ? 'calc(infinity)' : undefined
}} }}
className={`${mihomoCoreCardStatus} mihomo-core-card`} className={mihomoCoreCardStatus}
> >
{mihomoCoreCardStatus === 'col-span-2' ? ( {mihomoCoreCardStatus === 'col-span-2' ? (
<Card <Card
@ -97,7 +69,7 @@ const MihomoCoreCard: React.FC<Props> = (props) => {
className="flex justify-between h-[32px]" className="flex justify-between h-[32px]"
> >
<h3 <h3
className={`text-md font-bold leading-[32px] ${match ? 'text-primary-foreground' : 'text-foreground'} `} className={`text-md font-bold leading-[32px] ${match ? 'text-white' : 'text-foreground'} `}
> >
{version?.version ?? '-'} {version?.version ?? '-'}
</h3> </h3>
@ -118,16 +90,16 @@ const MihomoCoreCard: React.FC<Props> = (props) => {
}} }}
> >
<IoMdRefresh <IoMdRefresh
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px]`} className={`${match ? 'text-white' : 'text-foreground'} text-[24px]`}
/> />
</Button> </Button>
</div> </div>
</CardBody> </CardBody>
<CardFooter className="pt-1"> <CardFooter className="pt-1">
<div <div
className={`flex justify-between w-full text-md font-bold ${match ? 'text-primary-foreground' : 'text-foreground'}`} className={`flex justify-between w-full text-md font-bold ${match ? 'text-white' : 'text-foreground'}`}
> >
<h4>{t('sider.cards.core')}</h4> <h4></h4>
<h4>{calcTraffic(mem)}</h4> <h4>{calcTraffic(mem)}</h4>
</div> </div>
</CardFooter> </CardFooter>
@ -150,16 +122,14 @@ const MihomoCoreCard: React.FC<Props> = (props) => {
> >
<LuCpu <LuCpu
color="default" color="default"
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px] font-bold`} className={`${match ? 'text-white' : 'text-foreground'} text-[24px] font-bold`}
/> />
</Button> </Button>
</div> </div>
</CardBody> </CardBody>
<CardFooter className="pt-1"> <CardFooter className="pt-1">
<h3 <h3 className={`text-md font-bold ${match ? 'text-white' : 'text-foreground'}`}>
className={`text-md font-bold ${match ? 'text-primary-foreground' : 'text-foreground'}`}
>
{t('sider.cards.core')}
</h3> </h3>
</CardFooter> </CardFooter>
</Card> </Card>

Some files were not shown because too many files have changed in this diff Show More