Compare commits

..

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

211 changed files with 10342 additions and 21293 deletions

4
.eslintignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
out
.gitignore

9
.eslintrc.cjs Normal file
View File

@ -0,0 +1,9 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'@electron-toolkit/eslint-config-ts/recommended',
'@electron-toolkit/eslint-config-prettier'
]
}

View File

@ -1,5 +1,5 @@
name: 错误反馈
description: '提交 clash-party 漏洞'
description: '提交 mihomo-party 漏洞'
title: '[Bug] '
body:
- type: checkboxes
@ -10,7 +10,7 @@ body:
options:
- label: 我已在标题简短的描述了我所遇到的问题
- label: 我已在 [Issue Tracker](./?q=is%3Aissue) 中寻找过我要提出的问题,但未找到相同的问题
- label: 我已在 [常见问题](https://clashparty.org/docs/issues/common) 中寻找过我要提出的问题,并没有找到答案
- label: 我已在 [常见问题](https://mihomo.party/docs/issues/common) 中寻找过我要提出的问题,并没有找到答案
- label: 这是 GUI 程序的问题,而不是内核程序的问题
- label: 我已经关闭所有杀毒软件/代理软件后测试过,问题依旧存在
- label: 我已经使用最新的测试版本测试过,问题依旧存在
@ -34,7 +34,7 @@ body:
required: true
- type: input
attributes:
label: 发生问题 clash-party 版本
label: 发生问题 mihomo-party 版本
validations:
required: true
- type: textarea

View File

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

View File

@ -1,5 +1,5 @@
name: 功能请求
description: '请求 clash-party 功能'
description: '请求 mihomo-party 功能'
title: '[Feature] '
body:
- type: checkboxes

View File

@ -12,86 +12,7 @@ on:
permissions: write-all
jobs:
cleanup-dev-release:
runs-on: ubuntu-latest
steps:
- name: Delete Dev Release Assets
if: github.event_name == 'workflow_dispatch'
continue-on-error: true
run: |
# Get release ID for dev tag
echo "🔍 Looking for existing dev release..."
RELEASE_ID=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/releases/tags/dev" | \
jq -r '.id // empty')
if [ ! -z "$RELEASE_ID" ] && [ "$RELEASE_ID" != "empty" ]; then
echo "✅ Found dev release with ID: $RELEASE_ID"
echo "📋 Getting list of assets with pagination..."
ALL_ASSETS="[]"
PAGE=1
PER_PAGE=100
while true; do
echo "📄 Fetching page $PAGE..."
ASSETS_PAGE=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID/assets?page=$PAGE&per_page=$PER_PAGE")
PAGE_COUNT=$(echo "$ASSETS_PAGE" | jq '. | length')
echo "📦 Found $PAGE_COUNT assets on page $PAGE"
if [ "$PAGE_COUNT" -eq 0 ]; then
echo "📋 No more assets found, stopping pagination"
break
fi
ALL_ASSETS=$(echo "$ALL_ASSETS" "$ASSETS_PAGE" | jq -s '.[0] + .[1]')
if [ "$PAGE_COUNT" -lt "$PER_PAGE" ]; then
echo "📋 Last page reached (got $PAGE_COUNT < $PER_PAGE), stopping pagination"
break
fi
PAGE=$((PAGE + 1))
done
TOTAL_ASSET_COUNT=$(echo "$ALL_ASSETS" | jq '. | length')
echo "📦 Total assets found across all pages: $TOTAL_ASSET_COUNT"
if [ "$TOTAL_ASSET_COUNT" -gt 0 ]; then
# Delete each asset with detailed logging
echo "$ALL_ASSETS" | jq -r '.[].id' | while read asset_id; do
if [ ! -z "$asset_id" ]; then
echo "🗑️ Deleting asset ID: $asset_id"
RESPONSE=$(curl -s -w "HTTPSTATUS:%{http_code}" -X DELETE \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/releases/assets/$asset_id")
HTTP_CODE=$(echo $RESPONSE | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
if [ "$HTTP_CODE" = "204" ]; then
echo "✅ Successfully deleted asset $asset_id"
else
echo "❌ Failed to delete asset $asset_id (HTTP: $HTTP_CODE)"
echo "Response: $(echo $RESPONSE | sed -e 's/HTTPSTATUS:.*//')"
fi
# Add small delay to avoid rate limiting
sleep 0.5
fi
done
echo "🎉 Finished deleting all $TOTAL_ASSET_COUNT assets"
else
echo " No assets found to delete"
fi
else
echo " No existing dev release found"
fi
- name: Skip for Tag Release
if: startsWith(github.ref, 'refs/tags/v')
run: echo "Skipping cleanup for tag release"
windows:
needs: [cleanup-dev-release]
if: always() && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch')
strategy:
fail-fast: false
matrix:
@ -102,26 +23,17 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'pnpm'
run: npm install -g pnpm
- name: Install Dependencies
env:
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
run: pnpm install
- name: Update Version for Dev Build
if: github.event_name == 'workflow_dispatch'
env:
GITHUB_EVENT_NAME: workflow_dispatch
run: node scripts/update-version.mjs
- name: Prepare
run: pnpm prepare --${{ matrix.arch }}
run: |
pnpm install
pnpm add @mihomo-party/sysproxy-win32-${{ matrix.arch }}-msvc
pnpm prepare --${{ matrix.arch }}
- name: Build
env:
npm_config_arch: ${{ matrix.arch }}
@ -135,11 +47,9 @@ jobs:
}
- name: Generate checksums
run: pnpm checksum setup.exe portable.7z
- name: Copy Legacy Artifacts
run: pnpm copy-legacy
- name: Upload Artifacts
if: startsWith(github.ref, 'refs/heads/')
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: Windows ${{ matrix.arch }}
path: |
@ -155,23 +65,10 @@ jobs:
dist/*.sha256
dist/*setup.exe
dist/*portable.7z
token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Dev Release
if: github.event_name == 'workflow_dispatch'
uses: softprops/action-gh-release@v2
with:
tag_name: dev
files: |
dist/*.sha256
dist/*setup.exe
dist/*portable.7z
prerelease: true
draft: false
body_path: changelog.md
token: ${{ secrets.GITHUB_TOKEN }}
windows7:
needs: [cleanup-dev-release]
if: always() && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch')
strategy:
fail-fast: false
matrix:
@ -181,38 +78,23 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: '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-win32-${{ matrix.arch }}-msvc
pnpm add -D electron@22.3.27
(Get-Content electron-builder.yml) -replace 'windows', 'win7' | Set-Content electron-builder.yml
# Electron 22 requires CJS format
(Get-Content package.json) -replace '"type": "module"', '"type": "commonjs"' | Set-Content package.json
- name: Update Version for Dev Build
if: github.event_name == 'workflow_dispatch'
env:
GITHUB_EVENT_NAME: workflow_dispatch
run: node scripts/update-version.mjs
- name: Prepare
env:
LEGACY_BUILD: 'true'
run: pnpm prepare --${{ matrix.arch }}
pnpm prepare --${{ matrix.arch }}
- name: Build
env:
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
LEGACY_BUILD: 'true'
run: pnpm build:win --${{ matrix.arch }}
- name: Add Portable Flag
run: |
@ -222,11 +104,9 @@ jobs:
}
- name: Generate checksums
run: pnpm checksum setup.exe portable.7z
- name: Copy Legacy Artifacts
run: pnpm copy-legacy
- name: Upload Artifacts
if: startsWith(github.ref, 'refs/heads/')
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: Win7 ${{ matrix.arch }}
path: |
@ -242,23 +122,10 @@ jobs:
dist/*.sha256
dist/*setup.exe
dist/*portable.7z
token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Dev Release
if: github.event_name == 'workflow_dispatch'
uses: softprops/action-gh-release@v2
with:
tag_name: dev
files: |
dist/*.sha256
dist/*setup.exe
dist/*portable.7z
prerelease: true
draft: false
body_path: changelog.md
token: ${{ secrets.GITHUB_TOKEN }}
linux:
needs: [cleanup-dev-release]
if: always() && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch')
strategy:
fail-fast: false
matrix:
@ -268,28 +135,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'pnpm'
run: npm install -g pnpm
- name: Install Dependencies
env:
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
run: |
pnpm install
sed -i "s/productName: Clash Party/productName: clash-party/" electron-builder.yml
- name: Update Version for Dev Build
if: github.event_name == 'workflow_dispatch'
env:
GITHUB_EVENT_NAME: workflow_dispatch
run: node scripts/update-version.mjs
- name: Prepare
run: pnpm prepare --${{ matrix.arch }}
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 }}
- name: Build
env:
npm_config_arch: ${{ matrix.arch }}
@ -297,11 +154,9 @@ jobs:
run: pnpm build:linux --${{ matrix.arch }}
- name: Generate checksums
run: pnpm checksum .deb .rpm
- name: Copy Legacy Artifacts
run: pnpm copy-legacy
- name: Upload Artifacts
if: startsWith(github.ref, 'refs/heads/')
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: Linux ${{ matrix.arch }}
path: |
@ -317,23 +172,10 @@ jobs:
dist/*.sha256
dist/*.deb
dist/*.rpm
token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Dev Release
if: github.event_name == 'workflow_dispatch'
uses: softprops/action-gh-release@v2
with:
tag_name: dev
files: |
dist/*.sha256
dist/*.deb
dist/*.rpm
prerelease: true
draft: false
body_path: changelog.md
token: ${{ secrets.GITHUB_TOKEN }}
macos:
needs: [cleanup-dev-release]
if: always() && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch')
strategy:
fail-fast: false
matrix:
@ -343,26 +185,17 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'pnpm'
run: npm install -g pnpm
- name: Install Dependencies
env:
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
run: pnpm install
- name: Update Version for Dev Build
if: github.event_name == 'workflow_dispatch'
env:
GITHUB_EVENT_NAME: workflow_dispatch
run: node scripts/update-version.mjs
- name: Prepare
run: pnpm prepare --${{ matrix.arch }}
run: |
pnpm install
pnpm add @mihomo-party/sysproxy-darwin-${{ matrix.arch }}
pnpm prepare --${{ matrix.arch }}
- name: Build
env:
npm_config_arch: ${{ matrix.arch }}
@ -376,7 +209,7 @@ jobs:
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@v6
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.CSC_INSTALLER_LINK }}
p12-password: ${{ secrets.CSC_INSTALLER_KEY_PASSWORD }}
@ -396,11 +229,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Generate checksums
run: pnpm checksum .pkg
- name: Copy Legacy Artifacts
run: pnpm copy-legacy
- name: Upload Artifacts
if: startsWith(github.ref, 'refs/heads/')
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: MacOS ${{ matrix.arch }}
path: |
@ -414,22 +245,10 @@ jobs:
files: |
dist/*.sha256
dist/*.pkg
token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Dev Release
if: github.event_name == 'workflow_dispatch'
uses: softprops/action-gh-release@v2
with:
tag_name: dev
files: |
dist/*.sha256
dist/*.pkg
prerelease: true
draft: false
body_path: changelog.md
token: ${{ secrets.GITHUB_TOKEN }}
macos10:
needs: [cleanup-dev-release]
if: always() && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch')
strategy:
fail-fast: false
matrix:
@ -439,28 +258,18 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: '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
- name: Update Version for Dev Build
if: github.event_name == 'workflow_dispatch'
env:
GITHUB_EVENT_NAME: workflow_dispatch
run: node scripts/update-version.mjs
- name: Prepare
run: pnpm prepare --${{ matrix.arch }}
pnpm prepare --${{ matrix.arch }}
- name: Build
env:
npm_config_arch: ${{ matrix.arch }}
@ -475,7 +284,7 @@ jobs:
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@v6
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.CSC_INSTALLER_LINK }}
p12-password: ${{ secrets.CSC_INSTALLER_KEY_PASSWORD }}
@ -495,11 +304,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Generate checksums
run: pnpm checksum .pkg
- name: Copy Legacy Artifacts
run: pnpm copy-legacy
- name: Upload Artifacts
if: startsWith(github.ref, 'refs/heads/')
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: Catalina ${{ matrix.arch }}
path: |
@ -513,72 +320,32 @@ jobs:
files: |
dist/*.sha256
dist/*.pkg
token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Dev Release
if: github.event_name == 'workflow_dispatch'
uses: softprops/action-gh-release@v2
with:
tag_name: dev
files: |
dist/*.sha256
dist/*.pkg
prerelease: true
draft: false
body_path: changelog.md
token: ${{ secrets.GITHUB_TOKEN }}
updater:
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
needs: [windows, windows7, linux, macos, macos10]
if: startsWith(github.ref, 'refs/tags/v')
needs: [windows, macos, windows7, macos10]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'pnpm'
run: npm install -g pnpm
- name: Install Dependencies
run: pnpm install
- name: Update Version for Dev Build
if: github.event_name == 'workflow_dispatch'
env:
GITHUB_EVENT_NAME: workflow_dispatch
run: node scripts/update-version.mjs
- name: Telegram Notification
if: startsWith(github.ref, 'refs/tags/v')
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
RELEASE_TYPE: release
run: pnpm telegram
- name: Telegram Dev Notification
if: github.event_name == 'workflow_dispatch'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
GITHUB_SHA: ${{ github.sha }}
RELEASE_TYPE: dev
run: pnpm telegram:dev
- name: Generate latest.yml
run: pnpm updater
- name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: latest.yml
body_path: changelog.md
token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Dev Release
if: github.event_name == 'workflow_dispatch'
uses: softprops/action-gh-release@v2
with:
tag_name: dev
files: latest.yml
body_path: changelog.md
prerelease: true
draft: false
token: ${{ secrets.GITHUB_TOKEN }}
aur-release-updater:
strategy:
@ -590,25 +357,25 @@ jobs:
- mihomo-party-bin
- mihomo-party
if: startsWith(github.ref, 'refs/tags/v')
needs: updater
needs: linux
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Update Version
run: |
sed -i "s/pkgver=.*/pkgver=$(echo ${{ github.ref }} | tr -d 'refs/tags/v')/" aur/${{ matrix.pkgname }}/PKGBUILD
- name: Update Checksums
if: matrix.pkgname == 'mihomo-party' || matrix.pkgname == 'mihomo-party-electron'
run: |
wget https://github.com/${{ github.repository }}/archive/refs/tags/$(echo ${{ github.ref }} | tr -d 'refs/tags/').tar.gz -O release.tar.gz
wget https://github.com/mihomo-party-org/mihomo-party/archive/refs/tags/$(echo ${{ github.ref }} | tr -d 'refs/tags/').tar.gz -O release.tar.gz
sed -i "s/sha256sums=.*/sha256sums=(\"$(sha256sum ./release.tar.gz | awk '{print $1}')\"/" aur/mihomo-party/PKGBUILD
sed -i "s/sha256sums=.*/sha256sums=(\"$(sha256sum ./release.tar.gz | awk '{print $1}')\"/" aur/mihomo-party-electron/PKGBUILD
- name: Update Checksums
if: matrix.pkgname == 'mihomo-party-bin' || matrix.pkgname == 'mihomo-party-electron-bin'
run: |
wget https://github.com/${{ github.repository }}/releases/download/$(echo ${{ github.ref }} | tr -d 'refs/tags/')/clash-party-linux-$(echo ${{ github.ref }} | tr -d 'refs/tags/v')-amd64.deb -O amd64.deb
wget https://github.com/${{ github.repository }}/releases/download/$(echo ${{ github.ref }} | tr -d 'refs/tags/')/clash-party-linux-$(echo ${{ github.ref }} | tr -d 'refs/tags/v')-arm64.deb -O arm64.deb
wget https://github.com/mihomo-party-org/mihomo-party/releases/download/$(echo ${{ github.ref }} | tr -d 'refs/tags/')/mihomo-party-linux-$(echo ${{ github.ref }} | tr -d 'refs/tags/v')-amd64.deb -O amd64.deb
wget https://github.com/mihomo-party-org/mihomo-party/releases/download/$(echo ${{ github.ref }} | tr -d 'refs/tags/')/mihomo-party-linux-$(echo ${{ github.ref }} | tr -d 'refs/tags/v')-arm64.deb -O arm64.deb
sed -i "s/sha256sums_x86_64=.*/sha256sums_x86_64=(\"$(sha256sum ./amd64.deb | awk '{print $1}')\")/" aur/mihomo-party-bin/PKGBUILD
sed -i "s/sha256sums_aarch64=.*/sha256sums_aarch64=(\"$(sha256sum ./arm64.deb | awk '{print $1}')\")/" aur/mihomo-party-bin/PKGBUILD
sed -i "s/sha256sums_x86_64=.*/sha256sums_x86_64=(\"$(sha256sum ./amd64.deb | awk '{print $1}')\")/" aur/mihomo-party-electron-bin/PKGBUILD
@ -629,7 +396,7 @@ jobs:
if: startsWith(github.ref, 'refs/heads/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: update version
@ -661,5 +428,5 @@ jobs:
identifier: Mihomo-Party.Mihomo-Party
version: ${{env.VERSION}}
release-tag: v${{env.VERSION}}
installers-regex: 'clash-party-windows-.*setup\.exe$'
installers-regex: 'mihomo-party-windows-.*setup\.exe$'
token: ${{ secrets.POMPURIN404_TOKEN }}

3
.gitignore vendored
View File

@ -8,6 +8,3 @@ out
*.log*
.idea
*.ttf
party.md
CLAUDE.md
tsconfig.node.tsbuildinfo

5
.npmrc
View File

@ -1,6 +1,3 @@
shamefully-hoist=true
virtual-store-dir-max-length=80
public-hoist-pattern[]=*@heroui/*
only-built-dependencies[]=electron
only-built-dependencies[]=esbuild
only-built-dependencies[]=meta-json-schema
public-hoist-pattern[]=*@heroui/*

View File

@ -1,7 +1,3 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss"
]
"recommendations": ["dbaeumer.vscode-eslint"]
}

View File

@ -7,6 +7,5 @@
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"terminal.integrated.defaultProfile.windows": "PowerShell"
}
}

View File

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

View File

@ -12,20 +12,19 @@ depends=('gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core'
optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).')
install=$_pkgname.install
source=("${_pkgname}.sh")
source_x86_64=("${_pkgname}-${pkgver}-x86_64.deb::${url}/releases/download/v${pkgver}/clash-party-linux-${pkgver}-amd64.deb")
source_aarch64=("${_pkgname}-${pkgver}-aarch64.deb::${url}/releases/download/v${pkgver}/clash-party-linux-${pkgver}-arm64.deb")
sha256sums=('242609b1259e999e944b7187f4c03aacba8134a7671ff3a50e2e246b4c4eff48')
source_x86_64=("${_pkgname}-${pkgver}-x86_64.deb::${url}/releases/download/v${pkgver}/mihomo-party-linux-${pkgver}-amd64.deb")
source_aarch64=("${_pkgname}-${pkgver}-aarch64.deb::${url}/releases/download/v${pkgver}/mihomo-party-linux-${pkgver}-arm64.deb")
sha256sums=('f8049c1f26d5a92fbcebd7bebbdedbb3eab53422b21cf6127418251ccd061282')
sha256sums_x86_64=('b8d166f1134573336aaae1866d25262284b0cbabbf393684226aca0fd8d1bd83')
sha256sums_aarch64=('8cd7398b8fc1cd70d41e386af9995cbddc1043d9018391c29f056f1435712a10')
package() {
bsdtar -xf data.tar.xz -C "${pkgdir}/"
chmod +x ${pkgdir}/opt/clash-party/mihomo-party
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo-alpha
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo-smart
chmod +x ${pkgdir}/opt/mihomo-party/mihomo-party
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
install -Dm755 "${srcdir}/${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
sed -i '3s!/opt/clash-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}
}

View File

@ -9,4 +9,4 @@ if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
fi
# Launch
exec /opt/clash-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
exec /opt/mihomo-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"

View File

@ -12,11 +12,11 @@ optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayata
makedepends=('asar')
install=$_pkgname.install
source=("${_pkgname}.desktop" "${_pkgname}.sh")
source_x86_64=("${_pkgname}-${pkgver}-x86_64.deb::${url}/releases/download/v${pkgver}/clash-party-linux-${pkgver}-amd64.deb")
source_aarch64=("${_pkgname}-${pkgver}-aarch64.deb::${url}/releases/download/v${pkgver}/clash-party-linux-${pkgver}-arm64.deb")
source_x86_64=("${_pkgname}-${pkgver}-x86_64.deb::${url}/releases/download/v${pkgver}/mihomo-party-linux-${pkgver}-amd64.deb")
source_aarch64=("${_pkgname}-${pkgver}-aarch64.deb::${url}/releases/download/v${pkgver}/mihomo-party-linux-${pkgver}-arm64.deb")
sha256sums=(
"96a6250f67517493f839f964c024434dbcf784b25a73f074bb505f1521f52844"
"87fddbcd4a4cc7bda22ec4cadff0040e54395bb13184ee4688b58788c1fa7180"
"560733f0e5bd9b47ff50c849301c8a22ae17a5df26830d8c97033dfcbd392382"
)
sha256sums_x86_64=("43f8b9a5818a722cdb8e5044d2a90993274860b0da96961e1a2652169539ce39")
sha256sums_aarch64=("18574fdeb01877a629aa52ac0175335ce27c83103db4fcb2f1ad69e3e42ee10f")
@ -24,15 +24,14 @@ options=('!lto')
package() {
bsdtar -xf data.tar.xz -C $srcdir
asar extract $srcdir/opt/clash-party/resources/app.asar ${pkgdir}/opt/mihomo-party
cp -r $srcdir/opt/clash-party/resources/sidecar ${pkgdir}/opt/mihomo-party/resources/
cp -r $srcdir/opt/clash-party/resources/files ${pkgdir}/opt/mihomo-party/resources/
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo-alpha
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo-smart
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/files ${pkgdir}/opt/mihomo-party/resources/
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
install -Dm755 "${srcdir}/${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
install -Dm644 "${_pkgname}.desktop" "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
install -Dm644 "${pkgdir}/opt/clash-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"
chown -R root:root ${pkgdir}
}

View File

@ -1,10 +1,10 @@
[Desktop Entry]
Name=Clash Party
Name=Mihomo Party
Exec=mihomo-party %U
Terminal=false
Type=Application
Icon=mihomo-party
StartupWMClass=mihomo-party
MimeType=x-scheme-handler/clash;x-scheme-handler/mihomo;
Comment=Clash Party
Comment=Mihomo Party
Categories=Utility;

View File

@ -9,4 +9,4 @@ if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
fi
# Launch
exec electron /opt/clash-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
exec electron /opt/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"

View File

@ -18,13 +18,13 @@ source=(
)
sha256sums=("52d761e9432e17477acb8adb5744676df946476e0eb5210fee2b6d45f497f218"
"96a6250f67517493f839f964c024434dbcf784b25a73f074bb505f1521f52844"
"87fddbcd4a4cc7bda22ec4cadff0040e54395bb13184ee4688b58788c1fa7180"
"560733f0e5bd9b47ff50c849301c8a22ae17a5df26830d8c97033dfcbd392382"
)
options=('!lto')
prepare(){
cd $srcdir/clash-party-${pkgver}
sed -i "s/productName: Clash Party/productName: clash-party/" electron-builder.yml
cd $srcdir/${_pkgname}-${pkgver}
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
pnpm install
}
@ -37,12 +37,11 @@ package() {
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/files ${pkgdir}/opt/mihomo-party/resources/
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo-alpha
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo-smart
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
install -Dm755 "${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
install -Dm644 "${_pkgname}.desktop" "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
install -Dm644 "${pkgdir}/opt/clash-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"
chown -R root:root ${pkgdir}
}

View File

@ -1,10 +1,10 @@
[Desktop Entry]
Name=Clash Party
Name=Mihomo Party
Exec=mihomo-party %U
Terminal=false
Type=Application
Icon=mihomo-party
StartupWMClass=mihomo-party
MimeType=x-scheme-handler/clash;x-scheme-handler/mihomo;
Comment=Clash Party
Comment=Mihomo Party
Categories=Utility;

View File

@ -9,4 +9,4 @@ if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
fi
# Launch
exec electron /opt/clash-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
exec electron /opt/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"

View File

@ -12,7 +12,7 @@ optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayata
makedepends=('nodejs' 'pnpm' 'jq' 'libxcrypt-compat')
install=$_pkgname.install
source=("${_pkgname}.sh" "git+$url.git")
sha256sums=("242609b1259e999e944b7187f4c03aacba8134a7671ff3a50e2e246b4c4eff48" "SKIP")
sha256sums=("f8049c1f26d5a92fbcebd7bebbdedbb3eab53422b21cf6127418251ccd061282" "SKIP")
options=('!lto')
pkgver() {
@ -25,7 +25,7 @@ pkgver() {
prepare(){
cd $srcdir/${_pkgname}
sed -i "s/productName: Clash Party/productName: clash-party/" electron-builder.yml
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
pnpm install
}
@ -36,14 +36,13 @@ build(){
package() {
cd $srcdir/${_pkgname}/dist
bsdtar -xf clash-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}/"
chmod +x ${pkgdir}/opt/clash-party/mihomo-party
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo-alpha
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo-smart
chmod +x ${pkgdir}/opt/mihomo-party/mihomo-party
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
install -Dm755 "${srcdir}/../${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
sed -i '3s!/opt/clash-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}
}

View File

@ -9,4 +9,4 @@ if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
fi
# Launch
exec /opt/clash-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
exec /opt/mihomo-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"

View File

@ -15,30 +15,29 @@ source=(
"${pkgname}.sh"
)
sha256sums=("52d761e9432e17477acb8adb5744676df946476e0eb5210fee2b6d45f497f218"
"242609b1259e999e944b7187f4c03aacba8134a7671ff3a50e2e246b4c4eff48")
"f8049c1f26d5a92fbcebd7bebbdedbb3eab53422b21cf6127418251ccd061282")
options=('!lto')
prepare(){
cd $srcdir/clash-party-${pkgver}
sed -i "s/productName: Clash Party/productName: clash-party/" electron-builder.yml
cd $srcdir/${pkgname}-${pkgver}
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
pnpm install
}
build(){
cd $srcdir/clash-party-${pkgver}
cd $srcdir/${pkgname}-${pkgver}
pnpm build:linux deb
}
package() {
cd $srcdir/clash-party-${pkgver}/dist
bsdtar -xf clash-party-linux-${pkgver}*.deb
cd $srcdir/${pkgname}-${pkgver}/dist
bsdtar -xf mihomo-party-linux-${pkgver}*.deb
bsdtar -xf data.tar.xz -C "${pkgdir}/"
chmod +x ${pkgdir}/opt/clash-party/mihomo-party
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo-alpha
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo-smart
chmod +x ${pkgdir}/opt/mihomo-party/mihomo-party
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
install -Dm755 "${srcdir}/../${pkgname}.sh" "${pkgdir}/usr/bin/${pkgname}"
sed -i '3s!/opt/clash-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}
}

View File

@ -9,4 +9,4 @@ if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
fi
# Launch
exec /opt/clash-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
exec /opt/mihomo-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -8,7 +8,5 @@
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@ -1,22 +1,18 @@
#!/bin/bash
set -e
if type update-alternatives >/dev/null 2>&1; then
if type update-alternatives 2>/dev/null >&1; then
# Remove previous link if it doesn't use update-alternatives
if [ -L '/usr/bin/clash-party' ] && [ -e '/usr/bin/clash-party' ] && [ "$(readlink '/usr/bin/clash-party')" != '/etc/alternatives/clash-party' ]; then
rm -f '/usr/bin/clash-party'
if [ -L '/usr/bin/mihomo-party' -a -e '/usr/bin/mihomo-party' -a "`readlink '/usr/bin/mihomo-party'`" != '/etc/alternatives/mihomo-party' ]; then
rm -f '/usr/bin/mihomo-party'
fi
update-alternatives --install '/usr/bin/clash-party' 'clash-party' '/opt/clash-party/mihomo-party' 100 || ln -sf '/opt/clash-party/mihomo-party' '/usr/bin/clash-party'
update-alternatives --install '/usr/bin/mihomo-party' 'mihomo-party' '/opt/mihomo-party/mihomo-party' 100 || ln -sf '/opt/mihomo-party/mihomo-party' '/usr/bin/mihomo-party'
else
ln -sf '/opt/clash-party/mihomo-party' '/usr/bin/clash-party'
ln -sf '/opt/mihomo-party/mihomo-party' '/usr/bin/mihomo-party'
fi
chmod 4755 '/opt/clash-party/chrome-sandbox' 2>/dev/null || true
chmod +sx /opt/clash-party/resources/sidecar/mihomo 2>/dev/null || true
chmod +sx /opt/clash-party/resources/sidecar/mihomo-alpha 2>/dev/null || true
chmod +sx /opt/clash-party/resources/sidecar/mihomo-smart 2>/dev/null || 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
update-mime-database /usr/share/mime || true
@ -25,15 +21,3 @@ fi
if hash update-desktop-database 2>/dev/null; then
update-desktop-database /usr/share/applications || true
fi
# Update icon cache for GNOME/GTK environments
if hash gtk-update-icon-cache 2>/dev/null; then
for dir in /usr/share/icons/hicolor /usr/share/icons/gnome; do
[ -d "$dir" ] && gtk-update-icon-cache -f -t "$dir" 2>/dev/null || true
done
fi
# Refresh GNOME Shell icon cache
if hash update-icon-caches 2>/dev/null; then
update-icon-caches /usr/share/icons/* 2>/dev/null || true
fi

View File

@ -1,29 +0,0 @@
#!/bin/bash
case "$1" in
remove|purge|0)
if type update-alternatives >/dev/null 2>&1; then
update-alternatives --remove 'clash-party' '/opt/clash-party/mihomo-party' 2>/dev/null || true
fi
[ -L '/usr/bin/clash-party' ] && rm -f '/usr/bin/clash-party'
if hash update-mime-database 2>/dev/null; then
update-mime-database /usr/share/mime || true
fi
if hash update-desktop-database 2>/dev/null; then
update-desktop-database /usr/share/applications || true
fi
# Update icon cache
if hash gtk-update-icon-cache 2>/dev/null; then
for dir in /usr/share/icons/hicolor /usr/share/icons/gnome; do
[ -d "$dir" ] && gtk-update-icon-cache -f -t "$dir" 2>/dev/null || true
done
fi
;;
*)
# others
;;
esac

19
build/pkg-scripts/postinstall Executable file → Normal file
View File

@ -1,4 +1,4 @@
#!/bin/bash
#!/bin/sh
set -e
# 设置日志文件
@ -19,7 +19,7 @@ fi
if [[ $2 == *".app" ]]; then
APP_PATH="$2"
else
APP_PATH="$2/Clash Party.app"
APP_PATH="$2/Mihomo Party.app"
fi
HELPER_PATH="/Library/PrivilegedHelperTools/party.mihomo.helper"
@ -51,14 +51,6 @@ else
log "Warning: mihomo-alpha binary not found at $APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
fi
if [ -f "$APP_PATH/Contents/Resources/sidecar/mihomo-smart" ]; then
chown root:admin "$APP_PATH/Contents/Resources/sidecar/mihomo-smart"
chmod +s "$APP_PATH/Contents/Resources/sidecar/mihomo-smart"
log "Set permissions for mihomo-smart"
else
log "Warning: mihomo-smart binary not found at $APP_PATH/Contents/Resources/sidecar/mihomo-smart"
fi
# 复制 helper 工具
log "Installing helper tool..."
if [ -f "$APP_PATH/Contents/Resources/files/party.mihomo.helper" ]; then
@ -125,13 +117,6 @@ macos_version=$(sw_vers -productVersion)
macos_major=$(echo "$macos_version" | cut -d. -f1)
log "macOS version: $macos_version"
# 启用服务(防止安全软件禁用)
if ! launchctl enable system/party.mihomo.helper 2>/dev/null; then
log "Warning: Failed to enable service, continuing installation..."
else
log "Service enabled successfully"
fi
# 清理现有服务
log "Cleaning up existing services..."
launchctl bootout system "$LAUNCH_DAEMON" 2>/dev/null || true

4
build/pkg-scripts/preinstall Executable file → Normal file
View File

@ -1,4 +1,4 @@
#!/bin/bash
#!/bin/sh
set -e
# 检查 root 权限
@ -20,8 +20,6 @@ fi
rm -f "$HELPER_PATH"
# 清理可能存在的旧版本文件
rm -rf "/Applications/Clash Party.app"
rm -rf "/Applications/Clash\\ Party.app"
rm -rf "/Applications/Mihomo Party.app"
rm -rf "/Applications/Mihomo\\ Party.app"

View File

@ -1,83 +1,72 @@
# 1.9.2
## 1.7.7
### 新功能 (Feat)
- Mihomo 内核升级 v1.19.12
- 新增 Webdav 最大备数设置和清理逻辑
## 新功能 (Feat)
### 修复 (Fix)
- 修复 MacOS 下无法启动的问题(重置工作目录权限)
- 尝试修复不同版本 MacOS 下安装软件时候的报错Input/output error
- 部分遗漏的多国语言翻译
- 增加 Fish 和 Nushell 环境变量支持
- 增加规则统计和禁用开关
- 连接页面显示应用图标
- 增加订阅超时设置
- 增加 Windows 7 兼容构建支持
## 1.7.6
## 修复 (Fix)
**此版本修复了 1.7.5 中的几个严重 bug推荐所有人更新**
- 修复 group.all 过滤器中代理未定义导致的错误
- 修复默认延迟测试 URL 改为 HTTPS
- 修复连接图表 z-index 和缩放问题
- 修复减少动画模式下侧边栏卡片拖拽动画异常
- 修复 IME 输入法输入时字符重复问题
- 修复配置切换失败后切换队列损坏问题
- 修复取消最大化状态未保存的问题
- 优化备份配置导入处理
- 修复禁用自动滚动时日志显示未冻结的问题
- 修复订阅页面相对时间未自动刷新的问题
- 修复连接数badge遮挡下方关闭按钮点击的问题
### 修复 (Fix)
- 修复了内核1.19.8更新后gist同步失效的问题(#780)
- 部分遗漏的多国语言翻译
- MacOS 下启动Error: EACCES: permission denied
- MacOS 系统代理 bypass 不生效
- MacOS 系统代理开启时 500 报错
## 优化 (Optimize)
## 1.7.5
- 虚拟化规则编辑弹窗中的规则列表,提升渲染性能
- 优化内核启动性能
- 重构外部控制器和规则编辑器
- 优化订阅更新逻辑效率
### 新功能 (Feat)
- 增加组延迟测试时的动画
- 订阅卡片可右键点击
-
## 其他 (Chore)
### 修复 (Fix)
- 1.7.4引入的内核启动错误
- 无法手动设置内核权限
- 完善 系统代理socket 重建和检测机制
- 更新依赖
## 1.7.4
# 1.9.1
### 新功能 (Feat)
- Mihomo 内核升级 v1.19.10
- 改进 socket创建机制防止 MacOS 下系统代理开启无法找到 socket 文件的问题
- mihomo-party-helper增加更多日志以方便调试
- 改进 MacOS 下签名和公正流程
- 增加 MacOS 下 plist 权限设置
- 改进安装流程
-
## 修复 (Fix)
### 修复 (Fix)
- 修复mihomo-party-helper本地提权漏洞
- 修复 MacOS 下安装失败的问题
- 移除节点页面的滚动位置记忆,解决页面溢出的问题
- DNS hosts 设置在 useHosts 不为 true 时也会被错误应用的问题(#742)
- 当用户在 Profile 设置中修改了更新间隔并保存后,新的间隔时间不会立即生效(#671)
- 禁止选择器组件选择空值
- 修复proxy-provider
- 修复 Windows 下以管理员重启开启 TUN 时因单实例锁冲突导致的闪退问题
- 修复托盘菜单开启 TUN 时管理员重启后继续执行导致的竞态问题
- 修复关键资源文件复制失败时静默跳过导致内核启动异常的问题
## 1.7.3
**注意:如安装后为英文,请在设置中反复选择几次不同语言以写入配置文件**
# 1.9.0
## 新功能 (Feat)
- 支持隐藏不可用代理选项
- 支持禁用自动更新
- 支持交换任务栏点击行为
- 支持订阅导入时自动选择直连或代理
- 增加 WebDAV 证书忽略选项
- 增加 mrs ruleset 预览支持
- 增加认证令牌支持
- 增加详细错误提示并支持复制功能
- 托盘代理组样式支持子菜单模式
- 增加繁体中文(台湾)翻译
- 增加 HTML 检测和配置文件解析错误处理
- 将 sysproxy 迁移至 sysproxy-rs
## 修复 (Fix)
- 修复 Windows 旧 mihomo 进程导致的 EBUSY 错误
- 修复侧边栏卡片水平偏移
- macOS DNS 设置使用 helper 服务避免权限问题
- 修复首次启动时资源文件复制失败导致程序无法运行的问题
- 修复连接详情和日志无法选择的问题
- 修复 mixed-port 配置问题
- 空端口输入处理为 0
- 修复覆盖页面中缺失的占位符和错误处理
- 修复内核自重启时的竞态条件
- 修复端口值为 NaN 时的配置读写问题
- 修复 Smart Core 代理组名称替换精度问题
- 修复 profile/override 配置中 items 数组未定义导致的错误
- 修复 lite 模式下 geo 文件同步到 profile 工作目录
- 修复 Linux GNOME 桌面图标和启动器可见性问题
- 修复管理员重启时等待新进程启动
## 优化 (Optimize)
- 使用通知系统替换 alert() 弹窗
- 优化连接页面性能
### 新功能 (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,5 @@
appId: party.mihomo.app
productName: Clash Party
productName: Mihomo Party
directories:
buildResources: build
files:
@ -19,7 +19,7 @@ extraResources:
- from: './extra/'
to: ''
protocols:
name: 'Clash Party URI Scheme'
name: 'Mihomo Party URI Scheme'
schemes:
- 'clash'
- 'mihomo'
@ -27,9 +27,9 @@ win:
target:
- nsis
- 7z
artifactName: clash-party-windows-${version}-${arch}-portable.${ext}
artifactName: ${name}-windows-${version}-${arch}-portable.${ext}
nsis:
artifactName: clash-party-windows-${version}-${arch}-setup.${ext}
artifactName: ${name}-windows-${version}-${arch}-setup.${ext}
uninstallDisplayName: ${productName}
allowToChangeInstallationDirectory: true
oneClick: false
@ -39,48 +39,32 @@ mac:
target:
- pkg
entitlementsInherit: build/entitlements.mac.plist
hardenedRuntime: true
gatekeeperAssess: false
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
artifactName: clash-party-macos-${version}-${arch}.${ext}
notarize: true
artifactName: ${name}-macos-${version}-${arch}.${ext}
pkg:
allowAnywhere: false
allowCurrentUserHome: false
isRelocatable: false
background:
alignment: bottomleft
file: build/background.png
linux:
executableName: mihomo-party
icon: build/icon.png
desktop:
entry:
Name: Clash Party
GenericName: Proxy Client
Comment: A GUI client based on Mihomo
MimeType: 'x-scheme-handler/clash;x-scheme-handler/mihomo'
Keywords: proxy;clash;mihomo;vpn;
StartupWMClass: mihomo-party
Icon: mihomo-party
Name: Mihomo Party
MimeType: 'x-scheme-handler/clash;x-scheme-handler/mihomo'
target:
- deb
- rpm
maintainer: mihomo-party-org
category: Utility
artifactName: clash-party-linux-${version}-${arch}.${ext}
artifactName: ${name}-linux-${version}-${arch}.${ext}
deb:
afterInstall: 'build/linux/postinst'
afterRemove: 'build/linux/postuninst'
rpm:
afterInstall: 'build/linux/postinst'
afterRemove: 'build/linux/postuninst'
fpm:
- '--rpm-rpmbuild-define'
- '_build_id_links none'
npmRebuild: true
publish: []

View File

@ -1,7 +1,6 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://github.com/vdesjs/vite-plugin-monaco-editor/issues/21#issuecomment-1827562674
import monacoEditorPluginModule from 'vite-plugin-monaco-editor'
const isObjectWithDefaultFunction = (
@ -15,28 +14,12 @@ const monacoEditorPlugin = isObjectWithDefaultFunction(monacoEditorPluginModule)
? monacoEditorPluginModule.default
: monacoEditorPluginModule
// Win7 build: bundle all deps (Vite converts ESM→CJS), only externalize native modules
const isLegacyBuild = process.env.LEGACY_BUILD === 'true'
const legacyExternal = ['sysproxy-rs', 'electron']
export default defineConfig({
main: {
plugins: isLegacyBuild ? [] : [externalizeDepsPlugin()],
build: isLegacyBuild
? { rollupOptions: { external: legacyExternal, output: { format: 'cjs' } } }
: undefined
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: isLegacyBuild ? [] : [externalizeDepsPlugin()],
build: {
rollupOptions: {
external: isLegacyBuild ? legacyExternal : undefined,
output: {
format: 'cjs',
entryFileNames: '[name].cjs'
}
}
}
plugins: [externalizeDepsPlugin()]
},
renderer: {
build: {
@ -54,7 +37,6 @@ export default defineConfig({
},
plugins: [
react(),
tailwindcss(),
monacoEditorPlugin({
languageWorkers: ['editorWorkerService', 'typescript', 'css'],
customDistPath: (_, out) => `${out}/monacoeditorwork`,

View File

@ -1,80 +0,0 @@
const js = require('@eslint/js')
const react = require('eslint-plugin-react')
const reactHooks = require('eslint-plugin-react-hooks')
const importPlugin = require('eslint-plugin-import')
const { configs } = require('@electron-toolkit/eslint-config-ts')
module.exports = [
{
ignores: ['**/node_modules/**', '**/dist/**', '**/out/**', '**/extra/**', '**/src/native/**']
},
js.configs.recommended,
...configs.recommended,
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
react: react,
'react-hooks': reactHooks,
import: importPlugin
},
rules: {
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
// React Hooks 规则
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// Import 规则
'import/no-duplicates': 'warn',
'import/order': [
'warn',
{
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
'newlines-between': 'never'
}
],
// 代码质量
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-debugger': 'warn',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'prefer-const': 'warn'
},
settings: {
react: {
version: 'detect'
}
},
languageOptions: {
...react.configs.recommended.languageOptions
}
},
{
files: ['**/*.cjs', '**/*.mjs', '**/tailwind.config.js', '**/postcss.config.js'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/explicit-function-return-type': 'off'
}
},
{
files: ['**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn'
}
},
{
files: ['**/logger.ts'],
rules: {
'no-console': 'off'
}
}
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,119 +1,98 @@
{
"name": "mihomo-party",
"version": "1.9.2",
"description": "Clash Party",
"type": "module",
"version": "1.7.7",
"description": "Mihomo Party",
"main": "./out/main/index.js",
"author": "mihomo-party-org",
"homepage": "https://clashparty.org",
"homepage": "https://mihomo.party",
"scripts": {
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"lint:check": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"review": "pnpm run lint:check && pnpm run typecheck",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"prepare": "node scripts/prepare.mjs",
"prepare:dev": "node scripts/update-version.mjs && node scripts/prepare.mjs",
"updater": "node scripts/updater.mjs",
"checksum": "node scripts/checksum.mjs",
"copy-legacy": "node scripts/copy-legacy-artifacts.mjs",
"test-copy-legacy": "node scripts/test-copy-legacy.mjs",
"telegram": "node scripts/telegram.mjs release",
"telegram:dev": "node scripts/telegram.mjs dev",
"telegram": "node scripts/telegram.mjs",
"artifact": "node scripts/artifact.mjs",
"dev": "electron-vite dev",
"postinstall": "electron-builder install-app-deps && node node_modules/electron/install.js",
"postinstall": "electron-builder install-app-deps",
"build:win": "electron-vite build && electron-builder --publish never --win",
"build:win:dev": "pnpm run prepare:dev && electron-vite build && electron-builder --publish never --win",
"build:mac": "electron-vite build && electron-builder --publish never --mac",
"build:mac:dev": "pnpm run prepare:dev && electron-vite build && electron-builder --publish never --mac",
"build:linux": "electron-vite build && electron-builder --publish never --linux",
"build:linux:dev": "pnpm run prepare:dev && electron-vite build && electron-builder --publish never --linux"
"build:linux": "electron-vite build && electron-builder --publish never --linux"
},
"dependencies": {
"@electron-toolkit/utils": "^4.0.0",
"@types/plist": "^3.0.5",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@heroui/react": "^2.6.14",
"@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",
"axios": "^1.13.5",
"chokidar": "^5.0.0",
"croner": "^9.1.0",
"axios": "^1.7.7",
"chokidar": "^4.0.1",
"crypto-js": "^4.2.0",
"express": "^5.2.1",
"file-icon": "^6.0.0",
"file-icon-info": "^1.1.1",
"i18next": "^25.8.13",
"iconv-lite": "^0.7.2",
"js-yaml": "^4.1.1",
"plist": "^3.1.0",
"sysproxy-rs": "file:src\\native\\sysproxy",
"validator": "^13.15.26",
"webdav": "^5.9.0",
"ws": "^8.19.0",
"yaml": "^2.8.2"
"dayjs": "^1.11.13",
"express": "^5.0.1",
"i18next": "^24.2.2",
"iconv-lite": "^0.6.3",
"react-i18next": "^15.4.0",
"webdav": "^5.7.1",
"ws": "^8.18.0",
"yaml": "^2.6.0"
},
"devDependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@heroui/react": "^2.8.9",
"@tailwindcss/vite": "^4.2.1",
"@types/adm-zip": "^0.5.7",
"@types/crypto-js": "^4.2.2",
"@types/express": "^5.0.6",
"@types/node": "^25.3.0",
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
"@electron-toolkit/eslint-config-ts": "^2.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@types/adm-zip": "^0.5.6",
"@types/express": "^5.0.0",
"@types/node": "^22.13.1",
"@types/pubsub-js": "^1.8.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/validator": "^13.15.10",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"@vitejs/plugin-react": "^5.1.4",
"chart.js": "^4.5.1",
"cron-validator": "^1.4.0",
"dayjs": "^1.11.19",
"driver.js": "^1.4.0",
"electron": "37.10.0",
"electron-builder": "26.0.12",
"electron-vite": "4.0.1",
"@types/react": "^19.0.4",
"@types/react-dom": "^19.0.2",
"@types/ws": "^8.5.13",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"cron-validator": "^1.3.1",
"driver.js": "^1.3.5",
"electron": "^34.0.2",
"electron-builder": "25.1.8",
"electron-vite": "^2.3.0",
"electron-window-state": "^5.0.3",
"eslint": "9.39.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"form-data": "^4.0.5",
"framer-motion": "12.23.26",
"lodash": "^4.17.23",
"meta-json-schema": "^1.19.20",
"monaco-yaml": "^5.4.1",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"eslint": "8.57.1",
"eslint-plugin-react": "^7.37.2",
"form-data": "^4.0.1",
"framer-motion": "12.0.11",
"lodash": "^4.17.21",
"meta-json-schema": "^1.18.9",
"monaco-yaml": "^5.2.3",
"nanoid": "^5.0.8",
"next-themes": "^0.4.3",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"pubsub-js": "^1.9.5",
"react": "^19.2.4",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.4",
"react-error-boundary": "^6.1.1",
"react-i18next": "^16.5.4",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-monaco-editor": "^0.59.0",
"react-router-dom": "^7.13.1",
"react-virtuoso": "^4.18.1",
"swr": "^2.4.0",
"tailwindcss": "^4.2.1",
"tar": "^7.5.9",
"tsx": "^4.21.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-error-boundary": "^5.0.0",
"react-icons": "^5.3.0",
"react-markdown": "^9.0.1",
"react-monaco-editor": "^0.58.0",
"react-router-dom": "^7.1.5",
"react-virtuoso": "^4.12.0",
"recharts": "^2.13.3",
"swr": "^2.2.5",
"tailwindcss": "^3.4.17",
"tar": "^7.4.3",
"tsx": "^4.19.2",
"types-pac": "^1.0.3",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"typescript": "^5.6.3",
"vite": "^6.0.7",
"vite-plugin-monaco-editor": "^1.1.0"
},
"packageManager": "pnpm@10.27.0"
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c"
}

11038
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -1,7 +1,7 @@
#!/bin/bash
echo "=== Clash Party Cleanup Tool ==="
echo "This script will remove all Clash Party related files and services."
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
@ -17,8 +17,8 @@ sudo launchctl unload /Library/LaunchDaemons/party.mihomo.helper.plist 2>/dev/nu
echo "Removing files..."
sudo rm -f /Library/LaunchDaemons/party.mihomo.helper.plist
sudo rm -f /Library/PrivilegedHelperTools/party.mihomo.helper
sudo rm -rf "/Applications/Clash Party.app"
sudo rm -rf "/Applications/Clash\\ Party.app"
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

View File

@ -1,66 +0,0 @@
import { readFileSync, readdirSync, writeFileSync, copyFileSync, existsSync } from 'fs'
import { join } from 'path'
/**
* 复制打包产物并重命名为兼容旧版本的文件名
* clash-party 重命名为 mihomo-party用于更新检测兼容性
*/
const distDir = 'dist'
if (!existsSync(distDir)) {
console.log('❌ dist 目录不存在,请先执行打包命令')
process.exit(1)
}
const files = readdirSync(distDir)
console.log('📦 开始处理打包产物...')
let copiedCount = 0
for (const file of files) {
if (file.includes('clash-party') && !file.endsWith('.sha256')) {
const newFileName = file.replace('clash-party', 'mihomo-party')
const sourcePath = join(distDir, file)
const targetPath = join(distDir, newFileName)
try {
copyFileSync(sourcePath, targetPath)
console.log(`✅ 复制: ${file} -> ${newFileName}`)
copiedCount++
const sha256File = `${file}.sha256`
const sha256Path = join(distDir, sha256File)
if (existsSync(sha256Path)) {
const newSha256File = `${newFileName}.sha256`
const newSha256Path = join(distDir, newSha256File)
const sha256Content = readFileSync(sha256Path, 'utf8')
writeFileSync(newSha256Path, sha256Content)
console.log(`✅ 复制校验文件: ${sha256File} -> ${newSha256File}`)
copiedCount++
}
} catch (error) {
console.error(`❌ 复制文件失败: ${file}`, error.message)
}
}
}
if (copiedCount > 0) {
console.log(`🎉 成功复制 ${copiedCount} 个文件`)
console.log('📋 现在 dist 目录包含以下文件:')
const finalFiles = readdirSync(distDir).sort()
finalFiles.forEach((file) => {
if (file.includes('clash-party') || file.includes('mihomo-party')) {
const isLegacy = file.includes('mihomo-party')
console.log(` ${isLegacy ? '🔄' : '📦'} ${file}`)
}
})
console.log(' 📦 = 原始文件 (clash-party)')
console.log(' 🔄 = 兼容文件 (mihomo-party)')
} else {
console.log(' 没有找到需要复制的 clash-party 文件')
}

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import fs from 'fs'
import AdmZip from 'adm-zip'
import path from 'path'
@ -44,36 +45,6 @@ async function getLatestAlphaVersion() {
}
}
/* ======= mihomo smart ======= */
const MIHOMO_SMART_VERSION_URL =
'https://github.com/vernesong/mihomo/releases/download/Prerelease-Alpha/version.txt'
const MIHOMO_SMART_URL_PREFIX = `https://github.com/vernesong/mihomo/releases/download/Prerelease-Alpha`
let MIHOMO_SMART_VERSION
const MIHOMO_SMART_MAP = {
'win32-x64': 'mihomo-windows-amd64-v2-go120',
'win32-ia32': 'mihomo-windows-386-go120',
'win32-arm64': 'mihomo-windows-arm64',
'darwin-x64': 'mihomo-darwin-amd64-v2-go120',
'darwin-arm64': 'mihomo-darwin-arm64',
'linux-x64': 'mihomo-linux-amd64-v2-go120',
'linux-arm64': 'mihomo-linux-arm64'
}
async function getLatestSmartVersion() {
try {
const response = await fetch(MIHOMO_SMART_VERSION_URL, {
method: 'GET'
})
let v = await response.text()
MIHOMO_SMART_VERSION = v.trim() // Trim to remove extra whitespaces
console.log(`Latest smart version: ${MIHOMO_SMART_VERSION}`)
} catch (error) {
console.error('Error fetching latest smart version:', error.message)
process.exit(1)
}
}
/* ======= mihomo release ======= */
const MIHOMO_VERSION_URL =
'https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt'
@ -116,10 +87,6 @@ if (!MIHOMO_ALPHA_MAP[`${platform}-${arch}`]) {
throw new Error(`unsupported platform "${platform}-${arch}"`)
}
if (!MIHOMO_SMART_MAP[`${platform}-${arch}`]) {
throw new Error(`unsupported platform "${platform}-${arch}"`)
}
/**
* core info
*/
@ -156,23 +123,6 @@ function mihomo() {
downloadURL
}
}
function mihomoSmart() {
const name = MIHOMO_SMART_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz'
const downloadURL = `${MIHOMO_SMART_URL_PREFIX}/${name}-${MIHOMO_SMART_VERSION}.${urlExt}`
const exeFile = `${name}${isWin ? '.exe' : ''}`
const zipFile = `${name}-${MIHOMO_SMART_VERSION}.${urlExt}`
return {
name: 'mihomo-smart',
targetFile: `mihomo-smart${isWin ? '.exe' : ''}`,
exeFile,
zipFile,
downloadURL
}
}
/**
* download sidecar and rename
*/
@ -304,7 +254,7 @@ const resolveGeosite = () =>
const resolveGeoIP = () =>
resolveResource({
file: 'geoip.dat',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat`
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip-lite.dat`
})
const resolveASN = () =>
resolveResource({
@ -316,72 +266,16 @@ const resolveEnableLoopback = () =>
file: 'enableLoopback.exe',
downloadURL: `https://github.com/Kuingsmile/uwp-tool/releases/download/latest/enableLoopback.exe`
})
/* ======= sysproxy-rs ======= */
const SYSPROXY_RS_VERSION = 'v0.1.0'
const SYSPROXY_RS_URL_PREFIX = `https://github.com/mihomo-party-org/sysproxy-rs-opti/releases/download/${SYSPROXY_RS_VERSION}`
function getSysproxyNodeName() {
// 检测是否为 musl 系统(与 src/native/sysproxy/index.js 保持一致)
const isMusl = (() => {
if (platform !== 'linux') return false
try {
const output = execSync('ldd --version 2>&1 || true').toString()
return output.includes('musl')
} catch {
return false
}
})()
const isWin7Build = process.env.LEGACY_BUILD === 'true'
switch (platform) {
case 'win32':
if (arch === 'x64')
return isWin7Build ? 'sysproxy.win32-x64-msvc-win7.node' : 'sysproxy.win32-x64-msvc.node'
if (arch === 'arm64') return 'sysproxy.win32-arm64-msvc.node'
if (arch === 'ia32')
return isWin7Build ? 'sysproxy.win32-ia32-msvc-win7.node' : 'sysproxy.win32-ia32-msvc.node'
break
case 'darwin':
if (arch === 'x64') return 'sysproxy.darwin-x64.node'
if (arch === 'arm64') return 'sysproxy.darwin-arm64.node'
break
case 'linux':
if (isMusl) {
if (arch === 'x64') return 'sysproxy.linux-x64-musl.node'
if (arch === 'arm64') return 'sysproxy.linux-arm64-musl.node'
} else {
if (arch === 'x64') return 'sysproxy.linux-x64-gnu.node'
if (arch === 'arm64') return 'sysproxy.linux-arm64-gnu.node'
}
break
}
throw new Error(`Unsupported platform for sysproxy-rs: ${platform}-${arch}`)
}
const resolveSysproxy = async () => {
const nodeName = getSysproxyNodeName()
const sidecarDir = path.join(cwd, 'extra', 'sidecar')
const targetPath = path.join(sidecarDir, nodeName)
fs.mkdirSync(sidecarDir, { recursive: true })
// 清理其他平台的 .node 文件
const files = fs.readdirSync(sidecarDir)
for (const file of files) {
if (file.endsWith('.node') && file !== nodeName) {
fs.rmSync(path.join(sidecarDir, file))
console.log(`[INFO]: removed ${file}`)
}
}
if (fs.existsSync(targetPath)) {
fs.rmSync(targetPath)
}
await downloadFile(`${SYSPROXY_RS_URL_PREFIX}/${nodeName}`, targetPath)
console.log(`[INFO]: ${nodeName} finished`)
}
const resolveSysproxy = () =>
resolveResource({
file: 'sysproxy.exe',
downloadURL: `https://github.com/mihomo-party-org/sysproxy/releases/download/${arch}/sysproxy.exe`
})
const resolveRunner = () =>
resolveResource({
file: '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')
@ -411,7 +305,7 @@ const resolve7zip = () =>
})
const resolveSubstore = () =>
resolveResource({
file: 'sub-store.bundle.cjs',
file: 'sub-store.bundle.js',
downloadURL:
'https://github.com/sub-store-org/Sub-Store/releases/latest/download/sub-store.bundle.js'
})
@ -466,11 +360,6 @@ const tasks = [
func: () => getLatestReleaseVersion().then(() => resolveSidecar(mihomo())),
retry: 5
},
{
name: 'mihomo-smart',
func: () => getLatestSmartVersion().then(() => resolveSidecar(mihomoSmart())),
retry: 5
},
{ name: 'mmdb', func: resolveMmdb, retry: 5 },
{ name: 'metadb', func: resolveMetadb, retry: 5 },
{ name: 'geosite', func: resolveGeosite, retry: 5 },
@ -490,7 +379,14 @@ const tasks = [
{
name: 'sysproxy',
func: resolveSysproxy,
retry: 5
retry: 5,
winOnly: true
},
{
name: 'runner',
func: resolveRunner,
retry: 5,
winOnly: true
},
{
name: 'monitor',
@ -536,14 +432,7 @@ async function runTask() {
break
} catch (err) {
console.error(`[ERROR]: task::${task.name} try ${i} ==`, err.message)
if (i === task.retry - 1) {
if (task.optional) {
console.log(`[WARN]: Optional task::${task.name} failed, skipping...`)
break
} else {
throw err
}
}
if (i === task.retry - 1) throw err
}
}
return runTask()

View File

@ -1,79 +1,46 @@
import axios from 'axios'
import { readFileSync } from 'fs'
import {
getProcessedVersion,
isDevBuild,
getDownloadUrl,
generateDownloadLinksMarkdown,
getGitCommitHash
} from './version-utils.mjs'
const chat_id = '@MihomoPartyChannel'
const pkg = readFileSync('package.json', 'utf-8')
const changelog = readFileSync('changelog.md', 'utf-8')
// 获取处理后的版本号
const version = getProcessedVersion()
const releaseType = process.env.RELEASE_TYPE || process.argv[2] || 'release'
const isDevRelease = releaseType === 'dev' || isDevBuild()
function convertMarkdownToTelegramHTML(content) {
return content
.split('\n')
.map((line) => {
if (line.trim().length === 0) {
return ''
} else if (line.startsWith('## ')) {
return `<b>${line.replace('## ', '')}</b>`
} else if (line.startsWith('### ')) {
return `<b>${line.replace('### ', '')}</b>`
} else if (line.startsWith('#### ')) {
return `<b>${line.replace('#### ', '')}</b>`
} else {
let processedLine = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
const encodedUrl = encodeURI(url)
return `<a href="${encodedUrl}">${text}</a>`
})
processedLine = processedLine.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
return processedLine
}
})
.join('\n')
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`
for (const line of changelog.split('\n')) {
if (line.length === 0) {
content += '\n'
} else if (line.startsWith('### ')) {
content += `<b>${line.replace('### ', '')}</b>\n`
} else {
content += `${line}\n`
}
}
let content = ''
if (isDevRelease) {
// 版本号中提取commit hash
const shortCommitSha = getGitCommitHash(true)
const commitSha = getGitCommitHash(false)
content = `<b>🚧 <a href="https://github.com/mihomo-party-org/clash-party/releases/tag/dev">Clash Party Dev Build</a> 开发版本发布</b>\n\n`
content += `<b>基于版本:</b> ${version}\n`
content += `<b>提交哈希:</b> <a href="https://github.com/mihomo-party-org/clash-party/commit/${commitSha}">${shortCommitSha}</a>\n\n`
content += `<b>更新日志:</b>\n`
content += convertMarkdownToTelegramHTML(changelog)
content += '\n\n<b>⚠️ 注意:这是开发版本,可能存在不稳定性,仅供测试使用</b>\n'
} else {
// 正式版本通知
content = `<b>🌟 <a href="https://github.com/mihomo-party-org/clash-party/releases/tag/v${version}">Clash Party v${version}</a> 正式发布</b>\n\n`
content += convertMarkdownToTelegramHTML(changelog)
}
// 构建下载链接
const downloadUrl = getDownloadUrl(isDevRelease, version)
const downloadLinksMarkdown = generateDownloadLinksMarkdown(downloadUrl, version)
content += convertMarkdownToTelegramHTML(downloadLinksMarkdown)
content += '\n<b>下载地址:</b>\n<b>Windows10/11</b>\n'
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,
link_preview_options: {
is_disabled: false,
url: 'https://github.com/mihomo-party-org/clash-party',
url: 'https://github.com/mihomo-party-org/mihomo-party',
prefer_large_media: true
},
parse_mode: 'HTML'
})
console.log(`${isDevRelease ? '开发版本' : '正式版本'}Telegram 通知发送成功`)

View File

@ -1,31 +0,0 @@
import { readFileSync, writeFileSync } from 'fs'
import { getProcessedVersion, isDevBuild } from './version-utils.mjs'
// 更新package.json中的版本号
function updatePackageVersion() {
try {
const packagePath = 'package.json'
const packageContent = readFileSync(packagePath, 'utf-8')
const packageData = JSON.parse(packageContent)
// 获取处理后的版本号
const newVersion = getProcessedVersion()
console.log(`当前版本: ${packageData.version}`)
console.log(`${isDevBuild() ? 'Dev构建' : '正式构建'} - 新版本: ${newVersion}`)
packageData.version = newVersion
// 写回package.json
writeFileSync(packagePath, JSON.stringify(packageData, null, 2) + '\n')
console.log(`✅ package.json版本号已更新为: ${newVersion}`)
} catch (error) {
console.error('❌ 更新package.json版本号失败:', error.message)
process.exit(1)
}
}
updatePackageVersion()
export { updatePackageVersion }

View File

@ -1,29 +1,28 @@
import yaml from 'yaml'
import { readFileSync, writeFileSync } from 'fs'
import {
getProcessedVersion,
isDevBuild,
getDownloadUrl,
generateDownloadLinksMarkdown
} from './version-utils.mjs'
const pkg = readFileSync('package.json', 'utf-8')
let changelog = readFileSync('changelog.md', 'utf-8')
// 获取处理后的版本号
const version = getProcessedVersion()
const isDev = isDevBuild()
const downloadUrl = getDownloadUrl(isDev, version)
const { version } = JSON.parse(pkg)
const downloadUrl = `https://github.com/mihomo-party-org/mihomo-party/releases/download/v${version}`
const latest = {
version,
changelog
}
// 使用统一的下载链接生成函数
changelog += generateDownloadLinksMarkdown(downloadUrl, version)
changelog +=
'\n\n### 机场推荐:\n- 高性能海外机场,稳定首选:[https://狗狗加速.com](https://party.dginv.click/#/register?code=ARdo0mXx)'
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('changelog.md', changelog)

View File

@ -1,88 +0,0 @@
import { execSync } from 'child_process'
import { readFileSync } from 'fs'
// 获取Git commit hash
export function getGitCommitHash(short = true) {
try {
const command = short ? 'git rev-parse --short HEAD' : 'git rev-parse HEAD'
return execSync(command, { encoding: 'utf-8' }).trim()
} catch (error) {
console.warn('Failed to get git commit hash:', error.message)
return 'unknown'
}
}
// 获取当前月份日期
export function getCurrentMonthDate() {
const now = new Date()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${month}${day}`
}
// 从package.json读取基础版本号
export function getBaseVersion() {
try {
const pkg = readFileSync('package.json', 'utf-8')
const { version } = JSON.parse(pkg)
// 移除dev版本格式后缀
return version.replace(/-d\d{2,4}\.[a-f0-9]{7}$/, '')
} catch (error) {
console.error('Failed to read package.json:', error.message)
return '1.0.0'
}
}
// 生成dev版本号
export function getDevVersion() {
const baseVersion = getBaseVersion()
const monthDate = getCurrentMonthDate()
const commitHash = getGitCommitHash(true)
return `${baseVersion}-d${monthDate}.${commitHash}`
}
// 检查当前环境是否为dev构建
export function isDevBuild() {
return (
process.env.NODE_ENV === 'development' ||
process.argv.includes('--dev') ||
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch'
)
}
// 获取处理后的版本号
export function getProcessedVersion() {
if (isDevBuild()) {
return getDevVersion()
} else {
return getBaseVersion()
}
}
// 生成下载URL
export function getDownloadUrl(isDev, version) {
if (isDev) {
return 'https://github.com/mihomo-party-org/clash-party/releases/download/dev'
} else {
return `https://github.com/mihomo-party-org/clash-party/releases/download/v${version}`
}
}
export function generateDownloadLinksMarkdown(downloadUrl, version) {
let links = '\n### 下载地址:\n\n#### Windows10/11\n\n'
links += `- 安装版:[64位](${downloadUrl}/clash-party-windows-${version}-x64-setup.exe) | [32位](${downloadUrl}/clash-party-windows-${version}-ia32-setup.exe) | [ARM64](${downloadUrl}/clash-party-windows-${version}-arm64-setup.exe)\n\n`
links += `- 便携版:[64位](${downloadUrl}/clash-party-windows-${version}-x64-portable.7z) | [32位](${downloadUrl}/clash-party-windows-${version}-ia32-portable.7z) | [ARM64](${downloadUrl}/clash-party-windows-${version}-arm64-portable.7z)\n\n`
links += '\n#### Windows7/8\n\n'
links += `- 安装版:[64位](${downloadUrl}/clash-party-win7-${version}-x64-setup.exe) | [32位](${downloadUrl}/clash-party-win7-${version}-ia32-setup.exe)\n\n`
links += `- 便携版:[64位](${downloadUrl}/clash-party-win7-${version}-x64-portable.7z) | [32位](${downloadUrl}/clash-party-win7-${version}-ia32-portable.7z)\n\n`
links += '\n#### macOS 11+\n\n'
links += `- PKG[Intel](${downloadUrl}/clash-party-macos-${version}-x64.pkg) | [Apple Silicon](${downloadUrl}/clash-party-macos-${version}-arm64.pkg)\n\n`
links += '\n#### macOS 10.15+\n\n'
links += `- PKG[Intel](${downloadUrl}/clash-party-catalina-${version}-x64.pkg) | [Apple Silicon](${downloadUrl}/clash-party-catalina-${version}-arm64.pkg)\n\n`
links += '\n#### Linux\n\n'
links += `- DEB[64位](${downloadUrl}/clash-party-linux-${version}-amd64.deb) | [ARM64](${downloadUrl}/clash-party-linux-${version}-arm64.deb)\n\n`
links += `- RPM[64位](${downloadUrl}/clash-party-linux-${version}-x86_64.rpm) | [ARM64](${downloadUrl}/clash-party-linux-${version}-aarch64.rpm)`
return links
}

View File

@ -1,36 +1,24 @@
import { readFile, writeFile } from 'fs/promises'
import { appConfigPath } from '../utils/dirs'
import { parse, stringify } from '../utils/yaml'
import yaml from 'yaml'
import { deepMerge } from '../utils/merge'
import { defaultConfig } from '../utils/template'
let appConfig: IAppConfig // config.yaml
let appConfigWriteQueue: Promise<void> = Promise.resolve()
export async function getAppConfig(force = false): Promise<IAppConfig> {
if (force || !appConfig) {
appConfigWriteQueue = appConfigWriteQueue.then(async () => {
const data = await readFile(appConfigPath(), 'utf-8')
const parsedConfig = parse(data)
const mergedConfig = deepMerge({ ...defaultConfig }, parsedConfig || {})
if (JSON.stringify(mergedConfig) !== JSON.stringify(parsedConfig)) {
await writeFile(appConfigPath(), stringify(mergedConfig))
}
appConfig = mergedConfig
})
await appConfigWriteQueue
const data = await readFile(appConfigPath(), 'utf-8')
appConfig = yaml.parse(data, { merge: true }) || defaultConfig
}
if (typeof appConfig !== 'object') appConfig = defaultConfig
return appConfig
}
export async function patchAppConfig(patch: Partial<IAppConfig>): Promise<void> {
appConfigWriteQueue = appConfigWriteQueue.then(async () => {
if (patch.nameserverPolicy) {
appConfig.nameserverPolicy = patch.nameserverPolicy
}
appConfig = deepMerge(appConfig, patch)
await writeFile(appConfigPath(), stringify(appConfig))
})
await appConfigWriteQueue
if (patch.nameserverPolicy) {
appConfig.nameserverPolicy = patch.nameserverPolicy
}
appConfig = deepMerge(appConfig, patch)
await writeFile(appConfigPath(), yaml.stringify(appConfig))
}

View File

@ -1,49 +1,17 @@
import { readFile, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { controledMihomoConfigPath } from '../utils/dirs'
import { parse, stringify } from '../utils/yaml'
import { readFile, writeFile } from 'fs/promises'
import yaml from 'yaml'
import { generateProfile } from '../core/factory'
import { getAppConfig } from './app'
import { defaultControledMihomoConfig } from '../utils/template'
import { deepMerge } from '../utils/merge'
import { createLogger } from '../utils/logger'
import { getAppConfig } from './app'
const controledMihomoLogger = createLogger('ControledMihomo')
let controledMihomoConfig: Partial<IMihomoConfig> // mihomo.yaml
let controledMihomoWriteQueue: Promise<void> = Promise.resolve()
export async function getControledMihomoConfig(force = false): Promise<Partial<IMihomoConfig>> {
if (force || !controledMihomoConfig) {
if (existsSync(controledMihomoConfigPath())) {
const data = await readFile(controledMihomoConfigPath(), 'utf-8')
controledMihomoConfig = parse(data) || defaultControledMihomoConfig
} else {
controledMihomoConfig = defaultControledMihomoConfig
try {
await writeFile(
controledMihomoConfigPath(),
stringify(defaultControledMihomoConfig),
'utf-8'
)
} catch (error) {
controledMihomoLogger.error('Failed to create mihomo.yaml file', error)
}
}
// 确保配置包含所有必要的默认字段,处理升级场景
controledMihomoConfig = deepMerge(defaultControledMihomoConfig, controledMihomoConfig)
// 清理端口字段中的 NaN 值,恢复为默认值
const portFields = ['mixed-port', 'socks-port', 'port', 'redir-port', 'tproxy-port'] as const
for (const field of portFields) {
if (
typeof controledMihomoConfig[field] !== 'number' ||
Number.isNaN(controledMihomoConfig[field])
) {
controledMihomoConfig[field] = defaultControledMihomoConfig[field]
}
}
const data = await readFile(controledMihomoConfigPath(), 'utf-8')
controledMihomoConfig = yaml.parse(data, { merge: true }) || defaultControledMihomoConfig
}
if (typeof controledMihomoConfig !== 'object')
controledMihomoConfig = defaultControledMihomoConfig
@ -51,40 +19,38 @@ export async function getControledMihomoConfig(force = false): Promise<Partial<I
}
export async function patchControledMihomoConfig(patch: Partial<IMihomoConfig>): Promise<void> {
controledMihomoWriteQueue = controledMihomoWriteQueue.then(async () => {
const { controlDns = true, controlSniff = true } = await getAppConfig()
// 过滤端口字段中的 NaN 值,防止写入无效配置
const portFields = ['mixed-port', 'socks-port', 'port', 'redir-port', 'tproxy-port'] as const
for (const field of portFields) {
if (field in patch && (typeof patch[field] !== 'number' || Number.isNaN(patch[field]))) {
delete patch[field]
}
}
if (patch.hosts) {
controledMihomoConfig.hosts = patch.hosts
}
if (patch.dns?.['nameserver-policy']) {
controledMihomoConfig.dns = controledMihomoConfig.dns || {}
controledMihomoConfig.dns['nameserver-policy'] = patch.dns['nameserver-policy']
}
controledMihomoConfig = deepMerge(controledMihomoConfig, patch)
const { useNameserverPolicy, controlDns = true, controlSniff = true } = await getAppConfig()
if (!controlDns) {
delete controledMihomoConfig.dns
delete controledMihomoConfig.hosts
} else {
// 从不接管状态恢复
if (controlDns) {
// 确保 DNS 配置包含所有必要的默认字段,特别是新增的 fallback 等
controledMihomoConfig.dns = deepMerge(
defaultControledMihomoConfig.dns || {},
controledMihomoConfig.dns || {}
)
if (controledMihomoConfig.dns?.ipv6 === undefined) {
controledMihomoConfig.dns = defaultControledMihomoConfig.dns
}
if (controlSniff && !controledMihomoConfig.sniffer) {
}
if (!controlSniff) {
delete controledMihomoConfig.sniffer
} else {
// 从不接管状态恢复
if (!controledMihomoConfig.sniffer) {
controledMihomoConfig.sniffer = defaultControledMihomoConfig.sniffer
}
await generateProfile()
await writeFile(controledMihomoConfigPath(), stringify(controledMihomoConfig), 'utf-8')
})
await controledMihomoWriteQueue
}
if (patch.hosts) {
controledMihomoConfig.hosts = patch.hosts
}
if (patch.dns?.['nameserver-policy']) {
controledMihomoConfig.dns = controledMihomoConfig.dns || {}
controledMihomoConfig.dns['nameserver-policy'] = patch.dns['nameserver-policy']
}
controledMihomoConfig = deepMerge(controledMihomoConfig, patch)
if (!useNameserverPolicy) {
delete controledMihomoConfig?.dns?.['nameserver-policy']
}
if (process.platform === 'darwin') {
delete controledMihomoConfig?.tun?.device
}
await generateProfile()
await writeFile(controledMihomoConfigPath(), yaml.stringify(controledMihomoConfig), 'utf-8')
}

View File

@ -14,8 +14,7 @@ export {
getProfileStr,
setProfileStr,
changeCurrentProfile,
updateProfileItem,
convertMrsRuleset
updateProfileItem
} from './profile'
export {
getOverrideConfig,
@ -28,9 +27,3 @@ export {
setOverride,
updateOverrideItem
} from './override'
export {
createSmartOverride,
removeSmartOverride,
manageSmartOverride,
isSmartOverrideExists
} from './smartOverride'

View File

@ -1,29 +1,24 @@
import { overrideConfigPath, overridePath } from '../utils/dirs'
import { getControledMihomoConfig } from './controledMihomo'
import { readFile, writeFile, rm } from 'fs/promises'
import { existsSync } from 'fs'
import { overrideConfigPath, overridePath } from '../utils/dirs'
import * as chromeRequest from '../utils/chromeRequest'
import { parse, stringify } from '../utils/yaml'
import { getControledMihomoConfig } from './controledMihomo'
import axios from 'axios'
import yaml from 'yaml'
let overrideConfig: IOverrideConfig // override.yaml
let overrideConfigWriteQueue: Promise<void> = Promise.resolve()
export async function getOverrideConfig(force = false): Promise<IOverrideConfig> {
if (force || !overrideConfig) {
const data = await readFile(overrideConfigPath(), 'utf-8')
overrideConfig = parse(data) || { items: [] }
overrideConfig = yaml.parse(data, { merge: true }) || { items: [] }
}
if (typeof overrideConfig !== 'object') overrideConfig = { items: [] }
if (!Array.isArray(overrideConfig.items)) overrideConfig.items = []
return overrideConfig
}
export async function setOverrideConfig(config: IOverrideConfig): Promise<void> {
overrideConfigWriteQueue = overrideConfigWriteQueue.then(async () => {
overrideConfig = config
await writeFile(overrideConfigPath(), stringify(overrideConfig), 'utf-8')
})
await overrideConfigWriteQueue
overrideConfig = config
await writeFile(overrideConfigPath(), yaml.stringify(overrideConfig), 'utf-8')
}
export async function getOverrideItem(id: string | undefined): Promise<IOverrideItem | undefined> {
@ -45,22 +40,19 @@ export async function addOverrideItem(item: Partial<IOverrideItem>): Promise<voi
const config = await getOverrideConfig()
const newItem = await createOverride(item)
if (await getOverrideItem(item.id)) {
await updateOverrideItem(newItem)
updateOverrideItem(newItem)
} else {
config.items.push(newItem)
await setOverrideConfig(config)
}
await setOverrideConfig(config)
}
export async function removeOverrideItem(id: string): Promise<void> {
const config = await getOverrideConfig()
const item = await getOverrideItem(id)
if (!item) return
config.items = config.items?.filter((i) => i.id !== id)
config.items = config.items?.filter((item) => item.id !== id)
await setOverrideConfig(config)
if (existsSync(overridePath(id, item.ext))) {
await rm(overridePath(id, item.ext))
}
await rm(overridePath(id, item?.ext || 'js'))
}
export async function createOverride(item: Partial<IOverrideItem>): Promise<IOverrideItem> {
@ -78,7 +70,7 @@ export async function createOverride(item: Partial<IOverrideItem>): Promise<IOve
case 'remote': {
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
if (!item.url) throw new Error('Empty URL')
const res = await chromeRequest.get(item.url, {
const res = await axios.get(item.url, {
proxy: {
protocol: 'http',
host: '127.0.0.1',
@ -86,13 +78,13 @@ export async function createOverride(item: Partial<IOverrideItem>): Promise<IOve
},
responseType: 'text'
})
const data = res.data as string
const data = res.data
await setOverride(id, newItem.ext, data)
break
}
case 'local': {
const data = item.file || ''
await setOverride(id, newItem.ext, data)
setOverride(id, newItem.ext, data)
break
}
}

View File

@ -1,144 +1,93 @@
import { getControledMihomoConfig } from './controledMihomo'
import { mihomoProfileWorkDir, mihomoWorkDir, profileConfigPath, profilePath } from '../utils/dirs'
import { addProfileUpdater } from '../core/profileUpdater'
import { readFile, rm, writeFile } from 'fs/promises'
import { restartCore } from '../core/manager'
import { getAppConfig } from './app'
import { existsSync } from 'fs'
import { join } from 'path'
import { app } from 'electron'
import i18next from 'i18next'
import * as chromeRequest from '../utils/chromeRequest'
import { parse, stringify } from '../utils/yaml'
import axios, { AxiosResponse } from 'axios'
import yaml from 'yaml'
import { defaultProfile } from '../utils/template'
import { subStorePort } from '../resolve/server'
import { mihomoUpgradeConfig } from '../core/mihomoApi'
import { restartCore } from '../core/manager'
import { addProfileUpdater, removeProfileUpdater } from '../core/profileUpdater'
import { mihomoProfileWorkDir, mihomoWorkDir, profileConfigPath, profilePath } from '../utils/dirs'
import { createLogger } from '../utils/logger'
import { getAppConfig } from './app'
import { getControledMihomoConfig } from './controledMihomo'
import { join } from 'path'
import { app } from 'electron'
const profileLogger = createLogger('Profile')
let profileConfig: IProfileConfig
let profileConfigWriteQueue: Promise<void> = Promise.resolve()
let changeProfileQueue: Promise<void> = Promise.resolve()
let profileConfig: IProfileConfig // profile.yaml
export async function getProfileConfig(force = false): Promise<IProfileConfig> {
if (force || !profileConfig) {
const data = await readFile(profileConfigPath(), 'utf-8')
profileConfig = parse(data) || { items: [] }
profileConfig = yaml.parse(data, { merge: true }) || { items: [] }
}
if (typeof profileConfig !== 'object') profileConfig = { items: [] }
if (!Array.isArray(profileConfig.items)) profileConfig.items = []
return structuredClone(profileConfig)
return profileConfig
}
export async function setProfileConfig(config: IProfileConfig): Promise<void> {
profileConfigWriteQueue = profileConfigWriteQueue.then(async () => {
profileConfig = config
await writeFile(profileConfigPath(), stringify(config), 'utf-8')
})
await profileConfigWriteQueue
}
export async function updateProfileConfig(
updater: (config: IProfileConfig) => IProfileConfig | Promise<IProfileConfig>
): Promise<IProfileConfig> {
let result: IProfileConfig | undefined
profileConfigWriteQueue = profileConfigWriteQueue.then(async () => {
const data = await readFile(profileConfigPath(), 'utf-8')
profileConfig = parse(data) || { items: [] }
if (typeof profileConfig !== 'object') profileConfig = { items: [] }
if (!Array.isArray(profileConfig.items)) profileConfig.items = []
profileConfig = await updater(structuredClone(profileConfig))
result = profileConfig
await writeFile(profileConfigPath(), stringify(profileConfig), 'utf-8')
})
await profileConfigWriteQueue
return structuredClone(result ?? profileConfig)
profileConfig = config
await writeFile(profileConfigPath(), yaml.stringify(config), 'utf-8')
}
export async function getProfileItem(id: string | undefined): Promise<IProfileItem | undefined> {
const { items } = await getProfileConfig()
if (!id || id === 'default')
return { id: 'default', type: 'local', name: i18next.t('profiles.emptyProfile') }
if (!id || id === 'default') return { id: 'default', type: 'local', name: '空白订阅' }
return items.find((item) => item.id === id)
}
export async function changeCurrentProfile(id: string): Promise<void> {
// 使用队列确保 profile 切换串行执行,避免竞态条件
let taskError: unknown = null
changeProfileQueue = changeProfileQueue
.catch(() => {})
.then(async () => {
const { current } = await getProfileConfig()
if (current === id) return
try {
await updateProfileConfig((config) => {
config.current = id
return config
})
await restartCore()
} catch (e) {
// 回滚配置
await updateProfileConfig((config) => {
config.current = current
return config
})
taskError = e
}
})
await changeProfileQueue
if (taskError) {
throw taskError
const config = await getProfileConfig()
const current = config.current
config.current = id
await setProfileConfig(config)
try {
await restartCore()
} catch (e) {
config.current = current
throw e
} finally {
await setProfileConfig(config)
}
}
export async function updateProfileItem(item: IProfileItem): Promise<void> {
await updateProfileConfig((config) => {
const index = config.items.findIndex((i) => i.id === item.id)
if (index === -1) {
throw new Error('Profile not found')
}
config.items[index] = item
return config
})
const config = await getProfileConfig()
const index = config.items.findIndex((i) => i.id === item.id)
if (index === -1) {
throw new Error('Profile not found')
}
config.items[index] = item
await setProfileConfig(config)
}
export async function addProfileItem(item: Partial<IProfileItem>): Promise<void> {
const newItem = await createProfile(item)
let shouldChangeCurrent = false
await updateProfileConfig((config) => {
const existingIndex = config.items.findIndex((i) => i.id === newItem.id)
if (existingIndex !== -1) {
config.items[existingIndex] = newItem
} else {
config.items.push(newItem)
}
if (!config.current) {
shouldChangeCurrent = true
}
return config
})
const config = await getProfileConfig()
if (await getProfileItem(newItem.id)) {
await updateProfileItem(newItem)
} else {
config.items.push(newItem)
}
await setProfileConfig(config)
if (shouldChangeCurrent) {
if (!config.current) {
await changeCurrentProfile(newItem.id)
}
await addProfileUpdater(newItem)
}
export async function removeProfileItem(id: string): Promise<void> {
await removeProfileUpdater(id)
const config = await getProfileConfig()
config.items = config.items?.filter((item) => item.id !== id)
let shouldRestart = false
await updateProfileConfig((config) => {
config.items = config.items?.filter((item) => item.id !== id)
if (config.current === id) {
shouldRestart = true
config.current = config.items.length > 0 ? config.items[0].id : undefined
if (config.current === id) {
shouldRestart = true
if (config.items.length > 0) {
config.current = config.items[0].id
} else {
config.current = undefined
}
return config
})
}
await setProfileConfig(config)
if (existsSync(profilePath(id))) {
await rm(profilePath(id))
}
@ -152,143 +101,85 @@ export async function removeProfileItem(id: string): Promise<void> {
export async function getCurrentProfileItem(): Promise<IProfileItem> {
const { current } = await getProfileConfig()
return (
(await getProfileItem(current)) || {
id: 'default',
type: 'local',
name: i18next.t('profiles.emptyProfile')
}
)
}
interface FetchOptions {
url: string
useProxy: boolean
mixedPort: number
userAgent: string
authToken?: string
timeout: number
substore: boolean
}
interface FetchResult {
data: string
headers: Record<string, string>
}
async function fetchAndValidateSubscription(options: FetchOptions): Promise<FetchResult> {
const { url, useProxy, mixedPort, userAgent, authToken, timeout, substore } = options
const headers: Record<string, string> = { 'User-Agent': userAgent }
if (authToken) headers['Authorization'] = authToken
let res: chromeRequest.Response<string>
if (substore) {
const urlObj = new URL(`http://127.0.0.1:${subStorePort}${url}`)
urlObj.searchParams.set('target', 'ClashMeta')
urlObj.searchParams.set('noCache', 'true')
if (useProxy) {
urlObj.searchParams.set('proxy', `http://127.0.0.1:${mixedPort}`)
}
res = await chromeRequest.get(urlObj.toString(), { headers, responseType: 'text', timeout })
} else {
res = await chromeRequest.get(url, {
headers,
responseType: 'text',
timeout,
proxy: useProxy ? { protocol: 'http', host: '127.0.0.1', port: mixedPort } : false
})
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`Subscription failed: Request status code ${res.status}`)
}
const parsed = parse(res.data) as Record<string, unknown> | null
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('Subscription failed: Profile is not a valid YAML')
}
if (!parsed['proxies'] && !parsed['proxy-providers']) {
throw new Error('Subscription failed: Profile missing proxies or providers')
}
return { data: res.data, headers: res.headers }
return (await getProfileItem(current)) || { id: 'default', type: 'local', name: '空白订阅' }
}
export async function createProfile(item: Partial<IProfileItem>): Promise<IProfileItem> {
const id = item.id || new Date().getTime().toString(16)
const newItem: IProfileItem = {
const newItem = {
id,
name: item.name || (item.type === 'remote' ? 'Remote File' : 'Local File'),
type: item.type || 'local',
type: item.type,
url: item.url,
substore: item.substore || false,
interval: item.interval || 0,
override: item.override || [],
useProxy: item.useProxy || false,
allowFixedInterval: item.allowFixedInterval || false,
autoUpdate: item.autoUpdate ?? false,
authToken: item.authToken,
updated: new Date().getTime(),
updateTimeout: item.updateTimeout || 5
}
// Local
if (newItem.type === 'local') {
await setProfileStr(id, item.file || '')
return newItem
}
// Remote
if (!item.url) throw new Error('Empty URL')
const { userAgent, subscriptionTimeout = 30000 } = await getAppConfig()
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
const userItemTimeoutMs = (newItem.updateTimeout || 5) * 1000
const baseOptions: Omit<FetchOptions, 'useProxy' | 'timeout'> = {
url: item.url,
mixedPort,
userAgent: userAgent || `mihomo.party/v${app.getVersion()} (clash.meta)`,
authToken: item.authToken,
substore: newItem.substore || false
}
const fetchSub = (useProxy: boolean, timeout: number) =>
fetchAndValidateSubscription({ ...baseOptions, useProxy, timeout })
let result: FetchResult
if (newItem.useProxy || newItem.substore) {
result = await fetchSub(Boolean(newItem.useProxy), userItemTimeoutMs)
} else {
try {
result = await fetchSub(false, userItemTimeoutMs)
} catch (directError) {
try {
// smart fallback
result = await fetchSub(true, subscriptionTimeout)
} catch {
throw directError
updated: new Date().getTime()
} as IProfileItem
switch (newItem.type) {
case 'remote': {
const { userAgent } = await getAppConfig()
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
if (!item.url) throw new Error('Empty URL')
let res: AxiosResponse
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
? {
protocol: 'http',
host: '127.0.0.1',
port: mixedPort
}
: false,
headers: {
'User-Agent': userAgent || `mihomo.party/v${app.getVersion()} (clash.meta)`
},
responseType: 'text'
})
}
const data = res.data
const headers = res.headers
if (headers['content-disposition'] && newItem.name === 'Remote File') {
newItem.name = parseFilename(headers['content-disposition'])
}
if (headers['profile-web-page-url']) {
newItem.home = headers['profile-web-page-url']
}
if (headers['profile-update-interval']) {
if (!item.allowFixedInterval) {
newItem.interval = parseInt(headers['profile-update-interval']) * 60
}
}
if (headers['subscription-userinfo']) {
newItem.extra = parseSubinfo(headers['subscription-userinfo'])
}
await setProfileStr(id, data)
break
}
case 'local': {
const data = item.file || ''
await setProfileStr(id, data)
break
}
}
const { data, headers } = result
if (headers['content-disposition'] && newItem.name === 'Remote File') {
newItem.name = parseFilename(headers['content-disposition'])
}
if (headers['profile-web-page-url']) {
newItem.home = headers['profile-web-page-url']
}
if (headers['profile-update-interval'] && !item.allowFixedInterval) {
newItem.interval = parseInt(headers['profile-update-interval']) * 60
}
if (headers['subscription-userinfo']) {
newItem.extra = parseSubinfo(headers['subscription-userinfo'])
}
await setProfileStr(id, data)
return newItem
}
@ -296,79 +187,37 @@ export async function getProfileStr(id: string | undefined): Promise<string> {
if (existsSync(profilePath(id || 'default'))) {
return await readFile(profilePath(id || 'default'), 'utf-8')
} else {
return stringify(defaultProfile)
return yaml.stringify(defaultProfile)
}
}
export async function setProfileStr(id: string, content: string): Promise<void> {
// 读取最新的配置
const { current } = await getProfileConfig(true)
const { current } = await getProfileConfig()
await writeFile(profilePath(id), content, 'utf-8')
if (current === id) {
try {
const { generateProfile } = await import('../core/factory')
await generateProfile()
await mihomoUpgradeConfig()
profileLogger.info('Config reloaded successfully using mihomoUpgradeConfig')
} catch (error) {
profileLogger.error('Failed to reload config with mihomoUpgradeConfig', error)
try {
profileLogger.info('Falling back to restart core')
const { restartCore } = await import('../core/manager')
await restartCore()
profileLogger.info('Core restarted successfully')
} catch (restartError) {
profileLogger.error('Failed to restart core', restartError)
throw restartError
}
}
}
if (current === id) await restartCore()
}
export async function getProfile(id: string | undefined): Promise<IMihomoConfig> {
const profile = await getProfileStr(id)
// 检测是否为 HTML 内容(订阅返回错误页面)
const trimmed = profile.trim()
if (
trimmed.startsWith('<!DOCTYPE') ||
trimmed.startsWith('<html') ||
trimmed.startsWith('<HTML') ||
/<style[^>]*>/i.test(trimmed.slice(0, 500))
) {
throw new Error(
`Profile "${id}" contains HTML instead of YAML. The subscription may have returned an error page. Please re-import or update the subscription.`
)
}
try {
let result = parse(profile)
if (typeof result !== 'object') result = {}
return result as IMihomoConfig
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
throw new Error(`Failed to parse profile "${id}": ${msg}`)
}
let result = yaml.parse(profile, { merge: true }) || {}
if (typeof result !== 'object') result = {}
return result
}
// attachment;filename=xxx.yaml; filename*=UTF-8''%xx%xx%xx
function parseFilename(str: string): string {
if (str.match(/filename\*=.*''/)) {
const parts = str.split(/filename\*=.*''/)
if (parts[1]) {
return decodeURIComponent(parts[1])
}
const filename = decodeURIComponent(str.split(/filename\*=.*''/)[1])
return filename
} else {
const filename = str.split('filename=')[1]
return filename
}
const parts = str.split('filename=')
if (parts[1]) {
return parts[1].replace(/^["']|["']$/g, '')
}
return 'Remote File'
}
// subscription-userinfo: upload=1234; download=2234; total=1024000; expire=2218532293
function parseSubinfo(str: string): ISubscriptionUserInfo {
const parts = str.split(/\s*;\s*/)
const parts = str.split('; ')
const obj = {} as ISubscriptionUserInfo
parts.forEach((part) => {
const [key, value] = part.split('=')
@ -407,45 +256,3 @@ export async function setFileStr(path: string, content: string): Promise<void> {
)
}
}
export async function convertMrsRuleset(filePath: string, behavior: string): Promise<string> {
const { exec } = await import('child_process')
const { promisify } = await import('util')
const execAsync = promisify(exec)
const { mihomoCorePath } = await import('../utils/dirs')
const { getAppConfig } = await import('./app')
const { tmpdir } = await import('os')
const { randomBytes } = await import('crypto')
const { unlink } = await import('fs/promises')
const { core = 'mihomo' } = await getAppConfig()
const corePath = mihomoCorePath(core)
const { diffWorkDir = false } = await getAppConfig()
const { current } = await getProfileConfig()
let fullPath: string
if (isAbsolutePath(filePath)) {
fullPath = filePath
} else {
fullPath = join(diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), filePath)
}
const tempFileName = `mrs-convert-${randomBytes(8).toString('hex')}.txt`
const tempFilePath = join(tmpdir(), tempFileName)
try {
// 使用 mihomo convert-ruleset 命令转换 MRS 文件为 text 格式
// 命令格式: mihomo convert-ruleset <behavior> <format> <source>
await execAsync(`"${corePath}" convert-ruleset ${behavior} mrs "${fullPath}" "${tempFilePath}"`)
const content = await readFile(tempFilePath, 'utf-8')
await unlink(tempFilePath)
return content
} catch (error) {
try {
await unlink(tempFilePath)
} catch {
// ignore
}
throw error
}
}

View File

@ -1,423 +0,0 @@
import { overrideLogger } from '../utils/logger'
import { getAppConfig } from './app'
import { addOverrideItem, removeOverrideItem, getOverrideItem } from './override'
const SMART_OVERRIDE_ID = 'smart-core-override'
/**
* Smart
*/
function generateSmartOverrideTemplate(
useLightGBM: boolean,
collectData: boolean,
strategy: string,
collectorSize: number
): string {
return `
// 配置会在启用 Smart 内核时自动应用
function main(config) {
try {
// 确保配置对象存在
if (!config || typeof config !== 'object') {
console.log('[Smart Override] Invalid config object')
return config
}
// 设置 Smart 内核的 profile 配置
if (!config.profile) {
config.profile = {}
}
config.profile['smart-collector-size'] = ${collectorSize}
// 确保代理组配置存在
if (!config['proxy-groups']) {
config['proxy-groups'] = []
}
// 确保代理组是数组
if (!Array.isArray(config['proxy-groups'])) {
console.log('[Smart Override] proxy-groups is not an array, converting...')
config['proxy-groups'] = []
}
// 首先检查是否存在 url-test 或 load-balance 代理组
let hasUrlTestOrLoadBalance = false
for (let i = 0; i < config['proxy-groups'].length; i++) {
const group = config['proxy-groups'][i]
if (group && group.type) {
const groupType = group.type.toLowerCase()
if (groupType === 'url-test' || groupType === 'load-balance') {
hasUrlTestOrLoadBalance = true
break
}
}
}
// 如果存在 url-test 或 load-balance 代理组,只进行类型转换
if (hasUrlTestOrLoadBalance) {
console.log('[Smart Override] Found url-test or load-balance groups, converting to smart type')
// 记录需要更新引用的代理组名称映射
const nameMapping = new Map()
for (let i = 0; i < config['proxy-groups'].length; i++) {
const group = config['proxy-groups'][i]
if (group && group.type) {
const groupType = group.type.toLowerCase()
if (groupType === 'url-test' || groupType === 'load-balance') {
console.log('[Smart Override] Converting group:', group.name, 'from', group.type, 'to smart')
// 记录原名称和新名称的映射关系
const originalName = group.name
// 保留原有配置,只修改 type 和添加 Smart 特有配置
group.type = 'smart'
// 为代理组名称添加 (Smart Group) 后缀
if (group.name && !group.name.includes('(Smart Group)')) {
group.name = group.name + '(Smart Group)'
nameMapping.set(originalName, group.name)
}
// 添加 Smart 特有配置
if (!group['policy-priority']) {
group['policy-priority'] = '' // policy-priority: <1 means lower priority, >1 means higher priority, the default is 1, pattern support regex and string
}
group.uselightgbm = ${useLightGBM}
group.collectdata = ${collectData}
group.strategy = '${strategy}'
// 移除 url-test 和 load-balance 特有的配置
if (group.url) delete group.url
if (group.interval) delete group.interval
if (group.tolerance) delete group.tolerance
if (group.lazy) delete group.lazy
if (group.expected_status) delete group['expected-status']
}
}
}
// 更新配置文件中其他位置对代理组名称的引用
if (nameMapping.size > 0) {
console.log('[Smart Override] Updating references to renamed groups:', Array.from(nameMapping.entries()))
// 更新代理组中的 proxies 字段引用
if (config['proxy-groups'] && Array.isArray(config['proxy-groups'])) {
config['proxy-groups'].forEach(group => {
if (group && group.proxies && Array.isArray(group.proxies)) {
group.proxies = group.proxies.map(proxyName => {
if (nameMapping.has(proxyName)) {
console.log('[Smart Override] Updated proxy reference:', proxyName, '→', nameMapping.get(proxyName))
return nameMapping.get(proxyName)
}
return proxyName
})
}
})
}
// 更新规则中的代理组引用
// 规则参数列表,这些不是策略组名称
const ruleParamsSet = new Set(['no-resolve', 'force-remote-dns', 'prefer-ipv6'])
if (config.rules && Array.isArray(config.rules)) {
config.rules = config.rules.map(rule => {
if (typeof rule === 'string') {
// 按逗号分割规则,精确匹配策略组名称位置
const parts = rule.split(',').map(part => part.trim())
if (parts.length >= 2) {
// 找到策略组名称的位置
let targetIndex = -1
// MATCH 规则MATCH,策略组
if (parts[0] === 'MATCH' && parts.length === 2) {
targetIndex = 1
} else if (parts.length >= 3) {
// 其他规则TYPE,MATCHER,策略组[,参数...]
// 策略组通常在第 3 个位置(索引 2但需要跳过参数
for (let i = 2; i < parts.length; i++) {
if (!ruleParamsSet.has(parts[i])) {
targetIndex = i
break
}
}
}
// 只替换策略组名称位置
if (targetIndex !== -1 && nameMapping.has(parts[targetIndex])) {
const oldName = parts[targetIndex]
parts[targetIndex] = nameMapping.get(oldName)
console.log('[Smart Override] Updated rule reference:', oldName, '→', nameMapping.get(oldName))
return parts.join(',')
}
}
return rule
} else if (typeof rule === 'object' && rule !== null) {
// 处理对象格式的规则
['target', 'proxy'].forEach(field => {
if (rule[field] && nameMapping.has(rule[field])) {
console.log('[Smart Override] Updated rule object reference:', rule[field], '→', nameMapping.get(rule[field]))
rule[field] = nameMapping.get(rule[field])
}
})
}
return rule
})
}
// 更新其他可能的配置字段引用
['mode', 'proxy-mode'].forEach(field => {
if (config[field] && nameMapping.has(config[field])) {
console.log('[Smart Override] Updated config field', field + ':', config[field], '→', nameMapping.get(config[field]))
config[field] = nameMapping.get(config[field])
}
})
}
console.log('[Smart Override] Conversion completed, skipping other operations')
return config
}
// 如果没有 url-test 或 load-balance 代理组,执行原有逻辑
console.log('[Smart Override] No url-test or load-balance groups found, executing original logic')
// 查找现有的 Smart 代理组并更新
let smartGroupExists = false
for (let i = 0; i < config['proxy-groups'].length; i++) {
const group = config['proxy-groups'][i]
if (group && group.type === 'smart') {
smartGroupExists = true
console.log('[Smart Override] Found existing smart group:', group.name)
if (!group['policy-priority']) {
group['policy-priority'] = '' // policy-priority: <1 means lower priority, >1 means higher priority, the default is 1, pattern support regex and string
}
group.uselightgbm = ${useLightGBM}
group.collectdata = ${collectData}
group.strategy = '${strategy}'
break
}
}
// 如果没有 Smart 组且有可用代理,创建示例组
if (!smartGroupExists && config.proxies && Array.isArray(config.proxies) && config.proxies.length > 0) {
console.log('[Smart Override] Creating new smart group with', config.proxies.length, 'proxies')
// 获取所有代理的名称
const proxyNames = config.proxies
.filter(proxy => proxy && typeof proxy === 'object' && proxy.name)
.map(proxy => proxy.name)
if (proxyNames.length > 0) {
const smartGroup = {
name: 'Smart Group',
type: 'smart',
'policy-priority': '', // policy-priority: <1 means lower priority, >1 means higher priority, the default is 1, pattern support regex and string
uselightgbm: ${useLightGBM},
collectdata: ${collectData},
strategy: '${strategy}',
proxies: proxyNames
}
config['proxy-groups'].unshift(smartGroup)
console.log('[Smart Override] Created smart group at first position with proxies:', proxyNames)
} else {
console.log('[Smart Override] No valid proxies found, skipping smart group creation')
}
} else if (!smartGroupExists) {
console.log('[Smart Override] No proxies available, skipping smart group creation')
}
// 处理规则替换
if (config.rules && Array.isArray(config.rules)) {
console.log('[Smart Override] Processing rules, original count:', config.rules.length)
// 收集所有代理组名称
const proxyGroupNames = new Set()
if (config['proxy-groups'] && Array.isArray(config['proxy-groups'])) {
config['proxy-groups'].forEach(group => {
if (group && group.name) {
proxyGroupNames.add(group.name)
}
})
}
// 添加常见的内置目标
const builtinTargets = new Set([
'DIRECT',
'REJECT',
'REJECT-DROP',
'PASS',
'COMPATIBLE'
])
// 添加常见的规则参数,不应该替换
const ruleParams = new Set(['no-resolve', 'force-remote-dns', 'prefer-ipv6'])
console.log('[Smart Override] Found', proxyGroupNames.size, 'proxy groups:', Array.from(proxyGroupNames))
let replacedCount = 0
config.rules = config.rules.map(rule => {
if (typeof rule === 'string') {
// 检查是否是复杂规则格式(包含括号的嵌套规则)
if (rule.includes('((') || rule.includes('))')) {
console.log('[Smart Override] Skipping complex nested rule:', rule)
return rule
}
// 处理字符串格式的规则
const parts = rule.split(',').map(part => part.trim())
if (parts.length >= 2) {
// 找到代理组名称的位置
let targetIndex = -1
let targetValue = ''
// 处理 MATCH 规则
if (parts[0] === 'MATCH' && parts.length === 2) {
targetIndex = 1
targetValue = parts[1]
} else if (parts.length >= 3) {
// 处理其他规则
for (let i = 2; i < parts.length; i++) {
const part = parts[i]
if (!ruleParams.has(part)) {
targetIndex = i
targetValue = part
break
}
}
}
if (targetIndex !== -1 && targetValue) {
// 检查是否应该替换
const shouldReplace = !builtinTargets.has(targetValue) &&
(proxyGroupNames.has(targetValue) ||
!ruleParams.has(targetValue))
if (shouldReplace) {
parts[targetIndex] = 'Smart Group'
replacedCount++
console.log('[Smart Override] Replaced rule target:', targetValue, '→ Smart Group')
return parts.join(',')
}
}
}
} else if (typeof rule === 'object' && rule !== null) {
// 处理对象格式
let targetField = ''
let targetValue = ''
if (rule.target) {
targetField = 'target'
targetValue = rule.target
} else if (rule.proxy) {
targetField = 'proxy'
targetValue = rule.proxy
}
if (targetField && targetValue) {
const shouldReplace = !builtinTargets.has(targetValue) &&
(proxyGroupNames.has(targetValue) ||
!ruleParams.has(targetValue))
if (shouldReplace) {
rule[targetField] = 'Smart Group'
replacedCount++
console.log('[Smart Override] Replaced rule target:', targetValue, '→ Smart Group')
}
}
}
return rule
})
console.log('[Smart Override] Rules processed, replaced', replacedCount, 'non-DIRECT rules with Smart Group')
} else {
console.log('[Smart Override] No rules found or rules is not an array')
}
console.log('[Smart Override] Configuration processed successfully')
return config
} catch (error) {
console.error('[Smart Override] Error processing config:', error)
// 发生错误时返回原始配置,避免破坏整个配置
return config
}
}
`
}
/**
* Smart
*/
export async function createSmartOverride(): Promise<void> {
try {
// 获取应用配置
const {
smartCoreUseLightGBM = false,
smartCoreCollectData = false,
smartCoreStrategy = 'sticky-sessions',
smartCollectorSize = 100
} = await getAppConfig()
// 生成覆写模板
const template = generateSmartOverrideTemplate(
smartCoreUseLightGBM,
smartCoreCollectData,
smartCoreStrategy,
smartCollectorSize
)
await addOverrideItem({
id: SMART_OVERRIDE_ID,
name: 'Smart Core Override',
type: 'local',
ext: 'js',
global: true,
file: template
})
} catch (error) {
await overrideLogger.error('Failed to create Smart override', error)
throw error
}
}
/**
* Smart
*/
export async function removeSmartOverride(): Promise<void> {
try {
const existingOverride = await getOverrideItem(SMART_OVERRIDE_ID)
if (existingOverride) {
await removeOverrideItem(SMART_OVERRIDE_ID)
}
} catch (error) {
await overrideLogger.error('Failed to remove Smart override', error)
throw error
}
}
/**
* Smart
*/
export async function manageSmartOverride(): Promise<void> {
const { enableSmartCore = true, enableSmartOverride = true, core } = await getAppConfig()
if (enableSmartCore && enableSmartOverride && core === 'mihomo-smart') {
await createSmartOverride()
} else {
await removeSmartOverride()
}
}
/**
* Smart
*/
export async function isSmartOverrideExists(): Promise<boolean> {
try {
const override = await getOverrideItem(SMART_OVERRIDE_ID)
return !!override
} catch {
return false
}
}

View File

@ -1,82 +0,0 @@
import { exec } from 'child_process'
import { promisify } from 'util'
import { net } from 'electron'
import axios from 'axios'
import { getAppConfig, patchAppConfig } from '../config'
const execPromise = promisify(exec)
const helperSocketPath = '/tmp/mihomo-party-helper.sock'
let setPublicDNSTimer: NodeJS.Timeout | null = null
let recoverDNSTimer: NodeJS.Timeout | null = null
export async function getDefaultDevice(): Promise<string> {
const { stdout: deviceOut } = await execPromise(`route -n get default`)
let device = deviceOut.split('\n').find((s) => s.includes('interface:'))
device = device?.trim().split(' ').slice(1).join(' ')
if (!device) throw new Error('Get device failed')
return device
}
async function getDefaultService(): Promise<string> {
const device = await getDefaultDevice()
const { stdout: order } = await execPromise(`networksetup -listnetworkserviceorder`)
const block = order.split('\n\n').find((s) => s.includes(`Device: ${device}`))
if (!block) throw new Error('Get networkservice failed')
for (const line of block.split('\n')) {
if (line.match(/^\(\d+\).*/)) {
return line.trim().split(' ').slice(1).join(' ')
}
}
throw new Error('Get service failed')
}
async function getOriginDNS(): Promise<void> {
const service = await getDefaultService()
const { stdout: dns } = await execPromise(`networksetup -getdnsservers "${service}"`)
if (dns.startsWith("There aren't any DNS Servers set on")) {
await patchAppConfig({ originDNS: 'Empty' })
} else {
await patchAppConfig({ originDNS: dns.trim().replace(/\n/g, ' ') })
}
}
async function setDNS(dns: string): Promise<void> {
const service = await getDefaultService()
try {
await axios.post('http://localhost/dns', { service, dns }, { socketPath: helperSocketPath })
} catch {
// fallback to osascript if helper not available
const shell = `networksetup -setdnsservers "${service}" ${dns}`
const command = `do shell script "${shell}" with administrator privileges`
await execPromise(`osascript -e '${command}'`)
}
}
export async function setPublicDNS(): Promise<void> {
if (process.platform !== 'darwin') return
if (net.isOnline()) {
const { originDNS } = await getAppConfig()
if (!originDNS) {
await getOriginDNS()
await setDNS('223.5.5.5')
}
} else {
if (setPublicDNSTimer) clearTimeout(setPublicDNSTimer)
setPublicDNSTimer = setTimeout(() => setPublicDNS(), 5000)
}
}
export async function recoverDNS(): Promise<void> {
if (process.platform !== 'darwin') return
if (net.isOnline()) {
const { originDNS } = await getAppConfig()
if (originDNS) {
await setDNS(originDNS)
await patchAppConfig({ originDNS: undefined })
}
} else {
if (recoverDNSTimer) clearTimeout(recoverDNSTimer)
recoverDNSTimer = setTimeout(() => recoverDNS(), 5000)
}
}

View File

@ -1,7 +1,3 @@
import { copyFile, mkdir, writeFile, readFile, stat } from 'fs/promises'
import vm from 'vm'
import { existsSync, writeFileSync } from 'fs'
import path from 'path'
import {
getControledMihomoConfig,
getProfileConfig,
@ -16,128 +12,23 @@ import {
mihomoProfileWorkDir,
mihomoWorkConfigPath,
mihomoWorkDir,
overridePath,
rulePath
overridePath
} from '../utils/dirs'
import { parse, stringify } from '../utils/yaml'
import yaml from 'yaml'
import { copyFile, mkdir, writeFile } from 'fs/promises'
import { deepMerge } from '../utils/merge'
import { createLogger } from '../utils/logger'
import vm from 'vm'
import { existsSync, writeFileSync } from 'fs'
import path from 'path'
const factoryLogger = createLogger('Factory')
let runtimeConfigStr: string
let runtimeConfig: IMihomoConfig
let runtimeConfigStr: string = ''
let runtimeConfig: IMihomoConfig = {} as IMihomoConfig
// 辅助函数:处理带偏移量的规则
function processRulesWithOffset(ruleStrings: string[], currentRules: string[], isAppend = false) {
const normalRules: string[] = []
const rules = [...currentRules]
ruleStrings.forEach((ruleStr) => {
const parts = ruleStr.split(',')
const firstPartIsNumber =
!isNaN(Number(parts[0])) && parts[0].trim() !== '' && parts.length >= 3
if (firstPartIsNumber) {
const offset = parseInt(parts[0])
const rule = parts.slice(1).join(',')
if (isAppend) {
// 后置规则的插入位置计算
const insertPosition = Math.max(0, rules.length - Math.min(offset, rules.length))
rules.splice(insertPosition, 0, rule)
} else {
// 前置规则的插入位置计算
const insertPosition = Math.min(offset, rules.length)
rules.splice(insertPosition, 0, rule)
}
} else {
normalRules.push(ruleStr)
}
})
return { normalRules, insertRules: rules }
}
export async function generateProfile(): Promise<string | undefined> {
// 读取最新的配置
const { current } = await getProfileConfig(true)
const {
diffWorkDir = false,
controlDns = true,
controlSniff = true,
useNameserverPolicy
} = await getAppConfig()
export async function generateProfile(): Promise<void> {
const { current } = await getProfileConfig()
const { diffWorkDir = false } = await getAppConfig()
const currentProfile = await overrideProfile(current, await getProfile(current))
let controledMihomoConfig = await getControledMihomoConfig()
// 根据开关状态过滤控制配置
controledMihomoConfig = { ...controledMihomoConfig }
if (!controlDns) {
delete controledMihomoConfig.dns
delete controledMihomoConfig.hosts
}
if (!controlSniff) {
delete controledMihomoConfig.sniffer
}
if (!useNameserverPolicy) {
delete controledMihomoConfig?.dns?.['nameserver-policy']
}
// 应用规则文件
try {
const ruleFilePath = rulePath(current || 'default')
if (existsSync(ruleFilePath)) {
const ruleFileContent = await readFile(ruleFilePath, 'utf-8')
const ruleData = parse(ruleFileContent) as {
prepend?: string[]
append?: string[]
delete?: string[]
} | null
if (ruleData && typeof ruleData === 'object') {
// 确保 rules 数组存在
if (!currentProfile.rules) {
currentProfile.rules = [] as unknown as []
}
let rules = [...currentProfile.rules] as unknown as string[]
// 处理前置规则
if (ruleData.prepend?.length) {
const { normalRules: prependRules, insertRules } = processRulesWithOffset(
ruleData.prepend,
rules
)
rules = [...prependRules, ...insertRules]
}
// 处理后置规则
if (ruleData.append?.length) {
const { normalRules: appendRules, insertRules } = processRulesWithOffset(
ruleData.append,
rules,
true
)
rules = [...insertRules, ...appendRules]
}
// 处理删除规则
if (ruleData.delete?.length) {
const deleteSet = new Set(ruleData.delete)
rules = rules.filter((rule) => {
const ruleStr = Array.isArray(rule) ? rule.join(',') : rule
return !deleteSet.has(ruleStr)
})
}
currentProfile.rules = rules as unknown as []
}
}
} catch (error) {
factoryLogger.error('Failed to read or apply rule file', error)
}
const controledMihomoConfig = await getControledMihomoConfig()
const profile = deepMerge(currentProfile, controledMihomoConfig)
// 确保可以拿到基础日志信息
// 使用 debug 可以调试内核相关问题 `debug/pprof`
@ -145,7 +36,7 @@ export async function generateProfile(): Promise<string | undefined> {
profile['log-level'] = 'info'
}
runtimeConfig = profile
runtimeConfigStr = stringify(profile)
runtimeConfigStr = yaml.stringify(profile)
if (diffWorkDir) {
await prepareProfileWorkDir(current)
}
@ -153,30 +44,16 @@ export async function generateProfile(): Promise<string | undefined> {
diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work'),
runtimeConfigStr
)
return current
}
async function prepareProfileWorkDir(current: string | undefined): Promise<void> {
if (!existsSync(mihomoProfileWorkDir(current))) {
await mkdir(mihomoProfileWorkDir(current), { recursive: true })
}
const isSourceNewer = async (sourcePath: string, targetPath: string): Promise<boolean> => {
try {
const [sourceStats, targetStats] = await Promise.all([stat(sourcePath), stat(targetPath)])
return sourceStats.mtime > targetStats.mtime
} catch {
return true
}
}
const copy = async (file: string): Promise<void> => {
const targetPath = path.join(mihomoProfileWorkDir(current), file)
const sourcePath = path.join(mihomoWorkDir(), file)
if (!existsSync(sourcePath)) return
// 复制条件:目标不存在 或 源文件更新
const shouldCopy = !existsSync(targetPath) || (await isSourceNewer(sourcePath, targetPath))
if (shouldCopy) {
if (!existsSync(targetPath) && existsSync(sourcePath)) {
await copyFile(sourcePath, targetPath)
}
}
@ -204,7 +81,7 @@ async function overrideProfile(
profile = runOverrideScript(profile, content, item)
break
case 'yaml': {
let patch = parse(content) || {}
let patch = yaml.parse(content, { merge: true }) || {}
if (typeof patch !== 'object') patch = {}
profile = deepMerge(profile, patch)
break

View File

@ -1,21 +1,7 @@
import { ChildProcess, execFile, spawn } from 'child_process'
import { readFile, rm, writeFile } from 'fs/promises'
import { promisify } from 'util'
import path from 'path'
import os from 'os'
import { createWriteStream, existsSync } from 'fs'
import chokidar, { FSWatcher } from 'chokidar'
import { app, ipcMain } from 'electron'
import { mainWindow } from '../window'
import {
getAppConfig,
getControledMihomoConfig,
patchControledMihomoConfig,
manageSmartOverride
} from '../config'
import { ChildProcess, exec, execFile, spawn } from 'child_process'
import {
dataDir,
coreLogPath,
logPath,
mihomoCoreDir,
mihomoCorePath,
mihomoProfileWorkDir,
@ -23,11 +9,15 @@ import {
mihomoWorkConfigPath,
mihomoWorkDir
} from '../utils/dirs'
import { uploadRuntimeConfig } from '../resolve/gistApi'
import { startMonitor } from '../resolve/trafficMonitor'
import { safeShowErrorBox } from '../utils/init'
import i18next from '../../shared/i18n'
import { managerLogger } from '../utils/logger'
import { generateProfile } from './factory'
import {
getAppConfig,
getControledMihomoConfig,
getProfileConfig,
patchAppConfig,
patchControledMihomoConfig
} from '../config'
import { app, dialog, ipcMain, net } from 'electron'
import {
startMihomoTraffic,
startMihomoConnections,
@ -37,423 +27,219 @@ import {
stopMihomoTraffic,
stopMihomoLogs,
stopMihomoMemory,
patchMihomoConfig,
getAxios
patchMihomoConfig
} from './mihomoApi'
import { generateProfile } from './factory'
import { getSessionAdminStatus } from './permissions'
import {
cleanupSocketFile,
cleanupWindowsNamedPipes,
validateWindowsPipeAccess,
waitForCoreReady
} from './process'
import { setPublicDNS, recoverDNS } from './dns'
import chokidar from 'chokidar'
import { readFile, rm, writeFile } from 'fs/promises'
import { promisify } from 'util'
import { mainWindow } from '..'
import path from 'path'
import os from 'os'
import { createWriteStream, existsSync } from 'fs'
import { uploadRuntimeConfig } from '../resolve/gistApi'
import { startMonitor } from '../resolve/trafficMonitor'
import i18next from '../../shared/i18n'
// 重新导出权限相关函数
export {
initAdminStatus,
getSessionAdminStatus,
checkAdminPrivileges,
checkMihomoCorePermissions,
checkHighPrivilegeCore,
grantTunPermissions,
restartAsAdmin,
requestTunPermissions,
showTunPermissionDialog,
showErrorDialog,
checkTunPermissions,
manualGrantCorePermition
} from './permissions'
chokidar.watch(path.join(mihomoCoreDir(), 'meta-update'), {}).on('unlinkDir', async () => {
try {
await stopCore(true)
await startCore()
} catch (e) {
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`)
}
})
export { getDefaultDevice } from './dns'
const execFilePromise = promisify(execFile)
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 recoverDNSTimer: NodeJS.Timeout | null = null
let child: ChildProcess
let retry = 10
let isRestarting = false
// 文件监听器
let coreWatcher: FSWatcher | null = null
// 初始化核心文件监听
export function initCoreWatcher(): void {
if (coreWatcher) return
coreWatcher = chokidar.watch(path.join(mihomoCoreDir(), 'meta-update'), {})
coreWatcher.on('unlinkDir', async () => {
// 等待核心自我更新完成,避免与核心自动重启产生竞态
await new Promise((resolve) => setTimeout(resolve, 3000))
try {
await stopCore(true)
await startCore()
} catch (e) {
safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`)
}
})
}
// 清理核心文件监听
export function cleanupCoreWatcher(): void {
if (coreWatcher) {
coreWatcher.close()
coreWatcher = null
}
}
// 动态生成 IPC 路径
export const getMihomoIpcPath = (): string => {
if (process.platform === 'win32') {
const isAdmin = getSessionAdminStatus()
const sessionId = process.env.SESSIONNAME || process.env.USERNAME || 'default'
const processId = process.pid
return isAdmin
? `\\\\.\\pipe\\MihomoParty\\mihomo-admin-${sessionId}-${processId}`
: `\\\\.\\pipe\\MihomoParty\\mihomo-user-${sessionId}-${processId}`
}
const uid = process.getuid?.() || 'unknown'
const processId = process.pid
return `/tmp/mihomo-party-${uid}-${processId}.sock`
}
// 核心配置接口
interface CoreConfig {
corePath: string
workDir: string
ipcPath: string
logLevel: LogLevel
tunEnabled: boolean
autoSetDNS: boolean
cpuPriority: string
detached: boolean
}
// 准备核心配置
async function prepareCore(detached: boolean, skipStop = false): Promise<CoreConfig> {
const [appConfig, mihomoConfig] = await Promise.all([getAppConfig(), getControledMihomoConfig()])
export async function startCore(detached = false): Promise<Promise<void>[]> {
const {
core = 'mihomo',
autoSetDNS = true,
diffWorkDir = false,
mihomoCpuPriority = 'PRIORITY_NORMAL'
} = appConfig
const { 'log-level': logLevel = 'info' as LogLevel, tun } = mihomoConfig
// 清理旧进程
const pidPath = path.join(dataDir(), 'core.pid')
if (existsSync(pidPath)) {
const pid = parseInt(await readFile(pidPath, 'utf-8'))
mihomoCpuPriority = 'PRIORITY_NORMAL',
disableLoopbackDetector = false,
disableEmbedCA = false,
disableSystemCA = false,
skipSafePathCheck = false
} = await getAppConfig()
const { 'log-level': logLevel } = await getControledMihomoConfig()
if (existsSync(path.join(dataDir(), 'core.pid'))) {
const pid = parseInt(await readFile(path.join(dataDir(), 'core.pid'), 'utf-8'))
try {
process.kill(pid, 'SIGINT')
} catch {
// ignore
} finally {
await rm(pidPath)
await rm(path.join(dataDir(), 'core.pid'))
}
}
// 管理 Smart 内核覆写配置
if (core === 'mihomo-smart') {
await manageSmartOverride()
}
// generateProfile 返回实际使用的 current
const current = await generateProfile()
await checkProfile(current, core, diffWorkDir)
if (!skipStop) {
await stopCore()
}
await cleanupSocketFile()
// 设置 DNS
const { current } = await getProfileConfig()
const { tun } = await getControledMihomoConfig()
const corePath = mihomoCorePath(core)
await generateProfile()
await checkProfile()
await stopCore()
if (tun?.enable && autoSetDNS) {
try {
await setPublicDNS()
} catch (error) {
managerLogger.error('set dns failed', error)
await writeFile(logPath(), `[Manager]: set dns failed, ${error}`, {
flag: 'a'
})
}
}
// 获取动态 IPC 路径
const ipcPath = getMihomoIpcPath()
managerLogger.info(`Using IPC path: ${ipcPath}`)
if (process.platform === 'win32') {
await validateWindowsPipeAccess(ipcPath)
const stdout = createWriteStream(logPath(), { flags: 'a' })
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)
}
return {
corePath: mihomoCorePath(core),
workDir: diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(),
ipcPath,
logLevel,
tunEnabled: tun?.enable ?? false,
autoSetDNS,
cpuPriority: mihomoCpuPriority,
detached
}
}
// 启动核心进程
function spawnCoreProcess(config: CoreConfig): ChildProcess {
const { corePath, workDir, ipcPath, cpuPriority, detached } = config
const stdout = createWriteStream(coreLogPath(), { flags: 'a' })
const stderr = createWriteStream(coreLogPath(), { flags: 'a' })
const proc = spawn(corePath, ['-d', workDir, ctlParam, ipcPath], {
detached,
stdio: detached ? 'ignore' : undefined
})
if (process.platform === 'win32' && proc.pid) {
os.setPriority(
proc.pid,
os.constants.priority[cpuPriority as keyof typeof os.constants.priority]
)
}
if (!detached) {
proc.stdout?.pipe(stdout)
proc.stderr?.pipe(stderr)
}
return proc
}
// 设置核心进程事件监听
function setupCoreListeners(
proc: ChildProcess,
logLevel: LogLevel,
resolve: (value: Promise<void>[]) => void,
reject: (reason: unknown) => void
): void {
proc.on('close', async (code, signal) => {
managerLogger.info(`Core closed, code: ${code}, signal: ${signal}`)
if (isRestarting) {
managerLogger.info('Core closed during restart, skipping auto-restart')
return
child = spawn(
corePath,
['-d', diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), ctlParam, mihomoIpcPath],
{
detached: detached,
stdio: detached ? 'ignore' : undefined,
env: env
}
)
if (process.platform === 'win32' && child.pid) {
os.setPriority(child.pid, os.constants.priority[mihomoCpuPriority])
}
if (detached) {
child.unref()
return new Promise((resolve) => {
resolve([new Promise(() => {})])
})
}
child.on('close', async (code, signal) => {
await writeFile(logPath(), `[Manager]: Core closed, code: ${code}, signal: ${signal}\n`, {
flag: 'a'
})
if (retry) {
managerLogger.info('Try Restart Core')
await writeFile(logPath(), `[Manager]: Try Restart Core\n`, { flag: 'a' })
retry--
await restartCore()
} else {
await stopCore()
}
})
proc.stdout?.on('data', async (data) => {
const str = data.toString()
// TUN 权限错误
if (str.includes('configure tun interface: operation not permitted')) {
patchControledMihomoConfig({ tun: { enable: false } })
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
reject(i18next.t('tun.error.tunPermissionDenied'))
return
}
// 控制器监听错误
const isControllerError =
(process.platform !== 'win32' && str.includes('External controller unix listen error')) ||
(process.platform === 'win32' && str.includes('External controller pipe listen error'))
if (isControllerError) {
managerLogger.error('External controller listen error detected:', str)
if (process.platform === 'win32') {
managerLogger.info('Attempting Windows pipe cleanup and retry...')
try {
await cleanupWindowsNamedPipes()
await new Promise((r) => setTimeout(r, 2000))
} catch (cleanupError) {
managerLogger.error('Pipe cleanup failed:', cleanupError)
}
child.stdout?.pipe(stdout)
child.stderr?.pipe(stderr)
return new Promise((resolve, reject) => {
child.stdout?.on('data', async (data) => {
const str = data.toString()
if (str.includes('configure tun interface: operation not permitted')) {
patchControledMihomoConfig({ tun: { enable: false } })
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
reject(i18next.t('tun.error.tunPermissionDenied'))
}
reject(i18next.t('mihomo.error.externalControllerListenError'))
return
}
if ((process.platform !== 'win32' && str.includes('External controller unix listen error')) ||
(process.platform === 'win32' && str.includes('External controller pipe listen error'))
) {
reject(i18next.t('mihomo.error.externalControllerListenError'))
}
// API 就绪
const isApiReady =
(process.platform !== 'win32' && str.includes('RESTful API unix listening at')) ||
(process.platform === 'win32' && str.includes('RESTful API pipe listening at'))
if (isApiReady) {
resolve([
new Promise((innerResolve) => {
proc.stdout?.on('data', async (innerData) => {
if (
innerData
.toString()
.toLowerCase()
.includes('start initial compatible provider default')
) {
try {
mainWindow?.webContents.send('groupsUpdated')
mainWindow?.webContents.send('rulesUpdated')
await uploadRuntimeConfig()
} catch {
// ignore
if (
(process.platform !== 'win32' && str.includes('RESTful API unix listening at')) ||
(process.platform === 'win32' && str.includes('RESTful API pipe listening at'))
) {
resolve([
new Promise((resolve) => {
child.stdout?.on('data', async (data) => {
if (data.toString().toLowerCase().includes('start initial compatible provider default')) {
try {
mainWindow?.webContents.send('groupsUpdated')
mainWindow?.webContents.send('rulesUpdated')
await uploadRuntimeConfig()
} catch {
// ignore
}
await patchMihomoConfig({ 'log-level': logLevel })
resolve()
}
await patchMihomoConfig({ 'log-level': logLevel })
innerResolve()
}
})
})
})
])
await waitForCoreReady()
await getAxios(true)
await startMihomoTraffic()
await startMihomoConnections()
await startMihomoLogs()
await startMihomoMemory()
retry = 10
}
])
await startMihomoTraffic()
await startMihomoConnections()
await startMihomoLogs()
await startMihomoMemory()
retry = 10
}
})
})
}
// 启动核心
export async function startCore(detached = false, skipStop = false): Promise<Promise<void>[]> {
const config = await prepareCore(detached, skipStop)
child = spawnCoreProcess(config)
if (detached) {
managerLogger.info(
`Core process detached successfully on ${process.platform}, PID: ${child.pid}`
)
child.unref()
return [new Promise(() => {})]
}
return new Promise((resolve, reject) => {
setupCoreListeners(child, config.logLevel, resolve, reject)
})
}
// 停止核心
export async function stopCore(force = false): Promise<void> {
try {
if (!force) {
await recoverDNS()
}
} catch (error) {
managerLogger.error('recover dns failed', error)
await writeFile(logPath(), `[Manager]: recover dns failed, ${error}`, {
flag: 'a'
})
}
if (child) {
child.removeAllListeners()
child.kill('SIGINT')
}
stopMihomoTraffic()
stopMihomoConnections()
stopMihomoLogs()
stopMihomoMemory()
try {
await getAxios(true)
} catch (error) {
managerLogger.warn('Failed to refresh axios instance:', error)
}
await cleanupSocketFile()
}
// 重启核心
export async function restartCore(): Promise<void> {
if (isRestarting) {
managerLogger.info('Core restart already in progress, skipping duplicate request')
return
}
isRestarting = true
let retryCount = 0
const maxRetries = 3
try {
// 先显式停止核心,确保状态干净
await stopCore()
// 尝试启动核心,失败时重试
while (retryCount < maxRetries) {
try {
// skipStop=true 因为我们已经在上面停止了核心
await startCore(false, true)
return // 成功启动,退出函数
} catch (e) {
retryCount++
managerLogger.error(`restart core failed (attempt ${retryCount}/${maxRetries})`, e)
if (retryCount >= maxRetries) {
throw e
}
// 重试前等待一段时间
await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount))
// 确保清理干净再重试
await stopCore()
await cleanupSocketFile()
}
}
} finally {
isRestarting = false
await startCore()
} catch (e) {
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`)
}
}
// 保持核心运行
export async function keepCoreAlive(): Promise<void> {
try {
await startCore(true)
if (child?.pid) {
if (child && child.pid) {
await writeFile(path.join(dataDir(), 'core.pid'), child.pid.toString())
}
} catch (e) {
safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`)
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`)
}
}
// 退出但保持核心运行
export async function quitWithoutCore(): Promise<void> {
managerLogger.info(`Starting lightweight mode on platform: ${process.platform}`)
try {
await startCore(true)
if (child?.pid) {
await writeFile(path.join(dataDir(), 'core.pid'), child.pid.toString())
managerLogger.info(`Core started in lightweight mode with PID: ${child.pid}`)
}
} catch (e) {
managerLogger.error('Failed to start core in lightweight mode:', e)
safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`)
}
await keepCoreAlive()
await startMonitor(true)
managerLogger.info('Exiting main process, core will continue running in background')
app.exit()
}
// 检查配置文件
async function checkProfile(
current: string | undefined,
core: string = 'mihomo',
diffWorkDir: boolean = false
): Promise<void> {
async function checkProfile(): Promise<void> {
const {
core = 'mihomo',
diffWorkDir = false,
skipSafePathCheck = false
} = await getAppConfig()
const { current } = await getProfileConfig()
const corePath = mihomoCorePath(core)
const execFilePromise = promisify(execFile)
const env = {
SKIP_SAFE_PATH_CHECK: String(skipSafePathCheck)
}
try {
await execFilePromise(corePath, [
'-t',
@ -461,42 +247,104 @@ async function checkProfile(
diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work'),
'-d',
mihomoTestDir()
])
], { env })
} catch (error) {
managerLogger.error('Profile check failed', error)
if (error instanceof Error && 'stdout' in error) {
const { stdout, stderr } = error as { stdout: string; stderr?: string }
managerLogger.info('Profile check stdout', stdout)
managerLogger.info('Profile check stderr', stderr)
const { stdout } = error as { stdout: string }
const errorLines = stdout
.split('\n')
.filter((line) => line.includes('level=error') || line.includes('error'))
.map((line) => {
if (line.includes('level=error')) {
return line.split('level=error')[1]?.trim() || line
}
return line.trim()
})
.filter((line) => line.length > 0)
if (errorLines.length === 0) {
const allLines = stdout.split('\n').filter((line) => line.trim().length > 0)
throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}:\n${allLines.join('\n')}`)
} else {
throw new Error(
`${i18next.t('mihomo.error.profileCheckFailed')}:\n${errorLines.join('\n')}`
)
}
.filter((line) => line.includes('level=error'))
.map((line) => line.split('level=error')[1])
throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}:\n${errorLines.join('\n')}`)
} else {
throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}: ${error}`)
throw error
}
}
}
// 权限检查入口(从 permissions.ts 调用)
export async function checkAdminRestartForTun(): Promise<void> {
const { checkAdminRestartForTun: check } = await import('./permissions')
await check(restartCore)
export async function manualGrantCorePermition(): Promise<void> {
const { core = 'mihomo' } = await getAppConfig()
const corePath = mihomoCorePath(core)
const execPromise = promisify(exec)
const execFilePromise = promisify(execFile)
if (process.platform === 'darwin') {
const shell = `chown root:admin ${corePath.replace(' ', '\\\\ ')}\nchmod +sx ${corePath.replace(' ', '\\\\ ')}`
const command = `do shell script "${shell}" with administrator privileges`
await execPromise(`osascript -e '${command}'`)
}
if (process.platform === 'linux') {
await execFilePromise('pkexec', [
'bash',
'-c',
`chown root:root "${corePath}" && chmod +sx "${corePath}"`
])
}
}
export async function getDefaultDevice(): Promise<string> {
const execPromise = promisify(exec)
const { stdout: deviceOut } = await execPromise(`route -n get default`)
let device = deviceOut.split('\n').find((s) => s.includes('interface:'))
device = device?.trim().split(' ').slice(1).join(' ')
if (!device) throw new Error('Get device failed')
return device
}
async function getDefaultService(): Promise<string> {
const execPromise = promisify(exec)
const device = await getDefaultDevice()
const { stdout: order } = await execPromise(`networksetup -listnetworkserviceorder`)
const block = order.split('\n\n').find((s) => s.includes(`Device: ${device}`))
if (!block) throw new Error('Get networkservice failed')
for (const line of block.split('\n')) {
if (line.match(/^\(\d+\).*/)) {
return line.trim().split(' ').slice(1).join(' ')
}
}
throw new Error('Get service failed')
}
async function getOriginDNS(): Promise<void> {
const execPromise = promisify(exec)
const service = await getDefaultService()
const { stdout: dns } = await execPromise(`networksetup -getdnsservers "${service}"`)
if (dns.startsWith("There aren't any DNS Servers set on")) {
await patchAppConfig({ originDNS: 'Empty' })
} else {
await patchAppConfig({ originDNS: dns.trim().replace(/\n/g, ' ') })
}
}
async function setDNS(dns: string): Promise<void> {
const service = await getDefaultService()
const execPromise = promisify(exec)
await execPromise(`networksetup -setdnsservers "${service}" ${dns}`)
}
async function setPublicDNS(): Promise<void> {
if (process.platform !== 'darwin') return
if (net.isOnline()) {
const { originDNS } = await getAppConfig()
if (!originDNS) {
await getOriginDNS()
await setDNS('223.5.5.5')
}
} else {
if (setPublicDNSTimer) clearTimeout(setPublicDNSTimer)
setPublicDNSTimer = setTimeout(() => setPublicDNS(), 5000)
}
}
async function recoverDNS(): Promise<void> {
if (process.platform !== 'darwin') return
if (net.isOnline()) {
const { originDNS } = await getAppConfig()
if (originDNS) {
await setDNS(originDNS)
await patchAppConfig({ originDNS: undefined })
}
} else {
if (recoverDNSTimer) clearTimeout(recoverDNSTimer)
recoverDNSTimer = setTimeout(() => recoverDNS(), 5000)
}
}

View File

@ -1,18 +1,14 @@
import axios, { AxiosInstance } from 'axios'
import WebSocket from 'ws'
import { getAppConfig, getControledMihomoConfig } from '../config'
import { mainWindow } from '../window'
import { mainWindow } from '..'
import WebSocket from 'ws'
import { tray } from '../resolve/tray'
import { calcTraffic } from '../utils/calc'
import { floatingWindow } from '../resolve/floatingWindow'
import { createLogger } from '../utils/logger'
import { getRuntimeConfig } from './factory'
import { getMihomoIpcPath } from './manager'
import { floatingWindow } from '../resolve/floatingWindow'
import { mihomoIpcPath } from './manager'
const mihomoApiLogger = createLogger('MihomoApi')
let axiosIns: AxiosInstance | null = null
let currentIpcPath: string = ''
let axiosIns: AxiosInstance = null!
let mihomoTrafficWs: WebSocket | null = null
let trafficRetry = 10
let mihomoMemoryWs: WebSocket | null = null
@ -22,21 +18,12 @@ let logsRetry = 10
let mihomoConnectionsWs: WebSocket | null = null
let connectionsRetry = 10
const MAX_RETRY = 10
export const getAxios = async (force: boolean = false): Promise<AxiosInstance> => {
const dynamicIpcPath = getMihomoIpcPath()
if (axiosIns && !force && currentIpcPath === dynamicIpcPath) {
return axiosIns
}
currentIpcPath = dynamicIpcPath
mihomoApiLogger.info(`Creating axios instance with path: ${dynamicIpcPath}`)
if (axiosIns && !force) return axiosIns
axiosIns = axios.create({
baseURL: `http://localhost`,
socketPath: dynamicIpcPath,
socketPath: mihomoIpcPath,
timeout: 15000
})
@ -45,12 +32,6 @@ export const getAxios = async (force: boolean = false): Promise<AxiosInstance> =
return response.data
},
(error) => {
if (error.code === 'ENOENT') {
mihomoApiLogger.debug(`Pipe not ready: ${error.config?.socketPath}`)
} else {
mihomoApiLogger.error(`Axios error with path ${dynamicIpcPath}: ${error.message}`)
}
if (error.response && error.response.data) {
return Promise.reject(error.response.data)
}
@ -85,11 +66,6 @@ export const mihomoRules = async (): Promise<IMihomoRulesInfo> => {
return await instance.get('/rules')
}
export const mihomoRulesDisable = async (rules: Record<string, boolean>): Promise<void> => {
const instance = await getAxios()
return await instance.patch('/rules/disable', rules)
}
export const mihomoProxies = async (): Promise<IMihomoProxies> => {
const instance = await getAxios()
const proxies = (await instance.get('/proxies')) as IMihomoProxies
@ -110,14 +86,14 @@ export const mihomoGroups = async (): Promise<IMihomoMixedGroup[]> => {
if (proxies.proxies[name] && 'all' in proxies.proxies[name] && !proxies.proxies[name].hidden) {
const newGroup = proxies.proxies[name]
newGroup.testUrl = url
const newAll = (newGroup.all || []).map((name) => proxies.proxies[name])
const newAll = newGroup.all.map((name) => proxies.proxies[name])
groups.push({ ...newGroup, all: newAll })
}
})
if (!groups.find((group) => group.name === 'GLOBAL')) {
const newGlobal = proxies.proxies['GLOBAL'] as IMihomoGroup
if (!newGlobal.hidden) {
const newAll = (newGlobal.all || []).map((name) => proxies.proxies[name])
const newAll = newGlobal.all.map((name) => proxies.proxies[name])
groups.push({ ...newGlobal, all: newAll })
}
}
@ -169,7 +145,7 @@ export const mihomoProxyDelay = async (proxy: string, url?: string): Promise<IMi
const instance = await getAxios()
return await instance.get(`/proxies/${encodeURIComponent(proxy)}/delay`, {
params: {
url: url || delayTestUrl || 'https://www.gstatic.com/generate_204',
url: url || delayTestUrl || 'http://www.gstatic.com/generate_204',
timeout: delayTestTimeout || 5000
}
})
@ -181,7 +157,7 @@ export const mihomoGroupDelay = async (group: string, url?: string): Promise<IMi
const instance = await getAxios()
return await instance.get(`/group/${encodeURIComponent(group)}/delay`, {
params: {
url: url || delayTestUrl || 'https://www.gstatic.com/generate_204',
url: url || delayTestUrl || 'http://www.gstatic.com/generate_204',
timeout: delayTestTimeout || 5000
}
})
@ -192,63 +168,11 @@ export const mihomoUpgrade = async (): Promise<void> => {
return await instance.post('/upgrade')
}
export const mihomoUpgradeUI = async (): Promise<void> => {
const instance = await getAxios()
return await instance.post('/upgrade/ui')
}
export const mihomoUpgradeConfig = async (): Promise<void> => {
mihomoApiLogger.info('mihomoUpgradeConfig called')
try {
const instance = await getAxios()
mihomoApiLogger.info('axios instance obtained')
const { diffWorkDir = false } = await getAppConfig()
const { current } = await import('../config').then((mod) => mod.getProfileConfig(true))
const { mihomoWorkConfigPath } = await import('../utils/dirs')
const configPath = diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work')
mihomoApiLogger.info(`config path: ${configPath}`)
const { existsSync } = await import('fs')
if (!existsSync(configPath)) {
mihomoApiLogger.info('config file does not exist, generating...')
const { generateProfile } = await import('./factory')
await generateProfile()
}
const response = await instance.put('/configs?force=true', {
path: configPath
})
mihomoApiLogger.info(`config upgrade request completed ${response?.status || 'no status'}`)
} catch (error) {
mihomoApiLogger.error('Failed to upgrade config', error)
throw error
}
}
// Smart 内核 API
export const mihomoSmartGroupWeights = async (
groupName: string
): Promise<Record<string, number>> => {
const instance = await getAxios()
return await instance.get(`/group/${encodeURIComponent(groupName)}/weights`)
}
export const mihomoSmartFlushCache = async (configName?: string): Promise<void> => {
const instance = await getAxios()
if (configName) {
return await instance.post(`/cache/smart/flush/${encodeURIComponent(configName)}`)
} else {
return await instance.post('/cache/smart/flush')
}
}
export const startMihomoTraffic = async (): Promise<void> => {
trafficRetry = MAX_RETRY
await mihomoTraffic()
}
export const stopMihomoTraffic = (): void => {
trafficRetry = 0
if (mihomoTrafficWs) {
mihomoTrafficWs.removeAllListeners()
if (mihomoTrafficWs.readyState === WebSocket.OPEN) {
@ -259,16 +183,12 @@ export const stopMihomoTraffic = (): void => {
}
const mihomoTraffic = async (): Promise<void> => {
const dynamicIpcPath = getMihomoIpcPath()
const wsUrl = `ws+unix:${dynamicIpcPath}:/traffic`
mihomoApiLogger.info(`Creating traffic WebSocket with URL: ${wsUrl}`)
mihomoTrafficWs = new WebSocket(wsUrl)
mihomoTrafficWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/traffic`)
mihomoTrafficWs.onmessage = async (e): Promise<void> => {
const data = e.data as string
const json = JSON.parse(data) as IMihomoTrafficInfo
trafficRetry = MAX_RETRY
trafficRetry = 10
try {
mainWindow?.webContents.send('mihomoTraffic', json)
if (process.platform !== 'linux') {
@ -288,12 +208,11 @@ const mihomoTraffic = async (): Promise<void> => {
mihomoTrafficWs.onclose = (): void => {
if (trafficRetry) {
trafficRetry--
setTimeout(mihomoTraffic, 1000)
mihomoTraffic()
}
}
mihomoTrafficWs.onerror = (error): void => {
mihomoApiLogger.error('Traffic WebSocket error', error)
mihomoTrafficWs.onerror = (): void => {
if (mihomoTrafficWs) {
mihomoTrafficWs.close()
mihomoTrafficWs = null
@ -302,13 +221,10 @@ const mihomoTraffic = async (): Promise<void> => {
}
export const startMihomoMemory = async (): Promise<void> => {
memoryRetry = MAX_RETRY
await mihomoMemory()
}
export const stopMihomoMemory = (): void => {
memoryRetry = 0
if (mihomoMemoryWs) {
mihomoMemoryWs.removeAllListeners()
if (mihomoMemoryWs.readyState === WebSocket.OPEN) {
@ -319,13 +235,11 @@ export const stopMihomoMemory = (): void => {
}
const mihomoMemory = async (): Promise<void> => {
const dynamicIpcPath = getMihomoIpcPath()
const wsUrl = `ws+unix:${dynamicIpcPath}:/memory`
mihomoMemoryWs = new WebSocket(wsUrl)
mihomoMemoryWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/memory`)
mihomoMemoryWs.onmessage = (e): void => {
const data = e.data as string
memoryRetry = MAX_RETRY
memoryRetry = 10
try {
mainWindow?.webContents.send('mihomoMemory', JSON.parse(data) as IMihomoMemoryInfo)
} catch {
@ -336,7 +250,7 @@ const mihomoMemory = async (): Promise<void> => {
mihomoMemoryWs.onclose = (): void => {
if (memoryRetry) {
memoryRetry--
setTimeout(mihomoMemory, 1000)
mihomoMemory()
}
}
@ -349,13 +263,10 @@ const mihomoMemory = async (): Promise<void> => {
}
export const startMihomoLogs = async (): Promise<void> => {
logsRetry = MAX_RETRY
await mihomoLogs()
}
export const stopMihomoLogs = (): void => {
logsRetry = 0
if (mihomoLogsWs) {
mihomoLogsWs.removeAllListeners()
if (mihomoLogsWs.readyState === WebSocket.OPEN) {
@ -367,14 +278,12 @@ export const stopMihomoLogs = (): void => {
const mihomoLogs = async (): Promise<void> => {
const { 'log-level': logLevel = 'info' } = await getControledMihomoConfig()
const dynamicIpcPath = getMihomoIpcPath()
const wsUrl = `ws+unix:${dynamicIpcPath}:/logs?level=${logLevel}`
mihomoLogsWs = new WebSocket(wsUrl)
mihomoLogsWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/logs?level=${logLevel}`)
mihomoLogsWs.onmessage = (e): void => {
const data = e.data as string
logsRetry = MAX_RETRY
logsRetry = 10
try {
mainWindow?.webContents.send('mihomoLogs', JSON.parse(data) as IMihomoLogInfo)
} catch {
@ -385,7 +294,7 @@ const mihomoLogs = async (): Promise<void> => {
mihomoLogsWs.onclose = (): void => {
if (logsRetry) {
logsRetry--
setTimeout(mihomoLogs, 1000)
mihomoLogs()
}
}
@ -398,13 +307,10 @@ const mihomoLogs = async (): Promise<void> => {
}
export const startMihomoConnections = async (): Promise<void> => {
connectionsRetry = MAX_RETRY
await mihomoConnections()
}
export const stopMihomoConnections = (): void => {
connectionsRetry = 0
if (mihomoConnectionsWs) {
mihomoConnectionsWs.removeAllListeners()
if (mihomoConnectionsWs.readyState === WebSocket.OPEN) {
@ -415,13 +321,11 @@ export const stopMihomoConnections = (): void => {
}
const mihomoConnections = async (): Promise<void> => {
const dynamicIpcPath = getMihomoIpcPath()
const wsUrl = `ws+unix:${dynamicIpcPath}:/connections`
mihomoConnectionsWs = new WebSocket(wsUrl)
mihomoConnectionsWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/connections`)
mihomoConnectionsWs.onmessage = (e): void => {
const data = e.data as string
connectionsRetry = MAX_RETRY
connectionsRetry = 10
try {
mainWindow?.webContents.send('mihomoConnections', JSON.parse(data) as IMihomoConnectionsInfo)
} catch {
@ -432,7 +336,7 @@ const mihomoConnections = async (): Promise<void> => {
mihomoConnectionsWs.onclose = (): void => {
if (connectionsRetry) {
connectionsRetry--
setTimeout(mihomoConnections, 1000)
mihomoConnections()
}
}
@ -443,33 +347,3 @@ const mihomoConnections = async (): Promise<void> => {
}
}
}
export async function SysProxyStatus(): Promise<boolean> {
const appConfig = await getAppConfig()
return appConfig.sysProxy.enable
}
export const TunStatus = async (): Promise<boolean> => {
const config = await getControledMihomoConfig()
return config?.tun?.enable === true
}
export function calculateTrayIconStatus(
sysProxyEnabled: boolean,
tunEnabled: boolean
): 'white' | 'blue' | 'green' | 'red' {
if (sysProxyEnabled && tunEnabled) {
return 'red' // 系统代理 + TUN 同时启用(警告状态)
} else if (sysProxyEnabled) {
return 'blue' // 仅系统代理启用
} else if (tunEnabled) {
return 'green' // 仅 TUN 启用
} else {
return 'white' // 全关
}
}
export async function getTrayIconStatus(): Promise<'white' | 'blue' | 'green' | 'red'> {
const [sysProxyEnabled, tunEnabled] = await Promise.all([SysProxyStatus(), TunStatus()])
return calculateTrayIconStatus(sysProxyEnabled, tunEnabled)
}

View File

@ -1,412 +0,0 @@
import { exec, execFile } from 'child_process'
import { promisify } from 'util'
import { stat } from 'fs/promises'
import { existsSync } from 'fs'
import path from 'path'
import { app, dialog, ipcMain } from 'electron'
import { getAppConfig, patchControledMihomoConfig } from '../config'
import { mihomoCorePath, mihomoCoreDir } from '../utils/dirs'
import { managerLogger } from '../utils/logger'
import i18next from '../../shared/i18n'
const execPromise = promisify(exec)
const execFilePromise = promisify(execFile)
// 内核名称白名单
const ALLOWED_CORES = ['mihomo', 'mihomo-alpha', 'mihomo-smart'] as const
type AllowedCore = (typeof ALLOWED_CORES)[number]
export function isValidCoreName(core: string): core is AllowedCore {
return ALLOWED_CORES.includes(core as AllowedCore)
}
export function validateCorePath(corePath: string): void {
if (corePath.includes('..')) {
throw new Error('Invalid core path: directory traversal detected')
}
const dangerousChars = /[;&|`$(){}[\]<>'"\\]/
if (dangerousChars.test(path.basename(corePath))) {
throw new Error('Invalid core path: contains dangerous characters')
}
const normalizedPath = path.normalize(path.resolve(corePath))
const expectedDir = path.normalize(path.resolve(mihomoCoreDir()))
if (!normalizedPath.startsWith(expectedDir + path.sep) && normalizedPath !== expectedDir) {
throw new Error('Invalid core path: not in expected directory')
}
}
function shellEscape(arg: string): string {
return "'" + arg.replace(/'/g, "'\\''") + "'"
}
// 会话管理员状态缓存
let sessionAdminStatus: boolean | null = null
export async function initAdminStatus(): Promise<void> {
if (process.platform === 'win32' && sessionAdminStatus === null) {
sessionAdminStatus = await checkAdminPrivileges().catch(() => false)
}
}
export function getSessionAdminStatus(): boolean {
if (process.platform !== 'win32') {
return true
}
return sessionAdminStatus ?? false
}
export async function checkAdminPrivileges(): Promise<boolean> {
if (process.platform !== 'win32') {
return true
}
try {
await execPromise('chcp 65001 >nul 2>&1 && fltmc', { encoding: 'utf8' })
managerLogger.info('Admin privileges confirmed via fltmc')
return true
} catch (fltmcError: unknown) {
const errorCode = (fltmcError as { code?: number })?.code || 0
managerLogger.debug(`fltmc failed with code ${errorCode}, trying net session as fallback`)
try {
await execPromise('chcp 65001 >nul 2>&1 && net session', { encoding: 'utf8' })
managerLogger.info('Admin privileges confirmed via net session')
return true
} catch (netSessionError: unknown) {
const netErrorCode = (netSessionError as { code?: number })?.code || 0
managerLogger.debug(
`Both fltmc and net session failed, no admin privileges. Error codes: fltmc=${errorCode}, net=${netErrorCode}`
)
return false
}
}
}
export async function checkMihomoCorePermissions(): Promise<boolean> {
const { core = 'mihomo' } = await getAppConfig()
const corePath = mihomoCorePath(core)
try {
if (process.platform === 'win32') {
return await checkAdminPrivileges()
}
if (process.platform === 'darwin' || process.platform === 'linux') {
const stats = await stat(corePath)
return (stats.mode & 0o4000) !== 0 && stats.uid === 0
}
} catch {
return false
}
return false
}
export async function checkHighPrivilegeCore(): Promise<boolean> {
try {
const { core = 'mihomo' } = await getAppConfig()
const corePath = mihomoCorePath(core)
managerLogger.info(`Checking high privilege core: ${corePath}`)
if (process.platform === 'win32') {
if (!existsSync(corePath)) {
managerLogger.info('Core file does not exist')
return false
}
const hasHighPrivilegeProcess = await checkHighPrivilegeMihomoProcess()
if (hasHighPrivilegeProcess) {
managerLogger.info('Found high privilege mihomo process running')
return true
}
const isAdmin = await checkAdminPrivileges()
managerLogger.info(`Current process admin privileges: ${isAdmin}`)
return isAdmin
}
if (process.platform === 'darwin' || process.platform === 'linux') {
managerLogger.info('Non-Windows platform, skipping high privilege core check')
return false
}
} catch (error) {
managerLogger.error('Failed to check high privilege core', error)
return false
}
return false
}
async function checkHighPrivilegeMihomoProcess(): Promise<boolean> {
const mihomoExecutables =
process.platform === 'win32'
? ['mihomo.exe', 'mihomo-alpha.exe', 'mihomo-smart.exe']
: ['mihomo', 'mihomo-alpha', 'mihomo-smart']
try {
if (process.platform === 'win32') {
for (const executable of mihomoExecutables) {
try {
const { stdout } = await execPromise(
`chcp 65001 >nul 2>&1 && tasklist /FI "IMAGENAME eq ${executable}" /FO CSV`,
{ encoding: 'utf8' }
)
const lines = stdout.split('\n').filter((line) => line.includes(executable))
if (lines.length > 0) {
managerLogger.info(`Found ${lines.length} ${executable} processes running`)
for (const line of lines) {
const parts = line.split(',')
if (parts.length >= 2) {
const pid = parts[1].replace(/"/g, '').trim()
try {
const { stdout: processInfo } = await execPromise(
`powershell -NoProfile -Command "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Process -Id ${pid} | Select-Object Name,Id,Path,CommandLine | ConvertTo-Json"`,
{ encoding: 'utf8' }
)
const processJson = JSON.parse(processInfo)
managerLogger.info(`Process ${pid} info: ${processInfo.substring(0, 200)}`)
if (processJson.Name.includes('mihomo') && processJson.Path === null) {
return true
}
} catch {
managerLogger.info(`Cannot get info for process ${pid}, might be high privilege`)
}
}
}
}
} catch (error) {
managerLogger.error(`Failed to check ${executable} processes`, error)
}
}
} else {
let foundProcesses = false
for (const executable of mihomoExecutables) {
try {
const { stdout } = await execPromise(`ps aux | grep ${executable} | grep -v grep`)
const lines = stdout
.split('\n')
.filter((line) => line.trim() && line.includes(executable))
if (lines.length > 0) {
foundProcesses = true
managerLogger.info(`Found ${lines.length} ${executable} processes running`)
for (const line of lines) {
const parts = line.trim().split(/\s+/)
if (parts.length >= 1) {
const user = parts[0]
managerLogger.info(`${executable} process running as user: ${user}`)
if (user === 'root') {
return true
}
}
}
}
} catch {
// ignore
}
}
if (!foundProcesses) {
managerLogger.info('No mihomo processes found running')
}
}
} catch (error) {
managerLogger.error('Failed to check high privilege mihomo process', error)
}
return false
}
export async function grantTunPermissions(): Promise<void> {
const { core = 'mihomo' } = await getAppConfig()
if (!isValidCoreName(core)) {
throw new Error(`Invalid core name: ${core}. Allowed values: ${ALLOWED_CORES.join(', ')}`)
}
const corePath = mihomoCorePath(core)
validateCorePath(corePath)
if (process.platform === 'darwin') {
const escapedPath = shellEscape(corePath)
const script = `do shell script "chown root:admin ${escapedPath} && chmod +sx ${escapedPath}" with administrator privileges`
await execFilePromise('osascript', ['-e', script])
}
if (process.platform === 'linux') {
await execFilePromise('pkexec', ['chown', 'root:root', corePath])
await execFilePromise('pkexec', ['chmod', '+sx', corePath])
}
if (process.platform === 'win32') {
throw new Error('Windows platform requires running as administrator')
}
}
export async function restartAsAdmin(forTun: boolean = true): Promise<void> {
if (process.platform !== 'win32') {
throw new Error('This function is only available on Windows')
}
// 先停止 Core避免新旧进程冲突
try {
const { stopCore } = await import('./manager')
managerLogger.info('Stopping core before admin restart...')
await stopCore(true)
await new Promise((resolve) => setTimeout(resolve, 500))
} catch (error) {
managerLogger.warn('Failed to stop core before restart:', error)
}
const exePath = process.execPath
const args = process.argv.slice(1).filter((arg) => arg !== '--admin-restart-for-tun')
const restartArgs = forTun ? [...args, '--admin-restart-for-tun'] : args
const escapedExePath = exePath.replace(/'/g, "''")
const argsString = restartArgs.map((arg) => arg.replace(/'/g, "''")).join("', '")
// 使用 Start-Sleep 延迟启动,确保旧进程完全退出后再启动新进程
const command =
restartArgs.length > 0
? `powershell -NoProfile -Command "Start-Sleep -Milliseconds 1000; Start-Process -FilePath '${escapedExePath}' -ArgumentList '${argsString}' -Verb RunAs"`
: `powershell -NoProfile -Command "Start-Sleep -Milliseconds 1000; Start-Process -FilePath '${escapedExePath}' -Verb RunAs"`
managerLogger.info('Restarting as administrator with command', command)
// 先启动 PowerShell它会等待 1 秒),然后立即退出当前进程
exec(command, { windowsHide: true }, (error) => {
if (error) {
managerLogger.error('Failed to start PowerShell for admin restart', error)
}
})
managerLogger.info('PowerShell command started, quitting app immediately')
app.exit(0)
}
export async function requestTunPermissions(): Promise<void> {
if (process.platform === 'win32') {
await restartAsAdmin()
} else {
const hasPermissions = await checkMihomoCorePermissions()
if (!hasPermissions) {
await grantTunPermissions()
}
}
}
export async function showTunPermissionDialog(): Promise<boolean> {
managerLogger.info('Preparing TUN permission dialog...')
const title = i18next.t('tun.permissions.title') || '需要管理员权限'
const message =
i18next.t('tun.permissions.message') ||
'启用 TUN 模式需要管理员权限,是否现在重启应用获取权限?'
const confirmText = i18next.t('common.confirm') || '确认'
const cancelText = i18next.t('common.cancel') || '取消'
const choice = dialog.showMessageBoxSync({
type: 'warning',
title,
message,
buttons: [confirmText, cancelText],
defaultId: 0,
cancelId: 1
})
managerLogger.info(`TUN permission dialog choice: ${choice}`)
return choice === 0
}
export async function showErrorDialog(title: string, message: string): Promise<void> {
const okText = i18next.t('common.confirm') || '确认'
dialog.showMessageBoxSync({
type: 'error',
title,
message,
buttons: [okText],
defaultId: 0
})
}
export async function validateTunPermissionsOnStartup(
_restartCore: () => Promise<void>
): Promise<void> {
const { getControledMihomoConfig } = await import('../config')
const { tun } = await getControledMihomoConfig()
if (!tun?.enable) {
return
}
const hasPermissions = await checkMihomoCorePermissions()
if (!hasPermissions) {
// 启动时没有权限,静默禁用 TUN不弹窗打扰用户
managerLogger.warn(
'TUN is enabled but insufficient permissions detected, auto-disabling TUN...'
)
await patchControledMihomoConfig({ tun: { enable: false } })
const { mainWindow } = await import('../index')
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
managerLogger.info('TUN auto-disabled due to insufficient permissions on startup')
} else {
managerLogger.info('TUN permissions validated successfully')
}
}
export async function checkAdminRestartForTun(restartCore: () => Promise<void>): Promise<void> {
if (process.argv.includes('--admin-restart-for-tun')) {
managerLogger.info('Detected admin restart for TUN mode, auto-enabling TUN...')
try {
if (process.platform === 'win32') {
const hasAdminPrivileges = await checkAdminPrivileges()
if (hasAdminPrivileges) {
await patchControledMihomoConfig({ tun: { enable: true }, dns: { enable: true } })
const { checkAutoRun, enableAutoRun } = await import('../sys/autoRun')
const autoRunEnabled = await checkAutoRun()
if (autoRunEnabled) {
await enableAutoRun()
}
await restartCore()
managerLogger.info('TUN mode auto-enabled after admin restart')
const { mainWindow } = await import('../index')
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
} else {
managerLogger.warn('Admin restart detected but no admin privileges found')
}
}
} catch (error) {
managerLogger.error('Failed to auto-enable TUN after admin restart', error)
}
} else {
await validateTunPermissionsOnStartup(restartCore)
}
}
export function checkTunPermissions(): Promise<boolean> {
return checkMihomoCorePermissions()
}
export function manualGrantCorePermition(): Promise<void> {
return grantTunPermissions()
}

View File

@ -1,139 +0,0 @@
import { exec } from 'child_process'
import { promisify } from 'util'
import { rm } from 'fs/promises'
import { existsSync } from 'fs'
import { managerLogger } from '../utils/logger'
import { getAxios } from './mihomoApi'
const execPromise = promisify(exec)
// 常量
const CORE_READY_MAX_RETRIES = 30
const CORE_READY_RETRY_INTERVAL_MS = 100
export async function cleanupSocketFile(): Promise<void> {
if (process.platform === 'win32') {
await cleanupWindowsNamedPipes()
} else {
await cleanupUnixSockets()
}
}
export async function cleanupWindowsNamedPipes(): Promise<void> {
try {
try {
const { stdout } = await execPromise(
`powershell -NoProfile -Command "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Process | Where-Object {$_.ProcessName -like '*mihomo*'} | Select-Object Id,ProcessName | ConvertTo-Json"`,
{ encoding: 'utf8' }
)
if (stdout.trim()) {
managerLogger.info(`Found potential pipe-blocking processes: ${stdout}`)
try {
const processes = JSON.parse(stdout)
const processArray = Array.isArray(processes) ? processes : [processes]
for (const proc of processArray) {
const pid = proc.Id
if (pid && pid !== process.pid) {
await terminateProcess(pid)
}
}
} catch (parseError) {
managerLogger.warn('Failed to parse process list JSON:', parseError)
await fallbackTextParsing(stdout)
}
}
} catch (error) {
managerLogger.warn('Failed to check mihomo processes:', error)
}
await new Promise((resolve) => setTimeout(resolve, 1000))
} catch (error) {
managerLogger.error('Windows named pipe cleanup failed:', error)
}
}
async function terminateProcess(pid: number): Promise<void> {
try {
process.kill(pid, 0)
process.kill(pid, 'SIGTERM')
managerLogger.info(`Terminated process ${pid} to free pipe`)
} catch (error: unknown) {
if ((error as { code?: string })?.code !== 'ESRCH') {
managerLogger.warn(`Failed to terminate process ${pid}:`, error)
}
}
}
async function fallbackTextParsing(stdout: string): Promise<void> {
const lines = stdout.split('\n').filter((line) => line.includes('mihomo'))
for (const line of lines) {
const match = line.match(/(\d+)/)
if (match) {
const pid = parseInt(match[1])
if (pid !== process.pid) {
await terminateProcess(pid)
}
}
}
}
export async function cleanupUnixSockets(): Promise<void> {
try {
const socketPaths = [
'/tmp/mihomo-party.sock',
'/tmp/mihomo-party-admin.sock',
`/tmp/mihomo-party-${process.getuid?.() || 'user'}.sock`
]
for (const socketPath of socketPaths) {
try {
if (existsSync(socketPath)) {
await rm(socketPath)
managerLogger.info(`Cleaned up socket file: ${socketPath}`)
}
} catch (error) {
managerLogger.warn(`Failed to cleanup socket file ${socketPath}:`, error)
}
}
} catch (error) {
managerLogger.error('Unix socket cleanup failed:', error)
}
}
export async function validateWindowsPipeAccess(pipePath: string): Promise<void> {
try {
managerLogger.info(`Validating pipe access for: ${pipePath}`)
managerLogger.info(`Pipe validation completed for: ${pipePath}`)
} catch (error) {
managerLogger.error('Windows pipe validation failed:', error)
}
}
export async function waitForCoreReady(): Promise<void> {
for (let i = 0; i < CORE_READY_MAX_RETRIES; i++) {
try {
const axios = await getAxios(true)
await axios.get('/')
managerLogger.info(
`Core ready after ${i + 1} attempts (${(i + 1) * CORE_READY_RETRY_INTERVAL_MS}ms)`
)
return
} catch {
if (i === 0) {
managerLogger.info('Waiting for core to be ready...')
}
if (i === CORE_READY_MAX_RETRIES - 1) {
managerLogger.warn(
`Core not ready after ${CORE_READY_MAX_RETRIES} attempts, proceeding anyway`
)
return
}
await new Promise((resolve) => setTimeout(resolve, CORE_READY_RETRY_INTERVAL_MS))
}
}
}

View File

@ -1,141 +1,62 @@
import { Cron } from 'croner'
import { addProfileItem, getCurrentProfileItem, getProfileConfig, getProfileItem } from '../config'
import { logger } from '../utils/logger'
import { addProfileItem, getCurrentProfileItem, getProfileConfig } from '../config'
const intervalPool: Record<string, Cron | NodeJS.Timeout> = {}
const delayedUpdatePool: Record<string, NodeJS.Timeout> = {}
async function updateProfile(id: string): Promise<void> {
const item = await getProfileItem(id)
if (item && item.type === 'remote') {
await addProfileItem(item)
}
}
const intervalPool: Record<string, NodeJS.Timeout> = {}
export async function initProfileUpdater(): Promise<void> {
const { items = [], current } = await getProfileConfig()
const { items, current } = await getProfileConfig()
const currentItem = await getCurrentProfileItem()
for (const item of items.filter((i) => i.id !== current)) {
if (item.type === 'remote' && item.autoUpdate && item.interval) {
const itemId = item.id
if (typeof item.interval === 'number') {
intervalPool[itemId] = setInterval(
async () => {
try {
await updateProfile(itemId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update profile ${itemId}:`, e)
}
},
item.interval * 60 * 1000
)
} else if (typeof item.interval === 'string') {
intervalPool[itemId] = new Cron(item.interval, async () => {
if (item.type === 'remote' && item.interval) {
intervalPool[item.id] = setTimeout(
async () => {
try {
await updateProfile(itemId)
await addProfileItem(item)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update profile ${itemId}:`, e)
/* ignore */
}
})
}
},
item.interval * 60 * 1000
)
try {
await addProfileItem(item)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to init profile ${item.name}:`, e)
/* ignore */
}
}
}
if (currentItem?.type === 'remote' && currentItem.autoUpdate && currentItem.interval) {
const currentId = currentItem.id
if (typeof currentItem.interval === 'number') {
intervalPool[currentId] = setInterval(
async () => {
try {
await updateProfile(currentId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update current profile:`, e)
}
},
currentItem.interval * 60 * 1000
)
delayedUpdatePool[currentId] = setTimeout(
async () => {
delete delayedUpdatePool[currentId]
try {
await updateProfile(currentId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update current profile:`, e)
}
},
currentItem.interval * 60 * 1000 + 10000
)
} else if (typeof currentItem.interval === 'string') {
intervalPool[currentId] = new Cron(currentItem.interval, async () => {
if (currentItem?.type === 'remote' && currentItem.interval) {
intervalPool[currentItem.id] = setTimeout(
async () => {
try {
await updateProfile(currentId)
await addProfileItem(currentItem)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update current profile:`, e)
/* ignore */
}
})
}
},
currentItem.interval * 60 * 1000 + 10000 // +10s
)
try {
await addProfileItem(currentItem)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to init current profile:`, e)
/* ignore */
}
}
}
export async function addProfileUpdater(item: IProfileItem): Promise<void> {
if (item.type === 'remote' && item.autoUpdate && item.interval) {
if (item.type === 'remote' && item.interval) {
if (intervalPool[item.id]) {
if (intervalPool[item.id] instanceof Cron) {
;(intervalPool[item.id] as Cron).stop()
} else {
clearInterval(intervalPool[item.id] as NodeJS.Timeout)
}
clearTimeout(intervalPool[item.id])
}
const itemId = item.id
if (typeof item.interval === 'number') {
intervalPool[itemId] = setInterval(
async () => {
try {
await updateProfile(itemId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update profile ${itemId}:`, e)
}
},
item.interval * 60 * 1000
)
} else if (typeof item.interval === 'string') {
intervalPool[itemId] = new Cron(item.interval, async () => {
intervalPool[item.id] = setTimeout(
async () => {
try {
await updateProfile(itemId)
await addProfileItem(item)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update profile ${itemId}:`, e)
/* ignore */
}
})
}
}
}
export async function removeProfileUpdater(id: string): Promise<void> {
if (intervalPool[id]) {
if (intervalPool[id] instanceof Cron) {
;(intervalPool[id] as Cron).stop()
} else {
clearInterval(intervalPool[id] as NodeJS.Timeout)
}
delete intervalPool[id]
}
if (delayedUpdatePool[id]) {
clearTimeout(delayedUpdatePool[id])
delete delayedUpdatePool[id]
},
item.interval * 60 * 1000
)
}
}

View File

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

View File

@ -1,32 +0,0 @@
import { Notification } from 'electron'
import i18next from 'i18next'
import { addProfileItem } from './config'
import { mainWindow } from './window'
import { safeShowErrorBox } from './utils/init'
export async function handleDeepLink(url: string): Promise<void> {
if (!url.startsWith('clash://') && !url.startsWith('mihomo://')) return
const urlObj = new URL(url)
switch (urlObj.host) {
case 'install-config': {
try {
const profileUrl = urlObj.searchParams.get('url')
const profileName = urlObj.searchParams.get('name')
if (!profileUrl) {
throw new Error(i18next.t('profiles.error.urlParamMissing'))
}
await addProfileItem({
type: 'remote',
name: profileName ?? undefined,
url: profileUrl
})
mainWindow?.webContents.send('profileConfigUpdated')
new Notification({ title: i18next.t('profiles.notification.importSuccess') }).show()
} catch (e) {
safeShowErrorBox('profiles.error.importFailed', `${url}\n${e}`)
}
break
}
}
}

View File

@ -1,119 +1,135 @@
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, dialog } from 'electron'
import i18next from 'i18next'
import { initI18n } from '../shared/i18n'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { registerIpcMainHandlers } from './utils/ipc'
import { getAppConfig, patchAppConfig } from './config'
import {
startCore,
checkAdminRestartForTun,
checkHighPrivilegeCore,
restartAsAdmin,
initAdminStatus,
checkAdminPrivileges,
initCoreWatcher
} from './core/manager'
import { createTray } from './resolve/tray'
import { init, initBasic, safeShowErrorBox } from './utils/init'
import windowStateKeeper from 'electron-window-state'
import { app, shell, BrowserWindow, Menu, dialog, Notification, powerMonitor } from 'electron'
import { addProfileItem, getAppConfig, patchAppConfig } from './config'
import { quitWithoutCore, startCore, stopCore } from './core/manager'
import { triggerSysProxy } from './sys/sysproxy'
import icon from '../../resources/icon.png?asset'
import { createTray, hideDockIcon, showDockIcon } from './resolve/tray'
import { init } from './utils/init'
import { join } from 'path'
import { initShortcut } from './resolve/shortcut'
import { execSync, spawn, exec } from 'child_process'
import { createElevateTask } from './sys/misc'
import { promisify } from 'util'
import { stat } from 'fs/promises'
import { initProfileUpdater } from './core/profileUpdater'
import { existsSync, writeFileSync } from 'fs'
import { exePath, taskDir } from './utils/dirs'
import path from 'path'
import { startMonitor } from './resolve/trafficMonitor'
import { showFloatingWindow } from './resolve/floatingWindow'
import { logger, createLogger } from './utils/logger'
import { initWebdavBackupScheduler } from './resolve/backup'
import {
createWindow,
mainWindow,
showMainWindow,
triggerMainWindow,
closeMainWindow
} from './window'
import { handleDeepLink } from './deeplink'
import {
fixUserDataPermissions,
setupPlatformSpecifics,
setupAppLifecycle,
getSystemLanguage
} from './lifecycle'
import iconv from 'iconv-lite'
import { initI18n } from '../shared/i18n'
import i18next from 'i18next'
const mainLogger = createLogger('Main')
async function fixUserDataPermissions(): Promise<void> {
if (process.platform !== 'darwin') return
export { mainWindow, showMainWindow, triggerMainWindow, closeMainWindow }
const userDataPath = app.getPath('userData')
if (!existsSync(userDataPath)) return
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
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
export let mainWindow: BrowserWindow | null = null
if (process.platform === 'win32' && !is.dev && !process.argv.includes('noadmin')) {
try {
createElevateTask()
} catch (createError) {
try {
if (process.argv.slice(1).length > 0) {
writeFileSync(path.join(taskDir(), 'param.txt'), process.argv.slice(1).join(' '))
} else {
writeFileSync(path.join(taskDir(), 'param.txt'), 'empty')
}
if (!existsSync(path.join(taskDir(), 'mihomo-party-run.exe'))) {
throw new Error('mihomo-party-run.exe not found')
} else {
execSync('%SystemRoot%\\System32\\schtasks.exe /run /tn mihomo-party-run')
}
} catch (e) {
let createErrorStr = `${createError}`
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 {
app.exit()
}
}
}
async function initApp(): Promise<void> {
await fixUserDataPermissions()
}
initApp().catch((e) => {
safeShowErrorBox('common.error.initFailed', `${e}`)
app.quit()
})
initApp()
.then(() => {
const gotTheLock = app.requestSingleInstanceLock()
setupPlatformSpecifics()
async function checkHighPrivilegeCoreEarly(): Promise<void> {
if (process.platform !== 'win32') return
try {
await initBasic()
const isCurrentAppAdmin = await checkAdminPrivileges()
if (isCurrentAppAdmin) return
const hasHighPrivilegeCore = await checkHighPrivilegeCore()
if (!hasHighPrivilegeCore) return
try {
const appConfig = await getAppConfig()
const language = appConfig.language || (app.getLocale().startsWith('zh') ? 'zh-CN' : 'en-US')
await initI18n({ lng: language })
} catch {
await initI18n({ lng: 'zh-CN' })
if (!gotTheLock) {
app.quit()
}
})
.catch(() => {
// ignore permission fix errors
const gotTheLock = app.requestSingleInstanceLock()
const choice = dialog.showMessageBoxSync({
type: 'warning',
title: i18next.t('core.highPrivilege.title'),
message: i18next.t('core.highPrivilege.message'),
buttons: [i18next.t('common.confirm'), i18next.t('common.cancel')],
defaultId: 0,
cancelId: 1
})
if (choice === 0) {
try {
await restartAsAdmin(false)
app.exit(0)
} catch (error) {
safeShowErrorBox('common.error.adminRequired', `${error}`)
app.exit(1)
}
} else {
app.exit(0)
if (!gotTheLock) {
app.quit()
}
} catch (e) {
mainLogger.error('Failed to check high privilege core', e)
}
})
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'
})
}
async function initHardwareAcceleration(): Promise<void> {
try {
await initBasic()
const { disableHardwareAcceleration = false } = await getAppConfig()
if (disableHardwareAcceleration) {
app.disableHardwareAcceleration()
}
} catch (e) {
mainLogger.warn('Failed to read hardware acceleration config', e)
}
if (process.platform === 'linux') {
app.relaunch = customRelaunch
}
initHardwareAcceleration()
setupAppLifecycle()
if (process.platform === 'win32' && !exePath().startsWith('C')) {
// https://github.com/electron/electron/issues/43278
// https://github.com/electron/electron/issues/36698
app.commandLine.appendSwitch('in-process-gpu')
}
const initPromise = init()
app.on('second-instance', async (_event, commandline) => {
showMainWindow()
@ -128,96 +144,248 @@ app.on('open-url', async (_event, url) => {
await handleDeepLink(url)
})
const initPromise = (async () => {
await initBasic()
await checkHighPrivilegeCoreEarly()
await initAdminStatus()
app.on('before-quit', async (e) => {
e.preventDefault()
triggerSysProxy(false)
await stopCore()
app.exit()
})
powerMonitor.on('shutdown', async () => {
triggerSysProxy(false)
await stopCore()
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
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
// Set app user model id for windows
electronApp.setAppUserModelId('party.mihomo.app')
try {
await init()
const appConfig = await getAppConfig()
// 如果配置中没有语言设置,则使用系统语言
if (!appConfig.language) {
const systemLanguage = getSystemLanguage()
await patchAppConfig({ language: systemLanguage })
appConfig.language = systemLanguage
}
await initI18n({ lng: appConfig.language })
return appConfig
await initPromise
} catch (e) {
safeShowErrorBox('common.error.initFailed', `${e}`)
dialog.showErrorBox(i18next.t('common.error.initFailed'), `${e}`)
app.quit()
throw e
}
})()
app.whenReady().then(async () => {
electronApp.setAppUserModelId('party.mihomo.app')
const appConfig = await initPromise
try {
const [startPromise] = await startCore()
startPromise.then(async () => {
await initProfileUpdater()
})
} catch (e) {
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`)
}
try {
await startMonitor()
} catch {
// ignore
}
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
const { showFloatingWindow: showFloating = false, disableTray = false } = await getAppConfig()
registerIpcMainHandlers()
const createWindowPromise = createWindow()
let coreStarted = false
const coreStartPromise = (async (): Promise<void> => {
try {
initCoreWatcher()
const startPromises = await startCore()
if (startPromises.length > 0) {
startPromises[0].then(async () => {
await initProfileUpdater()
await initWebdavBackupScheduler()
await checkAdminRestartForTun()
})
}
coreStarted = true
} catch (e) {
safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`)
}
})()
const monitorPromise = (async (): Promise<void> => {
try {
await startMonitor()
} catch {
// ignore
}
})()
await createWindowPromise
const { showFloatingWindow: showFloating = false, disableTray = false } = appConfig
const uiTasks: Promise<void>[] = [initShortcut()]
await createWindow()
if (showFloating) {
uiTasks.push(
(async () => {
try {
await showFloatingWindow()
} catch (error) {
await logger.error('Failed to create floating window on startup', error)
}
})()
)
showFloatingWindow()
}
if (!disableTray) {
uiTasks.push(createTray())
await createTray()
}
await Promise.all(uiTasks)
await Promise.all([coreStartPromise, monitorPromise])
if (coreStarted) {
mainWindow?.webContents.send('core-started')
}
app.on('activate', () => {
await initShortcut()
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
showMainWindow()
})
})
async function handleDeepLink(url: string): Promise<void> {
if (!url.startsWith('clash://') && !url.startsWith('mihomo://')) return
const urlObj = new URL(url)
switch (urlObj.host) {
case 'install-config': {
try {
const profileUrl = urlObj.searchParams.get('url')
const profileName = urlObj.searchParams.get('name')
if (!profileUrl) {
throw new Error(i18next.t('profiles.error.urlParamMissing'))
}
await addProfileItem({
type: 'remote',
name: profileName ?? undefined,
url: profileUrl
})
mainWindow?.webContents.send('profileConfigUpdated')
new Notification({ title: i18next.t('profiles.notification.importSuccess') }).show()
break
} catch (e) {
dialog.showErrorBox(i18next.t('profiles.error.importFailed'), `${url}\n${e}`)
}
}
}
}
export async function createWindow(): Promise<void> {
const { useWindowFrame = false } = await getAppConfig()
const mainWindowState = windowStateKeeper({
defaultWidth: 800,
defaultHeight: 600,
file: 'window-state.json'
})
// https://github.com/electron/electron/issues/16521#issuecomment-582955104
Menu.setApplicationMenu(null)
mainWindow = new BrowserWindow({
minWidth: 800,
minHeight: 600,
width: mainWindowState.width,
height: mainWindowState.height,
x: mainWindowState.x,
y: mainWindowState.y,
show: false,
frame: useWindowFrame,
fullscreenable: false,
titleBarStyle: useWindowFrame ? 'default' : 'hidden',
titleBarOverlay: useWindowFrame
? false
: {
height: 49
},
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon: icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
spellcheck: false,
sandbox: false,
devTools: true
}
})
mainWindowState.manage(mainWindow)
mainWindow.on('ready-to-show', async () => {
const {
silentStart = false,
autoQuitWithoutCore = false,
autoQuitWithoutCoreDelay = 60
} = await getAppConfig()
if (autoQuitWithoutCore && !mainWindow?.isVisible()) {
if (quitTimeout) {
clearTimeout(quitTimeout)
}
quitTimeout = setTimeout(async () => {
await quitWithoutCore()
}, autoQuitWithoutCoreDelay * 1000)
}
if (!silentStart) {
if (quitTimeout) {
clearTimeout(quitTimeout)
}
mainWindow?.show()
mainWindow?.focusOnWebView()
}
})
mainWindow.webContents.on('did-fail-load', () => {
mainWindow?.webContents.reload()
})
mainWindow.on('show', () => {
showDockIcon()
})
mainWindow.on('close', async (event) => {
event.preventDefault()
mainWindow?.hide()
const {
autoQuitWithoutCore = false,
autoQuitWithoutCoreDelay = 60,
useDockIcon = true
} = await getAppConfig()
if (!useDockIcon) {
hideDockIcon()
}
if (autoQuitWithoutCore) {
if (quitTimeout) {
clearTimeout(quitTimeout)
}
quitTimeout = setTimeout(async () => {
await quitWithoutCore()
}, autoQuitWithoutCoreDelay * 1000)
}
})
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) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// 在开发模式下自动打开 DevTools
if (is.dev) {
mainWindow.webContents.openDevTools()
}
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
export function triggerMainWindow(): void {
if (mainWindow?.isVisible()) {
closeMainWindow()
} else {
showMainWindow()
}
}
export function showMainWindow(): void {
if (mainWindow) {
if (quitTimeout) {
clearTimeout(quitTimeout)
}
mainWindow.show()
mainWindow.focusOnWebView()
}
}
export function closeMainWindow(): void {
if (mainWindow) {
mainWindow.close()
}
}

View File

@ -1,76 +0,0 @@
import { spawn, exec } from 'child_process'
import { promisify } from 'util'
import { stat } from 'fs/promises'
import { existsSync } from 'fs'
import { app, powerMonitor } from 'electron'
import { stopCore, cleanupCoreWatcher } from './core/manager'
import { triggerSysProxy } from './sys/sysproxy'
import { exePath } from './utils/dirs'
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], {
detached: true,
stdio: 'ignore'
})
}
export 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
}
}
export function setupPlatformSpecifics(): void {
if (process.platform === 'linux') {
app.relaunch = customRelaunch
}
if (process.platform === 'win32' && !exePath().startsWith('C')) {
app.commandLine.appendSwitch('in-process-gpu')
}
}
export function setupAppLifecycle(): void {
app.on('before-quit', async (e) => {
e.preventDefault()
cleanupCoreWatcher()
await triggerSysProxy(false)
await stopCore()
app.exit()
})
powerMonitor.on('shutdown', async () => {
cleanupCoreWatcher()
triggerSysProxy(false)
await stopCore()
app.exit()
})
}
export function getSystemLanguage(): 'zh-CN' | 'en-US' {
const locale = app.getLocale()
return locale.startsWith('zh') ? 'zh-CN' : 'en-US'
}

View File

@ -1,21 +1,18 @@
import axios from 'axios'
import yaml from 'yaml'
import { app, shell } from 'electron'
import { getControledMihomoConfig } from '../config'
import { dataDir, exeDir, exePath, isPortable, resourcesFilesDir } from '../utils/dirs'
import { copyFile, rm, writeFile } from 'fs/promises'
import path from 'path'
import { existsSync } from 'fs'
import os from 'os'
import { exec, execSync, spawn } from 'child_process'
import { promisify } from 'util'
import { app, shell } from 'electron'
import i18next from 'i18next'
import { appLogger } from '../utils/logger'
import { dataDir, exeDir, exePath, isPortable, resourcesFilesDir } from '../utils/dirs'
import { getControledMihomoConfig } from '../config'
import { checkAdminPrivileges } from '../core/manager'
import { parse } from '../utils/yaml'
import * as chromeRequest from '../utils/chromeRequest'
export async function checkUpdate(): Promise<IAppVersion | undefined> {
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
const res = await chromeRequest.get(
const res = await axios.get(
'https://github.com/mihomo-party-org/mihomo-party/releases/latest/download/latest.yml',
{
headers: { 'Content-Type': 'application/octet-stream' },
@ -27,49 +24,31 @@ export async function checkUpdate(): Promise<IAppVersion | undefined> {
responseType: 'text'
}
)
const latest = parse(res.data as string) as IAppVersion
const latest = yaml.parse(res.data, { merge: true }) as IAppVersion
const currentVersion = app.getVersion()
if (compareVersions(latest.version, currentVersion) > 0) {
if (latest.version !== currentVersion) {
return latest
} else {
return undefined
}
}
// 1:新 -1:旧 0:相同
function compareVersions(a: string, b: string): number {
const parsePart = (part: string) => {
const numPart = part.split('-')[0]
const num = parseInt(numPart, 10)
return isNaN(num) ? 0 : num
}
const v1 = a.replace(/^v/, '').split('.').map(parsePart)
const v2 = b.replace(/^v/, '').split('.').map(parsePart)
for (let i = 0; i < Math.max(v1.length, v2.length); i++) {
const num1 = v1[i] || 0
const num2 = v2[i] || 0
if (num1 > num2) return 1
if (num1 < num2) return -1
}
return 0
}
export async function downloadAndInstallUpdate(version: string): Promise<void> {
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
const baseUrl = `https://github.com/mihomo-party-org/mihomo-party/releases/download/v${version}/`
const fileMap = {
'win32-x64': `clash-party-windows-${version}-x64-setup.exe`,
'win32-ia32': `clash-party-windows-${version}-ia32-setup.exe`,
'win32-arm64': `clash-party-windows-${version}-arm64-setup.exe`,
'darwin-x64': `clash-party-macos-${version}-x64.pkg`,
'darwin-arm64': `clash-party-macos-${version}-arm64.pkg`
'win32-x64': `mihomo-party-windows-${version}-x64-setup.exe`,
'win32-ia32': `mihomo-party-windows-${version}-ia32-setup.exe`,
'win32-arm64': `mihomo-party-windows-${version}-arm64-setup.exe`,
'darwin-x64': `mihomo-party-macos-${version}-x64.pkg`,
'darwin-arm64': `mihomo-party-macos-${version}-arm64.pkg`
}
let file = fileMap[`${process.platform}-${process.arch}`]
if (isPortable()) {
file = file.replace('-setup.exe', '-portable.7z')
}
if (!file) {
throw new Error(i18next.t('common.error.autoUpdateNotSupported'))
throw new Error('不支持自动更新,请手动下载更新')
}
if (process.platform === 'win32' && parseInt(os.release()) < 10) {
file = file.replace('windows', 'win7')
@ -84,7 +63,7 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
}
try {
if (!existsSync(path.join(dataDir(), file))) {
const res = await chromeRequest.get(`${baseUrl}${file}`, {
const res = await axios.get(`${baseUrl}${file}`, {
responseType: 'arraybuffer',
proxy: {
protocol: 'http',
@ -95,52 +74,13 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
'Content-Type': 'application/octet-stream'
}
})
await writeFile(path.join(dataDir(), file), res.data as string | Buffer)
await writeFile(path.join(dataDir(), file), res.data)
}
if (file.endsWith('.exe')) {
try {
const installerPath = path.join(dataDir(), file)
const isAdmin = await checkAdminPrivileges()
if (isAdmin) {
await appLogger.info('Running installer with existing admin privileges')
spawn(installerPath, ['/S', '--force-run'], {
detached: true,
stdio: 'ignore'
}).unref()
} else {
// 提升权限安装
const escapedPath = installerPath.replace(/'/g, "''")
const args = ['/S', '--force-run']
const argsString = args.map((arg) => arg.replace(/'/g, "''")).join("', '")
const command = `powershell -NoProfile -Command "Start-Process -FilePath '${escapedPath}' -ArgumentList '${argsString}' -Verb RunAs -WindowStyle Hidden"`
await appLogger.info('Starting installer with elevated privileges')
const execPromise = promisify(exec)
await execPromise(command, { windowsHide: true })
await appLogger.info('Installer started successfully with elevation')
}
} catch (installerError) {
await appLogger.error('Failed to start installer, trying fallback', installerError)
// Fallback: 尝试使用 shell.openPath 打开安装包
try {
await shell.openPath(path.join(dataDir(), file))
await appLogger.info('Opened installer with shell.openPath as fallback')
} catch (fallbackError) {
await appLogger.error('Fallback method also failed', fallbackError)
const installerErrorMessage =
installerError instanceof Error ? installerError.message : String(installerError)
const fallbackErrorMessage =
fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
throw new Error(
`Failed to execute installer: ${installerErrorMessage}. Fallback also failed: ${fallbackErrorMessage}`
)
}
}
spawn(path.join(dataDir(), file), ['/S', '--force-run'], {
detached: true,
stdio: 'ignore'
}).unref()
}
if (file.endsWith('.7z')) {
await copyFile(path.join(resourcesFilesDir(), '7za.exe'), path.join(dataDir(), '7za.exe'))

View File

@ -1,11 +1,6 @@
import https from 'https'
import { existsSync } from 'fs'
import { getAppConfig } from '../config'
import dayjs from 'dayjs'
import AdmZip from 'adm-zip'
import { Cron } from 'croner'
import { dialog } from 'electron'
import i18next from 'i18next'
import { systemLogger } from '../utils/logger'
import {
appConfigPath,
controledMihomoConfigPath,
@ -14,86 +9,36 @@ import {
overrideDir,
profileConfigPath,
profilesDir,
rulesDir,
subStoreDir,
themesDir
} from '../utils/dirs'
import { getAppConfig } from '../config'
let backupCronJob: Cron | null = null
interface WebDAVContext {
client: ReturnType<Awaited<typeof import('webdav/dist/node/index.js')>['createClient']>
webdavDir: string
webdavMaxBackups: number
}
async function getWebDAVClient(): Promise<WebDAVContext> {
export async function webdavBackup(): Promise<boolean> {
const { createClient } = await import('webdav/dist/node/index.js')
const {
webdavUrl = '',
webdavUsername = '',
webdavPassword = '',
webdavDir = 'clash-party',
webdavMaxBackups = 0,
webdavIgnoreCert = false
webdavDir = 'mihomo-party',
webdavMaxBackups = 0
} = await getAppConfig()
const clientOptions: Parameters<typeof createClient>[1] = {
username: webdavUsername,
password: webdavPassword
}
if (webdavIgnoreCert) {
clientOptions.httpsAgent = new https.Agent({
rejectUnauthorized: false
})
}
const client = createClient(webdavUrl, clientOptions)
return { client, webdavDir, webdavMaxBackups }
}
function createBackupZip(): AdmZip {
const zip = new AdmZip()
const files = [
appConfigPath(),
controledMihomoConfigPath(),
profileConfigPath(),
overrideConfigPath()
]
const folders = [
{ path: themesDir(), name: 'themes' },
{ path: profilesDir(), name: 'profiles' },
{ path: overrideDir(), name: 'override' },
{ path: rulesDir(), name: 'rules' },
{ path: subStoreDir(), name: 'substore' }
]
for (const file of files) {
if (existsSync(file)) {
zip.addLocalFile(file)
}
}
for (const { path, name } of folders) {
if (existsSync(path)) {
zip.addLocalFolder(path, name)
}
}
return zip
}
export async function webdavBackup(): Promise<boolean> {
const { client, webdavDir, webdavMaxBackups } = await getWebDAVClient()
const zip = createBackupZip()
zip.addLocalFile(appConfigPath())
zip.addLocalFile(controledMihomoConfigPath())
zip.addLocalFile(profileConfigPath())
zip.addLocalFile(overrideConfigPath())
zip.addLocalFolder(themesDir(), 'themes')
zip.addLocalFolder(profilesDir(), 'profiles')
zip.addLocalFolder(overrideDir(), 'override')
zip.addLocalFolder(subStoreDir(), 'substore')
const date = new Date()
const zipFileName = `${process.platform}_${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip`
const client = createClient(webdavUrl, {
username: webdavUsername,
password: webdavPassword
})
try {
await client.createDirectory(webdavDir)
} catch {
@ -130,7 +75,7 @@ export async function webdavBackup(): Promise<boolean> {
}
}
} catch (error) {
await systemLogger.error('Failed to clean up old backup files', error)
console.error('Failed to clean up old backup files:', error)
}
}
@ -138,14 +83,36 @@ export async function webdavBackup(): Promise<boolean> {
}
export async function webdavRestore(filename: string): Promise<void> {
const { client, webdavDir } = await getWebDAVClient()
const { createClient } = await import('webdav/dist/node/index.js')
const {
webdavUrl = '',
webdavUsername = '',
webdavPassword = '',
webdavDir = 'mihomo-party'
} = await getAppConfig()
const client = createClient(webdavUrl, {
username: webdavUsername,
password: webdavPassword
})
const zipData = await client.getFileContents(`${webdavDir}/${filename}`)
const zip = new AdmZip(zipData as Buffer)
zip.extractAllTo(dataDir(), true)
}
export async function listWebdavBackups(): Promise<string[]> {
const { client, webdavDir } = await getWebDAVClient()
const { createClient } = await import('webdav/dist/node/index.js')
const {
webdavUrl = '',
webdavUsername = '',
webdavPassword = '',
webdavDir = 'mihomo-party'
} = await getAppConfig()
const client = createClient(webdavUrl, {
username: webdavUsername,
password: webdavPassword
})
const files = await client.getDirectoryContents(webdavDir, { glob: '*.zip' })
if (Array.isArray(files)) {
return files.map((file) => file.basename)
@ -155,110 +122,17 @@ export async function listWebdavBackups(): Promise<string[]> {
}
export async function webdavDelete(filename: string): Promise<void> {
const { client, webdavDir } = await getWebDAVClient()
const { createClient } = await import('webdav/dist/node/index.js')
const {
webdavUrl = '',
webdavUsername = '',
webdavPassword = '',
webdavDir = 'mihomo-party'
} = await getAppConfig()
const client = createClient(webdavUrl, {
username: webdavUsername,
password: webdavPassword
})
await client.deleteFile(`${webdavDir}/${filename}`)
}
/**
* WebDAV
*/
export async function initWebdavBackupScheduler(): Promise<void> {
try {
// 先停止现有的定时任务
if (backupCronJob) {
backupCronJob.stop()
backupCronJob = null
}
const { webdavBackupCron } = await getAppConfig()
// 如果配置了 Cron 表达式,则启动定时任务
if (webdavBackupCron) {
backupCronJob = new Cron(webdavBackupCron, async () => {
try {
await webdavBackup()
await systemLogger.info('WebDAV backup completed successfully via cron job')
} catch (error) {
await systemLogger.error('Failed to execute WebDAV backup via cron job', error)
}
})
await systemLogger.info(`WebDAV backup scheduler initialized with cron: ${webdavBackupCron}`)
await systemLogger.info(`WebDAV backup scheduler nextRun: ${backupCronJob.nextRun()}`)
} else {
await systemLogger.info('WebDAV backup scheduler disabled (no cron expression configured)')
}
} catch (error) {
await systemLogger.error('Failed to initialize WebDAV backup scheduler', error)
}
}
/**
* WebDAV
*/
export async function stopWebdavBackupScheduler(): Promise<void> {
if (backupCronJob) {
backupCronJob.stop()
backupCronJob = null
await systemLogger.info('WebDAV backup scheduler stopped')
}
}
/**
* WebDAV
*
*/
export async function reinitScheduler(): Promise<void> {
await systemLogger.info('Reinitializing WebDAV backup scheduler...')
await stopWebdavBackupScheduler()
await initWebdavBackupScheduler()
await systemLogger.info('WebDAV backup scheduler reinitialized successfully')
}
/**
*
*/
export async function exportLocalBackup(): Promise<boolean> {
const zip = createBackupZip()
const date = new Date()
const zipFileName = `clash-party-backup-${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip`
const result = await dialog.showSaveDialog({
title: i18next.t('localBackup.export.title'),
defaultPath: zipFileName,
filters: [
{ name: 'ZIP Files', extensions: ['zip'] },
{ name: 'All Files', extensions: ['*'] }
]
})
if (!result.canceled && result.filePath) {
zip.writeZip(result.filePath)
await systemLogger.info(`Local backup exported to: ${result.filePath}`)
return true
}
return false
}
/**
*
*/
export async function importLocalBackup(): Promise<boolean> {
const result = await dialog.showOpenDialog({
title: i18next.t('localBackup.import.title'),
filters: [
{ name: 'ZIP Files', extensions: ['zip'] },
{ name: 'All Files', extensions: ['*'] }
],
properties: ['openFile']
})
if (!result.canceled && result.filePaths.length > 0) {
const filePath = result.filePaths[0]
const zip = new AdmZip(filePath)
zip.extractAllTo(dataDir(), true)
await systemLogger.info(`Local backup imported from: ${filePath}`)
return true
}
return false
}

View File

@ -1,121 +1,66 @@
import { join } from 'path'
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 { floatingWindowLogger } from '../utils/logger'
import { applyTheme } from './theme'
import { buildContextMenu, showTrayIcon } from './tray'
export let floatingWindow: BrowserWindow | null = null
function logError(message: string, error?: unknown): void {
floatingWindowLogger.log(`FloatingWindow Error: ${message}`, error).catch(() => {})
}
async function createFloatingWindow(): Promise<void> {
try {
const floatingWindowState = windowStateKeeper({ file: 'floating-window-state.json' })
const { customTheme = 'default.css', floatingWindowCompatMode = true } = await getAppConfig()
const safeMode = process.env.FLOATING_SAFE_MODE === 'true'
const useCompatMode =
floatingWindowCompatMode || process.env.FLOATING_COMPAT_MODE === 'true' || safeMode
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: 120,
height: 42,
x: floatingWindowState.x,
y: floatingWindowState.y,
show: false,
frame: safeMode,
alwaysOnTop: !safeMode,
resizable: safeMode,
transparent: !safeMode && !useCompatMode,
skipTaskbar: !safeMode,
minimizable: safeMode,
maximizable: safeMode,
fullscreenable: false,
closable: safeMode,
backgroundColor: safeMode ? '#ffffff' : useCompatMode ? '#f0f0f0' : '#00000000',
webPreferences: {
preload: join(__dirname, '../preload/index.cjs'),
spellcheck: false,
sandbox: false,
nodeIntegration: false,
contextIsolation: true
}
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
}
if (process.platform === 'win32') {
windowOptions.hasShadow = !safeMode
if (windowOptions.webPreferences) {
windowOptions.webPreferences.offscreen = 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')
}
floatingWindow = new BrowserWindow(windowOptions)
floatingWindowState.manage(floatingWindow)
// 事件监听器
floatingWindow.webContents.on('render-process-gone', (_, details) => {
logError('Render process gone', details.reason)
floatingWindow = null
})
floatingWindow.on('ready-to-show', () => {
applyTheme(customTheme)
floatingWindow?.show()
floatingWindow?.setAlwaysOnTop(true, 'screen-saver')
})
floatingWindow.on('moved', () => {
if (floatingWindow) {
floatingWindowState.saveState(floatingWindow)
}
})
// IPC 监听器
ipcMain.removeAllListeners('updateFloatingWindow')
ipcMain.on('updateFloatingWindow', () => {
if (floatingWindow) {
floatingWindow.webContents.send('controledMihomoConfigUpdated')
floatingWindow.webContents.send('appConfigUpdated')
}
})
// 加载页面
const url =
is.dev && process.env['ELECTRON_RENDERER_URL']
? `${process.env['ELECTRON_RENDERER_URL']}/floating.html`
: join(__dirname, '../renderer/floating.html')
is.dev ? await floatingWindow.loadURL(url) : await floatingWindow.loadFile(url)
} catch (error) {
logError('Failed to create floating window', error)
floatingWindow = null
throw error
})
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> {
try {
if (floatingWindow && !floatingWindow.isDestroyed()) {
floatingWindow.show()
} else {
await createFloatingWindow()
}
} catch (error) {
logError('Failed to show floating window', error)
// 如果已经是兼容模式还是崩溃,自动禁用悬浮窗
const { floatingWindowCompatMode = true } = await getAppConfig()
if (floatingWindowCompatMode) {
await patchAppConfig({ showFloatingWindow: false })
} else {
await patchAppConfig({ floatingWindowCompatMode: true })
}
throw error
if (floatingWindow) {
floatingWindow.show()
} else {
createFloatingWindow()
}
}
@ -131,8 +76,8 @@ export async function triggerFloatingWindow(): Promise<void> {
export async function closeFloatingWindow(): Promise<void> {
if (floatingWindow) {
ipcMain.removeAllListeners('updateFloatingWindow')
floatingWindow.close()
floatingWindow.destroy()
floatingWindow = null
}
await showTrayIcon()

View File

@ -1,4 +1,4 @@
import * as chromeRequest from '../utils/chromeRequest'
import axios from 'axios'
import { getAppConfig, getControledMihomoConfig } from '../config'
import { getRuntimeConfigStr } from '../core/factory'
@ -10,7 +10,7 @@ interface GistInfo {
async function listGists(token: string): Promise<GistInfo[]> {
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
const res = await chromeRequest.get('https://api.github.com/gists', {
const res = await axios.get('https://api.github.com/gists', {
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
@ -23,17 +23,17 @@ async function listGists(token: string): Promise<GistInfo[]> {
},
responseType: 'json'
})
return Array.isArray(res.data) ? res.data : []
return res.data as GistInfo[]
}
async function createGist(token: string, content: string): Promise<void> {
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
await chromeRequest.post(
return await axios.post(
'https://api.github.com/gists',
{
description: 'Auto Synced Clash Party Runtime Config',
description: 'Auto Synced Mihomo Party Runtime Config',
public: false,
files: { 'clash-party.yaml': { content } }
files: { 'mihomo-party.yaml': { content } }
},
{
headers: {
@ -52,11 +52,11 @@ async function createGist(token: string, content: string): Promise<void> {
async function updateGist(token: string, id: string, content: string): Promise<void> {
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
await chromeRequest.patch(
return await axios.patch(
`https://api.github.com/gists/${id}`,
{
description: 'Auto Synced Clash Party Runtime Config',
files: { 'clash-party.yaml': { content } }
description: 'Auto Synced Mihomo Party Runtime Config',
files: { 'mihomo-party.yaml': { content } }
},
{
headers: {
@ -77,13 +77,15 @@ export async function getGistUrl(): Promise<string> {
const { githubToken } = await getAppConfig()
if (!githubToken) return ''
const gists = await listGists(githubToken)
const gist = gists.find((gist) => gist.description === 'Auto Synced Clash Party Runtime Config')
const gist = gists.find((gist) => gist.description === 'Auto Synced Mihomo Party Runtime Config')
if (gist) {
return gist.html_url
} else {
await uploadRuntimeConfig()
const gists = await listGists(githubToken)
const gist = gists.find((gist) => gist.description === 'Auto Synced Clash Party Runtime Config')
const gist = gists.find(
(gist) => gist.description === 'Auto Synced Mihomo Party Runtime Config'
)
if (!gist) throw new Error('Gist not found')
return gist.html_url
}
@ -93,7 +95,7 @@ export async function uploadRuntimeConfig(): Promise<void> {
const { githubToken } = await getAppConfig()
if (!githubToken) return
const gists = await listGists(githubToken)
const gist = gists.find((gist) => gist.description === 'Auto Synced Clash Party Runtime Config')
const gist = gists.find((gist) => gist.description === 'Auto Synced Mihomo Party Runtime Config')
const config = await getRuntimeConfigStr()
if (gist) {
await updateGist(githubToken, gist.id, config)

View File

@ -1,4 +1,7 @@
import { getAppConfig, getControledMihomoConfig } from '../config'
import { Worker } from 'worker_threads'
import { dataDir, mihomoWorkDir, subStoreDir, substoreLogPath } from '../utils/dirs'
import subStoreIcon from '../../../resources/subStoreIcon.png?asset'
import { createWriteStream, existsSync, mkdirSync } from 'fs'
import { writeFile, rm, cp } from 'fs/promises'
import http from 'http'
@ -6,12 +9,8 @@ import net from 'net'
import path from 'path'
import { nativeImage } from 'electron'
import express from 'express'
import axios from 'axios'
import AdmZip from 'adm-zip'
import * as chromeRequest from '../utils/chromeRequest'
import subStoreIcon from '../../../resources/subStoreIcon.png?asset'
import { dataDir, mihomoWorkDir, subStoreDir, substoreLogPath } from '../utils/dirs'
import { getAppConfig, getControledMihomoConfig } from '../config'
import { systemLogger } from '../utils/logger'
export let pacPort: number
export let subStorePort: number
@ -112,14 +111,14 @@ export async function startSubStoreBackendServer(): Promise<void> {
SUB_STORE_BACKEND_API_HOST: subStoreHost,
SUB_STORE_DATA_BASE_PATH: subStoreDir(),
SUB_STORE_BACKEND_CUSTOM_ICON: icon.toDataURL(),
SUB_STORE_BACKEND_CUSTOM_NAME: 'Clash Party',
SUB_STORE_BACKEND_CUSTOM_NAME: 'Mihomo Party',
SUB_STORE_BACKEND_SYNC_CRON: subStoreBackendSyncCron,
SUB_STORE_BACKEND_DOWNLOAD_CRON: subStoreBackendDownloadCron,
SUB_STORE_BACKEND_UPLOAD_CRON: subStoreBackendUploadCron,
SUB_STORE_MMDB_COUNTRY_PATH: path.join(mihomoWorkDir(), 'country.mmdb'),
SUB_STORE_MMDB_ASN_PATH: path.join(mihomoWorkDir(), 'ASN.mmdb')
}
subStoreBackendWorker = new Worker(path.join(mihomoWorkDir(), 'sub-store.bundle.cjs'), {
subStoreBackendWorker = new Worker(path.join(mihomoWorkDir(), 'sub-store.bundle.js'), {
env: useProxyInSubStore
? {
...env,
@ -143,7 +142,7 @@ export async function stopSubStoreBackendServer(): Promise<void> {
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.cjs')
const backendPath = path.join(mihomoWorkDir(), 'sub-store.bundle.js')
const tempDir = path.join(dataDir(), 'temp')
try {
@ -154,8 +153,8 @@ export async function downloadSubStore(): Promise<void> {
mkdirSync(tempDir, { recursive: true })
// 下载后端文件
const tempBackendPath = path.join(tempDir, 'sub-store.bundle.cjs')
const backendRes = await chromeRequest.get(
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',
@ -167,9 +166,10 @@ export async function downloadSubStore(): Promise<void> {
}
}
)
await writeFile(tempBackendPath, Buffer.from(backendRes.data as Buffer))
await writeFile(tempBackendPath, Buffer.from(backendRes.data))
// 下载前端文件
const frontendRes = await chromeRequest.get(
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',
@ -182,7 +182,7 @@ export async function downloadSubStore(): Promise<void> {
}
)
// 先解压到临时目录
const zip = new AdmZip(Buffer.from(frontendRes.data as Buffer))
const zip = new AdmZip(Buffer.from(frontendRes.data))
zip.extractAllTo(tempDir, true)
await cp(tempBackendPath, backendPath)
if (existsSync(frontendDir)) {
@ -192,7 +192,7 @@ export async function downloadSubStore(): Promise<void> {
await cp(path.join(tempDir, 'dist'), frontendDir, { recursive: true })
await rm(tempDir, { recursive: true })
} catch (error) {
await systemLogger.error('substore.downloadFailed', error)
console.error('substore.downloadFailed:', error)
throw error
}
}

View File

@ -1,5 +1,5 @@
import { app, globalShortcut, ipcMain, Notification } from 'electron'
import { mainWindow, triggerMainWindow } from '../window'
import { mainWindow, triggerMainWindow } from '..'
import {
getAppConfig,
getControledMihomoConfig,
@ -9,9 +9,8 @@ import {
import { triggerSysProxy } from '../sys/sysproxy'
import { patchMihomoConfig } from '../core/mihomoApi'
import { quitWithoutCore, restartCore } from '../core/manager'
import i18next from '../../shared/i18n'
import { floatingWindow, triggerFloatingWindow } from './floatingWindow'
import { updateTrayIcon } from './tray'
import i18next from '../../shared/i18n'
export async function registerShortcut(
oldShortcut: string,
@ -27,7 +26,7 @@ export async function registerShortcut(
switch (action) {
case 'showWindowShortcut': {
return globalShortcut.register(newShortcut, () => {
triggerMainWindow(true)
triggerMainWindow()
})
}
case 'showFloatingWindowShortcut': {
@ -44,11 +43,7 @@ export async function registerShortcut(
await triggerSysProxy(!enable)
await patchAppConfig({ sysProxy: { enable: !enable } })
new Notification({
title: i18next.t(
!enable
? 'common.notification.systemProxyEnabled'
: 'common.notification.systemProxyDisabled'
)
title: i18next.t(!enable ? 'common.notification.systemProxyEnabled' : 'common.notification.systemProxyDisabled')
}).show()
mainWindow?.webContents.send('appConfigUpdated')
floatingWindow?.webContents.send('appConfigUpdated')
@ -56,7 +51,6 @@ export async function registerShortcut(
// ignore
} finally {
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
})
}
@ -72,9 +66,7 @@ export async function registerShortcut(
}
await restartCore()
new Notification({
title: i18next.t(
!enable ? 'common.notification.tunEnabled' : 'common.notification.tunDisabled'
)
title: i18next.t(!enable ? 'common.notification.tunEnabled' : 'common.notification.tunDisabled')
}).show()
mainWindow?.webContents.send('controledMihomoConfigUpdated')
floatingWindow?.webContents.send('appConfigUpdated')
@ -82,7 +74,6 @@ export async function registerShortcut(
// ignore
} finally {
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
})
}
@ -95,7 +86,6 @@ export async function registerShortcut(
}).show()
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
})
}
case 'globalModeShortcut': {
@ -107,7 +97,6 @@ export async function registerShortcut(
}).show()
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
})
}
case 'directModeShortcut': {
@ -119,7 +108,6 @@ export async function registerShortcut(
}).show()
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
})
}
case 'quitWithoutCoreShortcut': {

View File

@ -1,13 +1,13 @@
import { copyFile, readdir, readFile, writeFile } from 'fs/promises'
import path from 'path'
import { existsSync } from 'fs'
import AdmZip from 'adm-zip'
import { t } from 'i18next'
import { themesDir } from '../utils/dirs'
import * as chromeRequest from '../utils/chromeRequest'
import path from 'path'
import axios from 'axios'
import AdmZip from 'adm-zip'
import { getControledMihomoConfig } from '../config'
import { mainWindow } from '../window'
import { existsSync } from 'fs'
import { mainWindow } from '..'
import { floatingWindow } from './floatingWindow'
import { t } from 'i18next'
let insertedCSSKeyMain: string | undefined = undefined
let insertedCSSKeyFloating: string | undefined = undefined
@ -36,7 +36,7 @@ export async function resolveThemes(): Promise<{ key: string; label: string }[]>
export async function fetchThemes(): Promise<void> {
const zipUrl = 'https://github.com/mihomo-party-org/theme-hub/releases/download/latest/themes.zip'
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
const zipData = await chromeRequest.get(zipUrl, {
const zipData = await axios.get(zipUrl, {
responseType: 'arraybuffer',
headers: { 'Content-Type': 'application/octet-stream' },
proxy: {
@ -45,7 +45,7 @@ export async function fetchThemes(): Promise<void> {
port: mixedPort
}
})
const zip = new AdmZip(Buffer.from(zipData.data as Buffer))
const zip = new AdmZip(zipData.data as Buffer)
zip.extractAllTo(themesDir(), true)
}

View File

@ -1,9 +1,9 @@
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'
import { dataDir, resourcesFilesDir } from '../utils/dirs'
import { getAppConfig } from '../config'
let child: ChildProcess

View File

@ -1,5 +1,3 @@
import { app, clipboard, ipcMain, Menu, nativeImage, shell, Tray } from 'electron'
import { t } from 'i18next'
import {
changeCurrentProfile,
getAppConfig,
@ -9,50 +7,29 @@ import {
patchControledMihomoConfig
} from '../config'
import icoIcon from '../../../resources/icon.ico?asset'
import icoIconBlue from '../../../resources/icon_blue.ico?asset'
import icoIconRed from '../../../resources/icon_red.ico?asset'
import icoIconGreen from '../../../resources/icon_green.ico?asset'
import pngIcon from '../../../resources/icon.png?asset'
import pngIconBlue from '../../../resources/icon_blue.png?asset'
import pngIconRed from '../../../resources/icon_red.png?asset'
import pngIconGreen from '../../../resources/icon_green.png?asset'
import templateIcon from '../../../resources/iconTemplate.png?asset'
import {
mihomoChangeProxy,
mihomoCloseAllConnections,
mihomoGroups,
patchMihomoConfig,
getTrayIconStatus,
calculateTrayIconStatus
patchMihomoConfig
} from '../core/mihomoApi'
import { mainWindow, showMainWindow, triggerMainWindow } from '../window'
import { mainWindow, showMainWindow, triggerMainWindow } from '..'
import { app, clipboard, ipcMain, Menu, nativeImage, shell, Tray } from 'electron'
import { dataDir, logDir, mihomoCoreDir, mihomoWorkDir } from '../utils/dirs'
import { triggerSysProxy } from '../sys/sysproxy'
import {
quitWithoutCore,
restartCore,
checkMihomoCorePermissions,
requestTunPermissions,
restartAsAdmin
} from '../core/manager'
import { trayLogger } from '../utils/logger'
import { quitWithoutCore, restartCore } from '../core/manager'
import { floatingWindow, triggerFloatingWindow } from './floatingWindow'
import { t } from 'i18next'
export let tray: Tray | null = null
// macOS 流量显示状态,避免异步读取配置导致的时序问题
let macTrafficIconEnabled = false
export const buildContextMenu = async (): Promise<Menu> => {
// 添加调试日志
await trayLogger.debug('Current translation for tray.showWindow', t('tray.showWindow'))
await trayLogger.debug(
'Current translation for tray.hideFloatingWindow',
t('tray.hideFloatingWindow')
)
await trayLogger.debug(
'Current translation for tray.showFloatingWindow',
t('tray.showFloatingWindow')
)
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 {
@ -60,8 +37,6 @@ export const buildContextMenu = async (): Promise<Menu> => {
envType = process.platform === 'win32' ? ['powershell'] : ['bash'],
autoCloseConnection,
proxyInTray = true,
showCurrentProxyInTray = false,
trayProxyGroupStyle = 'default',
triggerSysProxyShortcut = '',
showFloatingWindowShortcut = '',
showWindowShortcut = '',
@ -76,13 +51,11 @@ export const buildContextMenu = async (): Promise<Menu> => {
if (proxyInTray && process.platform !== 'linux') {
try {
const groups = await mihomoGroups()
const groupItems: Electron.MenuItemConstructorOptions[] = groups.map((group) => {
const groupLabel = showCurrentProxyInTray ? `${group.name} | ${group.now}` : group.name
groupsMenu = groups.map((group) => {
return {
id: group.name,
label: groupLabel,
type: 'submenu' as const,
label: group.name,
type: 'submenu',
submenu: group.all.map((proxy) => {
const delay = proxy.history.length ? proxy.history[proxy.history.length - 1].delay : -1
let displayDelay = `(${delay}ms)`
@ -95,7 +68,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
return {
id: proxy.name,
label: `${proxy.name} ${displayDelay}`,
type: 'radio' as const,
type: 'radio',
checked: proxy.name === group.now,
click: async (): Promise<void> => {
await mihomoChangeProxy(group.name, proxy.name)
@ -107,22 +80,8 @@ export const buildContextMenu = async (): Promise<Menu> => {
})
}
})
if (trayProxyGroupStyle === 'submenu') {
groupsMenu = [
{ type: 'separator' },
{
id: 'proxy-groups',
label: t('tray.proxyGroups'),
type: 'submenu',
submenu: groupItems
}
]
} else {
groupsMenu = groupItems
groupsMenu.unshift({ type: 'separator' })
}
} catch {
groupsMenu.unshift({ type: 'separator' })
} catch (e) {
// ignore
// 避免出错时无法创建托盘菜单
}
@ -142,9 +101,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
{
id: 'show-floating',
accelerator: showFloatingWindowShortcut,
label: floatingWindow?.isVisible()
? t('tray.hideFloatingWindow')
: t('tray.showFloatingWindow'),
label: floatingWindow?.isVisible() ? t('tray.hideFloatingWindow') : t('tray.showFloatingWindow'),
type: 'normal',
click: async (): Promise<void> => {
await triggerFloatingWindow()
@ -162,7 +119,6 @@ export const buildContextMenu = async (): Promise<Menu> => {
mainWindow?.webContents.send('controledMihomoConfigUpdated')
mainWindow?.webContents.send('groupsUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
},
{
@ -177,7 +133,6 @@ export const buildContextMenu = async (): Promise<Menu> => {
mainWindow?.webContents.send('controledMihomoConfigUpdated')
mainWindow?.webContents.send('groupsUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
},
{
@ -192,7 +147,6 @@ export const buildContextMenu = async (): Promise<Menu> => {
mainWindow?.webContents.send('controledMihomoConfigUpdated')
mainWindow?.webContents.send('groupsUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
},
{ type: 'separator' },
@ -208,11 +162,10 @@ export const buildContextMenu = async (): Promise<Menu> => {
await patchAppConfig({ sysProxy: { enable } })
mainWindow?.webContents.send('appConfigUpdated')
floatingWindow?.webContents.send('appConfigUpdated')
} catch {
} catch (e) {
// ignore
} finally {
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
}
},
@ -225,39 +178,6 @@ export const buildContextMenu = async (): Promise<Menu> => {
const enable = item.checked
try {
if (enable) {
// 检查权限
try {
const hasPermissions = await checkMihomoCorePermissions()
if (!hasPermissions) {
if (process.platform === 'win32') {
try {
await restartAsAdmin()
return
} catch (error) {
await trayLogger.error('Failed to restart as admin from tray', error)
item.checked = false
ipcMain.emit('updateTrayMenu')
return
}
} else {
try {
await requestTunPermissions()
} catch (error) {
await trayLogger.error('Failed to grant TUN permissions from tray', error)
item.checked = false
ipcMain.emit('updateTrayMenu')
return
}
}
}
} catch (error) {
await trayLogger.warn('Permission check failed in tray', error)
item.checked = false
ipcMain.emit('updateTrayMenu')
return
}
await patchControledMihomoConfig({ tun: { enable }, dns: { enable: true } })
} else {
await patchControledMihomoConfig({ tun: { enable } })
@ -269,7 +189,6 @@ export const buildContextMenu = async (): Promise<Menu> => {
// ignore
} finally {
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
}
},
@ -288,7 +207,6 @@ export const buildContextMenu = async (): Promise<Menu> => {
await changeCurrentProfile(item.id)
mainWindow?.webContents.send('profileConfigUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
}
})
@ -373,7 +291,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
}
export async function createTray(): Promise<void> {
const { useDockIcon = true, swapTrayClick = false } = await getAppConfig()
const { useDockIcon = true } = await getAppConfig()
if (process.platform === 'linux') {
tray = new Tray(pngIcon)
const menu = await buildContextMenu()
@ -387,65 +305,36 @@ export async function createTray(): Promise<void> {
if (process.platform === 'win32') {
tray = new Tray(icoIcon)
}
tray?.setToolTip('Clash Party')
tray?.setToolTip('Mihomo Party')
tray?.setIgnoreDoubleClickEvents(true)
await updateTrayIcon()
if (process.platform === 'darwin') {
if (!useDockIcon) {
hideDockIcon()
}
// 移除旧监听器防止累积
ipcMain.removeAllListeners('trayIconUpdate')
ipcMain.on('trayIconUpdate', async (_, png: string, enabled: boolean) => {
macTrafficIconEnabled = enabled
ipcMain.on('trayIconUpdate', async (_, png: string) => {
const image = nativeImage.createFromDataURL(png).resize({ height: 16 })
image.setTemplateImage(true)
tray?.setImage(image)
})
// macOS 默认行为:左键显示窗口,右键显示菜单
tray?.addListener('click', async () => {
if (swapTrayClick) {
await updateTrayMenu()
} else {
triggerMainWindow()
}
})
tray?.addListener('right-click', async () => {
if (swapTrayClick) {
triggerMainWindow()
} else {
await updateTrayMenu()
}
triggerMainWindow()
})
tray?.addListener('click', async () => {
await updateTrayMenu()
})
}
if (process.platform === 'win32') {
tray?.addListener('click', async () => {
if (swapTrayClick) {
await updateTrayMenu()
} else {
triggerMainWindow()
}
tray?.addListener('click', () => {
triggerMainWindow()
})
tray?.addListener('right-click', async () => {
if (swapTrayClick) {
triggerMainWindow()
} else {
await updateTrayMenu()
}
await updateTrayMenu()
})
}
if (process.platform === 'linux') {
tray?.addListener('click', async () => {
if (swapTrayClick) {
await updateTrayMenu()
} else {
triggerMainWindow()
}
tray?.addListener('click', () => {
triggerMainWindow()
})
// 移除旧监听器防止累积
ipcMain.removeAllListeners('updateTrayMenu')
ipcMain.on('updateTrayMenu', async () => {
await updateTrayMenu()
})
@ -460,42 +349,26 @@ async function updateTrayMenu(): Promise<void> {
}
}
export async function copyEnv(
type: 'bash' | 'cmd' | 'powershell' | 'fish' | 'nushell'
): Promise<void> {
export async function copyEnv(type: 'bash' | 'cmd' | 'powershell'): Promise<void> {
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
const { sysProxy } = await getAppConfig()
const { host } = sysProxy
const proxyUrl = `http://${host || '127.0.0.1'}:${mixedPort}`
switch (type) {
case 'bash': {
clipboard.writeText(
`export https_proxy=${proxyUrl} http_proxy=${proxyUrl} all_proxy=${proxyUrl}`
`export https_proxy=http://${host || '127.0.0.1'}:${mixedPort} http_proxy=http://${host || '127.0.0.1'}:${mixedPort} all_proxy=http://${host || '127.0.0.1'}:${mixedPort}`
)
break
}
case 'cmd': {
clipboard.writeText(
`set http_proxy=${proxyUrl}\r\nset https_proxy=${proxyUrl}`
`set http_proxy=http://${host || '127.0.0.1'}:${mixedPort}\r\nset https_proxy=http://${host || '127.0.0.1'}:${mixedPort}`
)
break
}
case 'powershell': {
clipboard.writeText(
`$env:HTTP_PROXY="${proxyUrl}"; $env:HTTPS_PROXY="${proxyUrl}"`
)
break
}
case 'fish': {
clipboard.writeText(
`set -x http_proxy ${proxyUrl}; set -x https_proxy ${proxyUrl}; set -x all_proxy ${proxyUrl}`
)
break
}
case 'nushell': {
clipboard.writeText(
`$env.HTTP_PROXY = "${proxyUrl}"; $env.HTTPS_PROXY = "${proxyUrl}"; $env.ALL_PROXY = "${proxyUrl}"`
`$env:HTTP_PROXY="http://${host || '127.0.0.1'}:${mixedPort}"; $env:HTTPS_PROXY="http://${host || '127.0.0.1'}:${mixedPort}"`
)
break
}
@ -516,82 +389,13 @@ export async function closeTrayIcon(): Promise<void> {
}
export async function showDockIcon(): Promise<void> {
if (process.platform === 'darwin' && app.dock && !app.dock.isVisible()) {
if (process.platform === 'darwin' && !app.dock.isVisible()) {
await app.dock.show()
}
}
export async function hideDockIcon(): Promise<void> {
if (process.platform === 'darwin' && app.dock && app.dock.isVisible()) {
if (process.platform === 'darwin' && app.dock.isVisible()) {
app.dock.hide()
}
}
const getIconPaths = () => {
if (process.platform === 'win32') {
return {
white: icoIcon,
blue: icoIconBlue,
green: icoIconGreen,
red: icoIconRed
}
} else {
return {
white: pngIcon,
blue: pngIconBlue,
green: pngIconGreen,
red: pngIconRed
}
}
}
export function updateTrayIconImmediate(sysProxyEnabled: boolean, tunEnabled: boolean): void {
if (!tray) return
// macOS 流量显示开启时,由 trayIconUpdate 负责图标更新
if (process.platform === 'darwin' && macTrafficIconEnabled) return
const status = calculateTrayIconStatus(sysProxyEnabled, tunEnabled)
const iconPaths = getIconPaths()
getAppConfig().then(({ disableTrayIconColor = false }) => {
if (!tray) return
if (process.platform === 'darwin' && macTrafficIconEnabled) return
const iconPath = disableTrayIconColor ? iconPaths.white : iconPaths[status]
try {
if (process.platform === 'darwin') {
const icon = nativeImage.createFromPath(iconPath).resize({ height: 16 })
tray.setImage(icon)
} else if (process.platform === 'win32') {
tray.setImage(iconPath)
} else if (process.platform === 'linux') {
tray.setImage(iconPath)
}
} catch {
// Failed to update tray icon
}
})
}
export async function updateTrayIcon(): Promise<void> {
if (!tray) return
// macOS 流量显示开启时,由 trayIconUpdate 负责图标更新
if (process.platform === 'darwin' && macTrafficIconEnabled) return
const { disableTrayIconColor = false } = await getAppConfig()
const status = await getTrayIconStatus()
const iconPaths = getIconPaths()
const iconPath = disableTrayIconColor ? iconPaths.white : iconPaths[status]
try {
if (process.platform === 'darwin') {
const icon = nativeImage.createFromPath(iconPath).resize({ height: 16 })
tray.setImage(icon)
} else if (process.platform === 'win32') {
tray.setImage(iconPath)
} else if (process.platform === 'linux') {
tray.setImage(iconPath)
}
} catch {
// Failed to update tray icon
}
}

View File

@ -1,16 +1,13 @@
import { tmpdir } from 'os'
import { exePath, homeDir, taskDir } from '../utils/dirs'
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
import { exec } from 'child_process'
import { existsSync } from 'fs'
import { promisify } from 'util'
import path from 'path'
import { exePath, homeDir } from '../utils/dirs'
import { managerLogger } from '../utils/logger'
const appName = 'mihomo-party'
function getTaskXml(asAdmin: boolean): string {
const runLevel = asAdmin ? 'HighestAvailable' : 'LeastPrivilege'
function getTaskXml(): string {
return `<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<Triggers>
@ -22,7 +19,7 @@ function getTaskXml(asAdmin: boolean): string {
<Principals>
<Principal id="Author">
<LogonType>InteractiveToken</LogonType>
<RunLevel>${runLevel}</RunLevel>
<RunLevel>HighestAvailable</RunLevel>
</Principal>
</Principals>
<Settings>
@ -46,7 +43,8 @@ function getTaskXml(asAdmin: boolean): string {
</Settings>
<Actions Context="Author">
<Exec>
<Command>"${exePath()}"</Command>
<Command>"${path.join(taskDir(), `mihomo-party-run.exe`)}"</Command>
<Arguments>"${exePath()}"</Arguments>
</Exec>
</Actions>
</Task>
@ -56,24 +54,12 @@ function getTaskXml(asAdmin: boolean): string {
export async function checkAutoRun(): Promise<boolean> {
if (process.platform === 'win32') {
const execPromise = promisify(exec)
// 先检查任务计划程序
try {
const { stdout } = await execPromise(
`chcp 437 && %SystemRoot%\\System32\\schtasks.exe /query /tn "${appName}"`
)
if (stdout.includes(appName)) {
return true
}
} catch {
// 任务计划程序中不存在,继续检查注册表
}
// 检查注册表备用方案
try {
const regPath = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
const { stdout } = await execPromise(`reg query "${regPath}" /v "${appName}"`)
return stdout.includes(appName)
} catch {
} catch (e) {
return false
}
}
@ -95,51 +81,11 @@ export async function checkAutoRun(): Promise<boolean> {
export async function enableAutoRun(): Promise<void> {
if (process.platform === 'win32') {
const execPromise = promisify(exec)
const taskFilePath = path.join(tmpdir(), `${appName}.xml`)
const { checkAdminPrivileges } = await import('../core/manager')
const isAdmin = await checkAdminPrivileges()
await writeFile(taskFilePath, Buffer.from(`\ufeff${getTaskXml(isAdmin)}`, 'utf-16le'))
let taskCreated = false
if (isAdmin) {
try {
await execPromise(
`%SystemRoot%\\System32\\schtasks.exe /create /tn "${appName}" /xml "${taskFilePath}" /f`
)
taskCreated = true
} catch (error) {
await managerLogger.warn('Failed to create scheduled task as admin:', error)
}
} else {
try {
await execPromise(
`powershell -NoProfile -Command "Start-Process schtasks -Verb RunAs -ArgumentList '/create', '/tn', '${appName}', '/xml', '${taskFilePath}', '/f' -WindowStyle Hidden -Wait"`
)
// 验证任务是否创建成功
await new Promise((resolve) => setTimeout(resolve, 1000))
const created = await checkAutoRun()
taskCreated = created
if (!created) {
await managerLogger.warn('Scheduled task creation may have failed or been rejected')
}
} catch {
await managerLogger.info('Scheduled task creation failed, trying registry fallback')
}
}
// 任务计划程序失败时使用注册表备用方案(适用于 Windows IoT LTSC 等受限环境)
if (!taskCreated) {
await managerLogger.info('Using registry fallback for auto-run')
try {
const regPath = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
const regValue = `"${exePath()}"`
await execPromise(`reg add "${regPath}" /v "${appName}" /t REG_SZ /d ${regValue} /f`)
await managerLogger.info('Registry auto-run entry created successfully')
} catch (regError) {
await managerLogger.error('Failed to create registry auto-run entry:', regError)
}
}
const taskFilePath = path.join(taskDir(), `${appName}.xml`)
await writeFile(taskFilePath, Buffer.from(`\ufeff${getTaskXml()}`, 'utf-16le'))
await execPromise(
`%SystemRoot%\\System32\\schtasks.exe /create /tn "${appName}" /xml "${taskFilePath}" /f`
)
}
if (process.platform === 'darwin') {
const execPromise = promisify(exec)
@ -156,7 +102,7 @@ Terminal=false
Type=Application
Icon=mihomo-party
StartupWMClass=mihomo-party
Comment=Clash Party
Comment=Mihomo Party
Categories=Utility;
`
@ -175,29 +121,7 @@ Categories=Utility;
export async function disableAutoRun(): Promise<void> {
if (process.platform === 'win32') {
const execPromise = promisify(exec)
const { checkAdminPrivileges } = await import('../core/manager')
const isAdmin = await checkAdminPrivileges()
// 删除任务计划程序中的任务
try {
if (isAdmin) {
await execPromise(`%SystemRoot%\\System32\\schtasks.exe /delete /tn "${appName}" /f`)
} else {
await execPromise(
`powershell -NoProfile -Command "Start-Process schtasks -Verb RunAs -ArgumentList '/delete', '/tn', '${appName}', '/f' -WindowStyle Hidden -Wait"`
)
}
} catch {
// 任务可能不存在,忽略错误
}
// 同时删除注册表备用方案
try {
const regPath = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
await execPromise(`reg delete "${regPath}" /v "${appName}" /f`)
} catch {
// 注册表项可能不存在,忽略错误
}
await execPromise(`%SystemRoot%\\System32\\schtasks.exe /delete /tn "${appName}" /f`)
}
if (process.platform === 'darwin') {
const execPromise = promisify(exec)

View File

@ -1,21 +1,23 @@
import { exec, execFile, spawn } from 'child_process'
import { exec, execFile, execSync, spawn } from 'child_process'
import { app, dialog, nativeTheme, shell } from 'electron'
import { readFile } from 'fs/promises'
import path from 'path'
import { promisify } from 'util'
import { app, dialog, nativeTheme, shell } from 'electron'
import i18next from 'i18next'
import {
dataDir,
exePath,
mihomoCorePath,
overridePath,
profilePath,
resourcesDir
resourcesDir,
resourcesFilesDir,
taskDir
} from '../utils/dirs'
import { copyFileSync, writeFileSync } from 'fs'
export function getFilePath(ext: string[]): string[] | undefined {
return dialog.showOpenDialogSync({
title: i18next.t('common.dialog.selectSubscriptionFile'),
title: '选择订阅文件',
filters: [{ name: `${ext} file`, extensions: ext }],
properties: ['openFile']
})
@ -35,20 +37,8 @@ export function openFile(type: 'profile' | 'override', id: string, ext?: 'yaml'
}
export async function openUWPTool(): Promise<void> {
const execPromise = promisify(exec)
const execFilePromise = promisify(execFile)
const uwpToolPath = path.join(resourcesDir(), 'files', 'enableLoopback.exe')
const { checkAdminPrivileges } = await import('../core/manager')
const isAdmin = await checkAdminPrivileges()
if (!isAdmin) {
const escapedPath = uwpToolPath.replace(/'/g, "''")
const command = `powershell -NoProfile -Command "Start-Process -FilePath '${escapedPath}' -Verb RunAs -Wait"`
await execPromise(command, { windowsHide: true })
return
}
await execFilePromise(uwpToolPath)
}
@ -78,6 +68,57 @@ export function setNativeTheme(theme: 'system' | 'light' | 'dark'): void {
nativeTheme.themeSource = theme
}
function getElevateTaskXml(): string {
return `<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<Triggers />
<Principals>
<Principal id="Author">
<LogonType>InteractiveToken</LogonType>
<RunLevel>HighestAvailable</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>Parallel</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>false</AllowHardTerminate>
<StartWhenAvailable>false</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<IdleSettings>
<StopOnIdleEnd>false</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>3</Priority>
</Settings>
<Actions Context="Author">
<Exec>
<Command>"${path.join(taskDir(), `mihomo-party-run.exe`)}"</Command>
<Arguments>"${exePath()}"</Arguments>
</Exec>
</Actions>
</Task>
`
}
export function createElevateTask(): void {
const taskFilePath = path.join(taskDir(), `mihomo-party-run.xml`)
writeFileSync(taskFilePath, Buffer.from(`\ufeff${getElevateTaskXml()}`, 'utf-16le'))
copyFileSync(
path.join(resourcesFilesDir(), '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(

View File

@ -1,9 +1,9 @@
import { exec } from 'child_process'
import { promisify } from 'util'
import { ipcMain, net } from 'electron'
import { getAppConfig, patchControledMihomoConfig } from '../config'
import { patchMihomoConfig } from '../core/mihomoApi'
import { mainWindow } from '../window'
import { mainWindow } from '..'
import { ipcMain, net } from 'electron'
import { getDefaultDevice } from '../core/manager'
export async function getCurrentSSID(): Promise<string | undefined> {
@ -32,8 +32,6 @@ export async function getCurrentSSID(): Promise<string | undefined> {
}
let lastSSID: string | undefined
let ssidCheckInterval: NodeJS.Timeout | null = null
export async function checkSSID(): Promise<void> {
try {
const { pauseSSID = [] } = await getAppConfig()
@ -58,18 +56,8 @@ export async function checkSSID(): Promise<void> {
}
export async function startSSIDCheck(): Promise<void> {
if (ssidCheckInterval) {
clearInterval(ssidCheckInterval)
}
await checkSSID()
ssidCheckInterval = setInterval(checkSSID, 30000)
}
export function stopSSIDCheck(): void {
if (ssidCheckInterval) {
clearInterval(ssidCheckInterval)
ssidCheckInterval = null
}
setInterval(checkSSID, 30000)
}
async function getSSIDByAirport(): Promise<string | undefined> {

View File

@ -1,59 +1,55 @@
import { promisify } from 'util'
import { exec } from 'child_process'
import fs from 'fs'
import { triggerAutoProxy, triggerManualProxy } from 'sysproxy-rs'
import { net } from 'electron'
import axios from 'axios'
import { triggerAutoProxy, triggerManualProxy } from '@mihomo-party/sysproxy'
import { getAppConfig, getControledMihomoConfig } from '../config'
import { pacPort, startPacServer, stopPacServer } from '../resolve/server'
import { proxyLogger } from '../utils/logger'
import { promisify } from 'util'
import { execFile } from 'child_process'
import path from 'path'
import { resourcesFilesDir } from '../utils/dirs'
import { net } from 'electron'
import axios from 'axios'
import fs from 'fs'
let defaultBypass: string[]
let triggerSysProxyTimer: NodeJS.Timeout | null = null
const helperSocketPath = '/tmp/mihomo-party-helper.sock'
const defaultBypass: string[] = (() => {
switch (process.platform) {
case 'linux':
return ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1']
case 'darwin':
return [
'127.0.0.1',
'192.168.0.0/16',
'10.0.0.0/8',
'172.16.0.0/12',
'localhost',
'*.local',
'*.crashlytics.com',
'<local>'
]
case 'win32':
return [
'localhost',
'127.*',
'192.168.*',
'10.*',
'172.16.*',
'172.17.*',
'172.18.*',
'172.19.*',
'172.20.*',
'172.21.*',
'172.22.*',
'172.23.*',
'172.24.*',
'172.25.*',
'172.26.*',
'172.27.*',
'172.28.*',
'172.29.*',
'172.30.*',
'172.31.*',
'<local>'
]
default:
return ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1']
}
})()
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']
if (process.platform === 'darwin')
defaultBypass = [
'127.0.0.1',
'192.168.0.0/16',
'10.0.0.0/8',
'172.16.0.0/12',
'localhost',
'*.local',
'*.crashlytics.com',
'<local>'
]
if (process.platform === 'win32')
defaultBypass = [
'localhost',
'127.*',
'192.168.*',
'10.*',
'172.16.*',
'172.17.*',
'172.18.*',
'172.19.*',
'172.20.*',
'172.21.*',
'172.22.*',
'172.23.*',
'172.24.*',
'172.25.*',
'172.26.*',
'172.27.*',
'172.28.*',
'172.29.*',
'172.30.*',
'172.31.*',
'<local>'
]
export async function triggerSysProxy(enable: boolean): Promise<void> {
if (net.isOnline()) {
@ -74,59 +70,87 @@ async function enableSysProxy(): Promise<void> {
const { sysProxy } = await getAppConfig()
const { mode, host, bypass = defaultBypass } = sysProxy
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
const proxyHost = host || '127.0.0.1'
if (process.platform === 'darwin') {
// macOS 需要 helper 提权
if (mode === 'auto') {
await helperRequest(() =>
axios.post(
'http://localhost/pac',
{ url: `http://${proxyHost}:${pacPort}/pac` },
{ socketPath: helperSocketPath }
const execFilePromise = promisify(execFile)
switch (mode || 'manual') {
case 'auto': {
if (process.platform === 'win32') {
try {
await execFilePromise(path.join(resourcesFilesDir(), 'sysproxy.exe'), [
'pac',
`http://${host || '127.0.0.1'}:${pacPort}/pac`
])
} catch {
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 {
await helperRequest(() =>
axios.post(
'http://localhost/global',
{ host: proxyHost, port: port.toString(), bypass: bypass.join(',') },
{ socketPath: helperSocketPath }
)
)
}
} else {
// Windows / Linux 直接使用 sysproxy-rs
try {
if (mode === 'auto') {
triggerAutoProxy(true, `http://${proxyHost}:${pacPort}/pac`)
} else {
triggerManualProxy(true, proxyHost, port, bypass.join(','))
triggerAutoProxy(true, `http://${host || '127.0.0.1'}:${pacPort}/pac`)
}
} catch (error) {
await proxyLogger.error('Failed to enable system proxy', error)
throw error
break
}
case 'manual': {
if (process.platform === 'win32') {
try {
await execFilePromise(path.join(resourcesFilesDir(), 'sysproxy.exe'), [
'global',
`${host || '127.0.0.1'}:${port}`,
bypass.join(';')
])
} catch {
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 {
triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(','))
}
break
}
}
}
async function disableSysProxy(): Promise<void> {
await stopPacServer()
if (process.platform === 'darwin') {
await helperRequest(() => axios.get('http://localhost/off', { socketPath: helperSocketPath }))
} else {
// Windows / Linux 直接使用 sysproxy-rs
const execFilePromise = promisify(execFile)
if (process.platform === 'win32') {
try {
await execFilePromise(path.join(resourcesFilesDir(), 'sysproxy.exe'), ['set', '1'])
} catch {
triggerAutoProxy(false, '')
triggerManualProxy(false, '', 0, '')
} catch (error) {
await proxyLogger.error('Failed to disable system proxy', error)
throw error
}
} else if (process.platform === 'darwin') {
await helperRequest(() =>
axios.get('http://localhost/off', {
socketPath: helperSocketPath
})
)
} else {
triggerAutoProxy(false, '')
triggerManualProxy(false, '', 0, '')
}
}
// Helper function to check if socket file exists
function isSocketFileExists(): boolean {
try {
return fs.existsSync(helperSocketPath)
@ -135,88 +159,64 @@ function isSocketFileExists(): boolean {
}
}
async function isHelperRunning(): Promise<boolean> {
try {
const execPromise = promisify(exec)
const { stdout } = await execPromise('pgrep -f party.mihomo.helper')
return stdout.trim().length > 0
} catch {
return false
}
}
async function startHelperService(): Promise<void> {
const execPromise = promisify(exec)
const shell = `launchctl kickstart -k system/party.mihomo.helper`
const command = `do shell script "${shell}" with administrator privileges`
await execPromise(`osascript -e '${command}'`)
await new Promise((resolve) => setTimeout(resolve, 1500))
}
// 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}'`)
await new Promise((resolve) => setTimeout(resolve, 1000))
// Wait a bit for socket recreation
await new Promise(resolve => setTimeout(resolve, 1000))
} catch (error) {
await proxyLogger.error('Failed to send signal to helper', error)
console.log('Failed to send signal to helper:', error)
throw error
}
}
async function helperRequest(requestFn: () => Promise<unknown>, maxRetries = 2): Promise<unknown> {
// 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
const errCode = (error as NodeJS.ErrnoException).code
const errMsg = (error as Error).message || ''
if (
attempt < maxRetries &&
(errCode === 'ECONNREFUSED' ||
errCode === 'ENOENT' ||
errMsg.includes('connect ECONNREFUSED') ||
errMsg.includes('ENOENT'))
) {
await proxyLogger.info(
`Helper request failed (attempt ${attempt + 1}/${maxRetries + 1}), checking helper status...`
)
const helperRunning = await isHelperRunning()
const socketExists = isSocketFileExists()
if (!helperRunning) {
await proxyLogger.info('Helper process not running, starting service...')
try {
await startHelperService()
await proxyLogger.info('Helper service started, retrying...')
continue
} catch (startError) {
await proxyLogger.warn('Failed to start helper service', startError)
}
} else if (!socketExists) {
await proxyLogger.info('Socket file missing but helper running, requesting recreation...')
// 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()
await proxyLogger.info('Socket recreation requested, retrying...')
console.log('Socket recreation requested, retrying...')
continue
} catch (signalError) {
await proxyLogger.warn('Failed to request socket recreation', 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

@ -1,63 +0,0 @@
import fs from 'fs'
import path from 'path'
import { spawnSync } from 'child_process'
import plist from 'plist'
import { findBestAppPath, isIOSApp } from './icon'
export async function getAppName(appPath: string): Promise<string> {
if (process.platform === 'darwin') {
try {
const targetPath = findBestAppPath(appPath)
if (!targetPath) return ''
if (isIOSApp(targetPath)) {
const plistPath = path.join(targetPath, 'Info.plist')
const xml = fs.readFileSync(plistPath, 'utf-8')
const parsed = plist.parse(xml) as Record<string, unknown>
return (parsed.CFBundleDisplayName as string) || (parsed.CFBundleName as string) || ''
}
try {
const appName = getLocalizedAppName(targetPath)
if (appName) return appName
} catch {
// ignore
}
const plistPath = path.join(targetPath, 'Contents', 'Info.plist')
if (fs.existsSync(plistPath)) {
const xml = fs.readFileSync(plistPath, 'utf-8')
const parsed = plist.parse(xml) as Record<string, unknown>
return (parsed.CFBundleDisplayName as string) || (parsed.CFBundleName as string) || ''
} else {
// ignore
}
} catch {
// ignore
}
}
return ''
}
function getLocalizedAppName(appPath: string): string {
const escapedPath = appPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
const jxa = `
ObjC.import('Foundation');
const fm = $.NSFileManager.defaultManager;
const name = fm.displayNameAtPath('${escapedPath}');
name.js;
`
const res = spawnSync('osascript', ['-l', 'JavaScript'], {
input: jxa,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
})
if (res.error) {
throw res.error
}
if (res.status !== 0) {
throw new Error(res.stderr.trim() || `osascript exited ${res.status}`)
}
return res.stdout.trim()
}

View File

@ -21,7 +21,7 @@ export function calcTraffic(byte: number): string {
function formatNumString(num: number): string {
let str = num.toFixed(2)
if (str.length <= 5) return str
if (str.length === 6) {
if (str.length == 6) {
str = num.toFixed(1)
return str
} else {

View File

@ -1,262 +0,0 @@
import { net, session } from 'electron'
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
headers?: Record<string, string>
body?: string | Buffer
proxy?:
| {
protocol: 'http' | 'https' | 'socks5'
host: string
port: number
}
| false
timeout?: number
responseType?: 'text' | 'json' | 'arraybuffer'
followRedirect?: boolean
maxRedirects?: number
}
export interface Response<T = unknown> {
data: T
status: number
statusText: string
headers: Record<string, string>
url: string
}
// 复用单个 session 用于代理请求
let proxySession: Electron.Session | null = null
let currentProxyUrl: string | null = null
let proxySetupPromise: Promise<void> | null = null
async function getProxySession(proxyUrl: string): Promise<Electron.Session> {
if (!proxySession) {
proxySession = session.fromPartition('proxy-requests', { cache: false })
}
if (currentProxyUrl !== proxyUrl) {
proxySetupPromise = proxySession.setProxy({ proxyRules: proxyUrl })
currentProxyUrl = proxyUrl
}
if (proxySetupPromise) {
await proxySetupPromise
}
return proxySession
}
/**
* Make HTTP request using Chromium's network stack (via electron.net)
* This provides better compatibility, HTTP/2 support, and system certificate integration
*/
export async function request<T = unknown>(
url: string,
options: RequestOptions = {}
): Promise<Response<T>> {
const {
method = 'GET',
headers = {},
body,
proxy,
timeout = 30000,
responseType = 'text',
followRedirect = true,
maxRedirects = 20
} = options
return new Promise((resolve, reject) => {
let sessionToUse: Electron.Session = session.defaultSession
// Set up proxy if specified
const setupProxy = async (): Promise<void> => {
if (proxy) {
const proxyUrl = `${proxy.protocol}://${proxy.host}:${proxy.port}`
sessionToUse = await getProxySession(proxyUrl)
}
}
const cleanup = (): void => {}
setupProxy()
.then(() => {
const req = net.request({
method,
url,
session: sessionToUse,
redirect: followRedirect ? 'follow' : 'manual',
useSessionCookies: true
})
// Set request headers
Object.entries(headers).forEach(([key, value]) => {
req.setHeader(key, value)
})
// Timeout handling
let timeoutId: NodeJS.Timeout | undefined
if (timeout > 0) {
timeoutId = setTimeout(() => {
req.abort()
cleanup()
reject(new Error(`Request timeout after ${timeout}ms`))
}, timeout)
}
const chunks: Buffer[] = []
let redirectCount = 0
req.on('redirect', () => {
redirectCount++
if (redirectCount > maxRedirects) {
req.abort()
cleanup()
if (timeoutId) clearTimeout(timeoutId)
reject(new Error(`Too many redirects (>${maxRedirects})`))
}
})
req.on('response', (res) => {
const { statusCode, statusMessage } = res
// Extract response headers
const responseHeaders: Record<string, string> = {}
const rawHeaders = res.rawHeaders || []
for (let i = 0; i < rawHeaders.length; i += 2) {
responseHeaders[rawHeaders[i].toLowerCase()] = rawHeaders[i + 1]
}
res.on('data', (chunk: Buffer) => {
chunks.push(chunk)
})
res.on('end', () => {
cleanup()
if (timeoutId) clearTimeout(timeoutId)
const buffer = Buffer.concat(chunks)
let data: unknown
try {
switch (responseType) {
case 'json':
data = JSON.parse(buffer.toString('utf-8'))
break
case 'arraybuffer':
data = buffer
break
case 'text':
default:
data = buffer.toString('utf-8')
}
resolve({
data: data as T,
status: statusCode,
statusText: statusMessage,
headers: responseHeaders,
url: url
})
} catch (error: unknown) {
reject(new Error(`Failed to parse response: ${String(error)}`))
}
})
res.on('error', (error: unknown) => {
cleanup()
if (timeoutId) clearTimeout(timeoutId)
reject(error)
})
})
req.on('error', (error: unknown) => {
cleanup()
if (timeoutId) clearTimeout(timeoutId)
reject(error)
})
req.on('abort', () => {
cleanup()
if (timeoutId) clearTimeout(timeoutId)
reject(new Error('Request aborted'))
})
// Send request body
if (body) {
if (typeof body === 'string') {
req.write(body, 'utf-8')
} else {
req.write(body)
}
}
req.end()
})
.catch((error: unknown) => {
cleanup()
reject(new Error(`Failed to setup proxy: ${String(error)}`))
})
})
}
/**
* Convenience method for GET requests
*/
export const get = <T = unknown>(
url: string,
options?: Omit<RequestOptions, 'method' | 'body'>
): Promise<Response<T>> => request<T>(url, { ...options, method: 'GET' })
/**
* Convenience method for POST requests
*/
export const post = <T = unknown>(
url: string,
data: unknown,
options?: Omit<RequestOptions, 'method' | 'body'>
): Promise<Response<T>> => {
const body = typeof data === 'string' ? data : JSON.stringify(data)
const headers = options?.headers || {}
if (typeof data !== 'string' && !headers['content-type']) {
headers['content-type'] = 'application/json'
}
return request<T>(url, { ...options, method: 'POST', body, headers })
}
/**
* Convenience method for PUT requests
*/
export const put = <T = unknown>(
url: string,
data: unknown,
options?: Omit<RequestOptions, 'method' | 'body'>
): Promise<Response<T>> => {
const body = typeof data === 'string' ? data : JSON.stringify(data)
const headers = options?.headers || {}
if (typeof data !== 'string' && !headers['content-type']) {
headers['content-type'] = 'application/json'
}
return request<T>(url, { ...options, method: 'PUT', body, headers })
}
/**
* Convenience method for DELETE requests
*/
export const del = <T = unknown>(
url: string,
options?: Omit<RequestOptions, 'method' | 'body'>
): Promise<Response<T>> => request<T>(url, { ...options, method: 'DELETE' })
/**
* Convenience method for PATCH requests
*/
export const patch = <T = unknown>(
url: string,
data: unknown,
options?: Omit<RequestOptions, 'method' | 'body'>
): Promise<Response<T>> => {
const body = typeof data === 'string' ? data : JSON.stringify(data)
const headers = options?.headers || {}
if (typeof data !== 'string' && !headers['content-type']) {
headers['content-type'] = 'application/json'
}
return request<T>(url, { ...options, method: 'PATCH', body, headers })
}

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,7 @@
import { existsSync, mkdirSync } from 'fs'
import path from 'path'
import { is } from '@electron-toolkit/utils'
import { existsSync, mkdirSync } from 'fs'
import { app } from 'electron'
import path from 'path'
export const homeDir = app.getPath('home')
@ -23,7 +23,7 @@ export function taskDir(): string {
if (!existsSync(userDataDir)) {
mkdirSync(userDataDir, { recursive: true })
}
const dir = path.join(userDataDir, 'tasks')
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
@ -69,10 +69,6 @@ export function mihomoCoreDir(): string {
export function mihomoCorePath(core: string): string {
const isWin = process.platform === 'win32'
// 处理 Smart 内核
if (core === 'mihomo-smart') {
return path.join(mihomoCoreDir(), `mihomo-smart${isWin ? '.exe' : ''}`)
}
return path.join(mihomoCoreDir(), `${core}${isWin ? '.exe' : ''}`)
}
@ -134,35 +130,12 @@ export function logDir(): string {
export function logPath(): string {
const date = new Date()
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const name = `clash-party-${year}-${month}-${day}`
const name = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
return path.join(logDir(), `${name}.log`)
}
export function substoreLogPath(): string {
const date = new Date()
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const name = `sub-store-${year}-${month}-${day}`
const name = `sub-store-${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
return path.join(logDir(), `${name}.log`)
}
export function coreLogPath(): string {
const date = new Date()
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const name = `core-${year}-${month}-${day}`
return path.join(logDir(), `${name}.log`)
}
export function rulesDir(): string {
return path.join(dataDir(), 'rules')
}
export function rulePath(id: string): string {
return path.join(rulesDir(), `${id}.yaml`)
}

View File

@ -1,228 +0,0 @@
import { createWriteStream, createReadStream, existsSync, rmSync } from 'fs'
import { writeFile } from 'fs/promises'
import { execSync } from 'child_process'
import { platform } from 'os'
import { join } from 'path'
import { createGunzip } from 'zlib'
import AdmZip from 'adm-zip'
import { stopCore } from '../core/manager'
import { mihomoCoreDir } from './dirs'
import * as chromeRequest from './chromeRequest'
import { createLogger } from './logger'
const log = createLogger('GitHub')
export interface GitHubTag {
name: string
zipball_url: string
tarball_url: string
}
interface VersionCache {
data: GitHubTag[]
timestamp: number
}
const CACHE_EXPIRY = 5 * 60 * 1000
const GITHUB_API_CONFIG = {
BASE_URL: 'https://api.github.com',
API_VERSION: '2022-11-28',
TAGS_PER_PAGE: 100
}
const PLATFORM_MAP: Record<string, string> = {
'win32-x64': 'mihomo-windows-amd64-compatible',
'win32-ia32': 'mihomo-windows-386',
'win32-arm64': 'mihomo-windows-arm64',
'darwin-x64': 'mihomo-darwin-amd64-compatible',
'darwin-arm64': 'mihomo-darwin-arm64',
'linux-x64': 'mihomo-linux-amd64-compatible',
'linux-arm64': 'mihomo-linux-arm64'
}
const versionCache = new Map<string, VersionCache>()
/**
* GitHub
* @param owner
* @param repo
* @param forceRefresh
* @returns
*/
export async function getGitHubTags(
owner: string,
repo: string,
forceRefresh = false
): Promise<GitHubTag[]> {
const cacheKey = `${owner}/${repo}`
// 检查缓存
if (!forceRefresh && versionCache.has(cacheKey)) {
const cache = versionCache.get(cacheKey)
if (cache && Date.now() - cache.timestamp < CACHE_EXPIRY) {
log.debug(`Returning cached tags for ${owner}/${repo}`)
return cache.data
}
}
try {
log.debug(`Fetching tags for ${owner}/${repo}`)
const response = await chromeRequest.get<GitHubTag[]>(
`${GITHUB_API_CONFIG.BASE_URL}/repos/${owner}/${repo}/tags?per_page=${GITHUB_API_CONFIG.TAGS_PER_PAGE}`,
{
headers: {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': GITHUB_API_CONFIG.API_VERSION
},
responseType: 'json',
timeout: 10000
}
)
// 更新缓存
versionCache.set(cacheKey, {
data: response.data,
timestamp: Date.now()
})
log.debug(`Successfully fetched ${response.data.length} tags for ${owner}/${repo}`)
return response.data
} catch (error) {
log.error(`Failed to fetch tags for ${owner}/${repo}`, error)
if (error instanceof Error) {
throw new Error(`GitHub API error: ${error.message}`)
}
throw new Error('Failed to fetch version list')
}
}
/**
*
* @param owner
* @param repo
*/
export function clearVersionCache(owner: string, repo: string): void {
const cacheKey = `${owner}/${repo}`
const hasCache = versionCache.has(cacheKey)
versionCache.delete(cacheKey)
log.debug(`Cache ${hasCache ? 'cleared' : 'not found'} for ${owner}/${repo}`)
}
/**
* GitHub Release
* @param url URL
* @param outputPath
*/
async function downloadGitHubAsset(url: string, outputPath: string): Promise<void> {
try {
log.debug(`Downloading asset from ${url}`)
const response = await chromeRequest.get(url, {
responseType: 'arraybuffer',
timeout: 30000
})
await writeFile(outputPath, Buffer.from(response.data as Buffer))
log.debug(`Successfully downloaded asset to ${outputPath}`)
} catch (error) {
log.error(`Failed to download asset from ${url}`, error)
if (error instanceof Error) {
throw new Error(`Download error: ${error.message}`)
}
throw new Error('Failed to download core file')
}
}
/**
* mihomo
* @param version
*/
export async function installMihomoCore(version: string): Promise<void> {
try {
log.info(`Installing mihomo core version ${version}`)
const plat = platform()
const arch = process.arch
// 映射平台和架构到 GitHub Release 文件名
const key = `${plat}-${arch}`
const name = PLATFORM_MAP[key]
if (!name) {
throw new Error(`Unsupported platform "${plat}-${arch}"`)
}
const isWin = plat === 'win32'
const urlExt = isWin ? 'zip' : 'gz'
const downloadURL = `https://github.com/MetaCubeX/mihomo/releases/download/${version}/${name}-${version}.${urlExt}`
const coreDir = mihomoCoreDir()
const tempZip = join(coreDir, `temp-core.${urlExt}`)
const exeFile = `${name}${isWin ? '.exe' : ''}`
const targetFile = `mihomo-specific${isWin ? '.exe' : ''}`
const targetPath = join(coreDir, targetFile)
// 如果目标文件已存在,先停止核心
if (existsSync(targetPath)) {
log.debug('Stopping core before extracting new core file')
// 先停止核心
await stopCore(true)
}
// 下载文件
await downloadGitHubAsset(downloadURL, tempZip)
// 解压文件
if (urlExt === 'zip') {
log.debug(`Extracting ZIP file ${tempZip}`)
const zip = new AdmZip(tempZip)
const entries = zip.getEntries()
const entry = entries.find((e) => e.entryName.includes(exeFile))
if (entry) {
zip.extractEntryTo(entry, coreDir, false, true, false, targetFile)
log.debug(`Successfully extracted ${exeFile} to ${targetPath}`)
} else {
throw new Error(`Executable file not found in zip: ${exeFile}`)
}
} else {
// 处理.gz 文件
log.debug(`Extracting GZ file ${tempZip}`)
const readStream = createReadStream(tempZip)
const writeStream = createWriteStream(targetPath)
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
log.error('Gzip decompression failed', error)
reject(new Error(`Gzip decompression failed: ${error.message}`))
}
readStream
.pipe(createGunzip().on('error', onError))
.pipe(writeStream)
.on('finish', () => {
log.debug('Gunzip finished')
try {
execSync(`chmod 755 ${targetPath}`)
log.debug('Chmod binary finished')
} catch (chmodError) {
log.warn('Failed to chmod binary', chmodError)
}
resolve()
})
.on('error', onError)
})
}
// 清理临时文件
log.debug(`Cleaning up temporary file ${tempZip}`)
rmSync(tempZip)
log.info(`Successfully installed mihomo core version ${version}`)
} catch (error) {
log.error('Failed to install mihomo core', error)
throw new Error(
`Failed to install core: ${error instanceof Error ? error.message : String(error)}`
)
}
}

View File

@ -1,287 +0,0 @@
import { exec } from 'child_process'
import fs, { existsSync } from 'fs'
import os from 'os'
import path from 'path'
import crypto from 'crypto'
import axios from 'axios'
import { getIcon } from 'file-icon-info'
import { app } from 'electron'
import { getControledMihomoConfig } from '../config'
import { windowsDefaultIcon, darwinDefaultIcon, otherDevicesIcon } from './defaultIcon'
export function isIOSApp(appPath: string): boolean {
const appDir = appPath.endsWith('.app')
? appPath
: appPath.includes('.app')
? appPath.substring(0, appPath.indexOf('.app') + 4)
: path.dirname(appPath)
return !fs.existsSync(path.join(appDir, 'Contents'))
}
function hasIOSAppIcon(appPath: string): boolean {
try {
const items = fs.readdirSync(appPath)
return items.some((item) => {
const lower = item.toLowerCase()
const ext = path.extname(item).toLowerCase()
return lower.startsWith('appicon') && (ext === '.png' || ext === '.jpg' || ext === '.jpeg')
})
} catch {
return false
}
}
function hasMacOSAppIcon(appPath: string): boolean {
const resourcesDir = path.join(appPath, 'Contents', 'Resources')
if (!fs.existsSync(resourcesDir)) {
return false
}
try {
const items = fs.readdirSync(resourcesDir)
return items.some((item) => path.extname(item).toLowerCase() === '.icns')
} catch {
return false
}
}
export function findBestAppPath(appPath: string): string | null {
if (!appPath.includes('.app') && !appPath.includes('.xpc')) {
return null
}
const parts = appPath.split(path.sep)
const appPaths: string[] = []
for (let i = 0; i < parts.length; i++) {
if (parts[i].endsWith('.app') || parts[i].endsWith('.xpc')) {
const fullPath = parts.slice(0, i + 1).join(path.sep)
appPaths.push(fullPath)
}
}
if (appPaths.length === 0) {
return null
}
if (appPaths.length === 1) {
return appPaths[0]
}
for (let i = appPaths.length - 1; i >= 0; i--) {
const appDir = appPaths[i]
if (isIOSApp(appDir)) {
if (hasIOSAppIcon(appDir)) {
return appDir
}
} else {
if (hasMacOSAppIcon(appDir)) {
return appDir
}
}
}
return appPaths[0]
}
async function findDesktopFile(appPath: string): Promise<string | null> {
try {
const execName = path.isAbsolute(appPath) ? path.basename(appPath) : appPath
const desktopDirs = ['/usr/share/applications', `${process.env.HOME}/.local/share/applications`]
for (const dir of desktopDirs) {
if (!existsSync(dir)) continue
const files = fs.readdirSync(dir)
const desktopFiles = files.filter((file) => file.endsWith('.desktop'))
for (const file of desktopFiles) {
const fullPath = path.join(dir, file)
try {
const content = fs.readFileSync(fullPath, 'utf-8')
const execMatch = content.match(/^Exec\s*=\s*(.+?)$/m)
if (execMatch) {
const execLine = execMatch[1].trim()
const execCmd = execLine.split(/\s+/)[0]
const execBasename = path.basename(execCmd)
if (
execCmd === appPath ||
execBasename === execName ||
execCmd.endsWith(appPath) ||
appPath.endsWith(execBasename)
) {
return fullPath
}
}
const nameRegex = new RegExp(`^Name\\s*=\\s*${appPath}\\s*$`, 'im')
const genericNameRegex = new RegExp(`^GenericName\\s*=\\s*${appPath}\\s*$`, 'im')
if (nameRegex.test(content) || genericNameRegex.test(content)) {
return fullPath
}
} catch {
continue
}
}
}
} catch {
// ignore
}
return null
}
function parseIconNameFromDesktopFile(content: string): string | null {
const match = content.match(/^Icon\s*=\s*(.+?)$/m)
return match ? match[1].trim() : null
}
function resolveIconPath(iconName: string): string | null {
if (path.isAbsolute(iconName) && existsSync(iconName)) {
return iconName
}
const searchPaths: string[] = []
const sizes = ['512x512', '256x256', '128x128', '64x64', '48x48', '32x32', '24x24', '16x16']
const extensions = ['png', 'svg', 'xpm']
const iconDirs = [
'/usr/share/icons/hicolor',
'/usr/share/pixmaps',
'/usr/share/icons/Adwaita',
`${process.env.HOME}/.local/share/icons`
]
for (const dir of iconDirs) {
for (const size of sizes) {
for (const ext of extensions) {
searchPaths.push(path.join(dir, size, 'apps', `${iconName}.${ext}`))
}
}
}
for (const ext of extensions) {
searchPaths.push(`/usr/share/pixmaps/${iconName}.${ext}`)
}
for (const dir of iconDirs) {
for (const ext of extensions) {
searchPaths.push(path.join(dir, `${iconName}.${ext}`))
}
}
return searchPaths.find((iconPath) => existsSync(iconPath)) || null
}
export async function getIconDataURL(appPath: string): Promise<string> {
if (!appPath) {
return otherDevicesIcon
}
if (appPath === 'mihomo') {
appPath = app.getPath('exe')
}
if (process.platform === 'darwin') {
if (!appPath.includes('.app') && !appPath.includes('.xpc')) {
return darwinDefaultIcon
}
const { fileIconToBuffer } = await import('file-icon')
const targetPath = findBestAppPath(appPath)
if (!targetPath) {
return darwinDefaultIcon
}
const iconBuffer = await fileIconToBuffer(targetPath, { size: 512 })
const base64Icon = Buffer.from(iconBuffer).toString('base64')
return `data:image/png;base64,${base64Icon}`
}
if (process.platform === 'win32') {
if (fs.existsSync(appPath) && /\.(exe|dll)$/i.test(appPath)) {
try {
let targetPath = appPath
let tempLinkPath: string | null = null
if (/[\u4e00-\u9fff]/.test(appPath)) {
const tempDir = os.tmpdir()
const randomName = crypto.randomBytes(8).toString('hex')
const fileExt = path.extname(appPath)
tempLinkPath = path.join(tempDir, `${randomName}${fileExt}`)
try {
await new Promise<void>((resolve) => {
exec(`mklink "${tempLinkPath}" "${appPath}"`, (error) => {
if (!error && tempLinkPath && fs.existsSync(tempLinkPath)) {
targetPath = tempLinkPath
}
resolve()
})
})
} catch {
// ignore mklink errors
}
}
try {
const iconBuffer = await new Promise<Buffer>((resolve, reject) => {
getIcon(targetPath, (b64d) => {
try {
resolve(Buffer.from(b64d, 'base64'))
} catch (error) {
reject(error)
}
})
})
return `data:image/png;base64,${iconBuffer.toString('base64')}`
} finally {
if (tempLinkPath && fs.existsSync(tempLinkPath)) {
try {
fs.unlinkSync(tempLinkPath)
} catch {
// ignore cleanup errors
}
}
}
} catch {
return windowsDefaultIcon
}
} else {
return windowsDefaultIcon
}
} else if (process.platform === 'linux') {
const desktopFile = await findDesktopFile(appPath)
if (desktopFile) {
const content = fs.readFileSync(desktopFile, 'utf-8')
const iconName = parseIconNameFromDesktopFile(content)
if (iconName) {
const iconPath = resolveIconPath(iconName)
if (iconPath) {
try {
const iconBuffer = fs.readFileSync(iconPath)
return `data:image/png;base64,${iconBuffer.toString('base64')}`
} catch {
return darwinDefaultIcon
}
}
}
} else {
return darwinDefaultIcon
}
}
return ''
}
export async function getImageDataURL(url: string): Promise<string> {
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
const res = await axios.get(url, {
responseType: 'arraybuffer',
...(port !== 0 && {
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

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

View File

@ -1,31 +1,3 @@
import { mkdir, writeFile, rm, readdir, cp, stat, rename } from 'fs/promises'
import { existsSync } from 'fs'
import { exec } from 'child_process'
import { promisify } from 'util'
import path from 'path'
import { app, dialog } from 'electron'
import {
startPacServer,
startSubStoreBackendServer,
startSubStoreFrontendServer
} from '../resolve/server'
import { triggerSysProxy } from '../sys/sysproxy'
import {
getAppConfig,
getControledMihomoConfig,
patchAppConfig,
patchControledMihomoConfig
} from '../config'
import { startSSIDCheck } from '../sys/ssid'
import i18next, { resources } from '../../shared/i18n'
import { stringify } from './yaml'
import {
defaultConfig,
defaultControledMihomoConfig,
defaultOverrideConfig,
defaultProfile,
defaultProfileConfig
} from './template'
import {
appConfigPath,
controledMihomoConfigPath,
@ -39,26 +11,36 @@ import {
profilePath,
profilesDir,
resourcesFilesDir,
rulesDir,
subStoreDir,
themesDir
} from './dirs'
import { initLogger } from './logger'
let isInitBasicCompleted = false
export function safeShowErrorBox(titleKey: string, message: string): void {
let title: string
try {
title = i18next.t(titleKey)
if (!title || title === titleKey) throw new Error('Translation not ready')
} catch {
const isZh = app.getLocale().startsWith('zh')
const lang = isZh ? resources['zh-CN'].translation : resources['en-US'].translation
title = lang[titleKey] || (isZh ? '错误' : 'Error')
}
dialog.showErrorBox(title, message)
}
import {
defaultConfig,
defaultControledMihomoConfig,
defaultOverrideConfig,
defaultProfile,
defaultProfileConfig
} from './template'
import yaml from 'yaml'
import { mkdir, writeFile, rm, readdir, cp, stat } from 'fs/promises'
import { existsSync } from 'fs'
import { exec } from 'child_process'
import { promisify } from 'util'
import path from 'path'
import {
startPacServer,
startSubStoreBackendServer,
startSubStoreFrontendServer
} from '../resolve/server'
import { triggerSysProxy } from '../sys/sysproxy'
import {
getAppConfig,
getControledMihomoConfig,
patchAppConfig,
patchControledMihomoConfig
} from '../config'
import { app } from 'electron'
import { startSSIDCheck } from '../sys/ssid'
async function fixDataDirPermissions(): Promise<void> {
if (process.platform !== 'darwin') return
@ -83,286 +65,192 @@ async function fixDataDirPermissions(): Promise<void> {
}
}
async function isSourceNewer(sourcePath: string, targetPath: string): Promise<boolean> {
try {
const [sourceStats, targetStats] = await Promise.all([stat(sourcePath), stat(targetPath)])
return sourceStats.mtime > targetStats.mtime
} catch {
return true
}
}
async function initDirs(): Promise<void> {
await fixDataDirPermissions()
const dirsToCreate = [
dataDir(),
themesDir(),
profilesDir(),
overrideDir(),
rulesDir(),
mihomoWorkDir(),
logDir(),
mihomoTestDir(),
subStoreDir()
]
await Promise.all(
dirsToCreate.map(async (dir) => {
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true })
}
})
)
if (!existsSync(dataDir())) {
await mkdir(dataDir())
}
if (!existsSync(themesDir())) {
await mkdir(themesDir())
}
if (!existsSync(profilesDir())) {
await mkdir(profilesDir())
}
if (!existsSync(overrideDir())) {
await mkdir(overrideDir())
}
if (!existsSync(mihomoWorkDir())) {
await mkdir(mihomoWorkDir())
}
if (!existsSync(logDir())) {
await mkdir(logDir())
}
if (!existsSync(mihomoTestDir())) {
await mkdir(mihomoTestDir())
}
if (!existsSync(subStoreDir())) {
await mkdir(subStoreDir())
}
}
async function initConfig(): Promise<void> {
const configs = [
{ path: appConfigPath(), content: defaultConfig, name: 'app config' },
{ path: profileConfigPath(), content: defaultProfileConfig, name: 'profile config' },
{ path: overrideConfigPath(), content: defaultOverrideConfig, name: 'override config' },
{ path: profilePath('default'), content: defaultProfile, name: 'default profile' },
{
path: controledMihomoConfigPath(),
content: defaultControledMihomoConfig,
name: 'mihomo config'
}
]
await Promise.all(
configs.map(async (config) => {
if (!existsSync(config.path)) {
await writeFile(config.path, stringify(config.content))
}
})
)
}
async function killOldMihomoProcesses(): Promise<void> {
if (process.platform !== 'win32') return
const execPromise = promisify(exec)
try {
const { stdout } = await execPromise(
'powershell -NoProfile -Command "Get-Process | Where-Object {$_.ProcessName -like \'*mihomo*\'} | Select-Object Id | ConvertTo-Json"',
{ encoding: 'utf8' }
)
if (!stdout.trim()) return
const processes = JSON.parse(stdout)
const processArray = Array.isArray(processes) ? processes : [processes]
for (const proc of processArray) {
const pid = proc.Id
if (pid && pid !== process.pid) {
try {
process.kill(pid, 'SIGTERM')
await initLogger.info(`Terminated old mihomo process ${pid}`)
} catch {
// 进程可能退出
}
}
}
await new Promise((resolve) => setTimeout(resolve, 200))
} catch {
// 忽略错误
if (!existsSync(appConfigPath())) {
await writeFile(appConfigPath(), yaml.stringify(defaultConfig))
}
if (!existsSync(profileConfigPath())) {
await writeFile(profileConfigPath(), yaml.stringify(defaultProfileConfig))
}
if (!existsSync(overrideConfigPath())) {
await writeFile(overrideConfigPath(), yaml.stringify(defaultOverrideConfig))
}
if (!existsSync(profilePath('default'))) {
await writeFile(profilePath('default'), yaml.stringify(defaultProfile))
}
if (!existsSync(controledMihomoConfigPath())) {
await writeFile(controledMihomoConfigPath(), yaml.stringify(defaultControledMihomoConfig))
}
}
async function initFiles(): Promise<void> {
await killOldMihomoProcesses()
const copyFile = async (file: string): Promise<void> => {
const copy = async (file: string): Promise<void> => {
const targetPath = path.join(mihomoWorkDir(), file)
const testTargetPath = path.join(mihomoTestDir(), file)
const sourcePath = path.join(resourcesFilesDir(), file)
if (!existsSync(sourcePath)) return
const targets = [path.join(mihomoWorkDir(), file), path.join(mihomoTestDir(), file)]
await Promise.all(
targets.map(async (targetPath) => {
const shouldCopy = !existsSync(targetPath) || (await isSourceNewer(sourcePath, targetPath))
if (!shouldCopy) return
try {
await cp(sourcePath, targetPath, { recursive: true, force: true })
} catch (error: unknown) {
const code = (error as NodeJS.ErrnoException).code
// 文件被占用或权限问题,如果目标已存在则跳过
if (
(code === 'EPERM' || code === 'EBUSY' || code === 'EACCES') &&
existsSync(targetPath)
) {
await initLogger.warn(`Skipping ${file}: file is in use or permission denied`)
return
}
throw error
}
})
)
if (!existsSync(targetPath) && existsSync(sourcePath)) {
await cp(sourcePath, targetPath, { recursive: true })
}
if (!existsSync(testTargetPath) && existsSync(sourcePath)) {
await cp(sourcePath, testTargetPath, { recursive: true })
}
}
await Promise.all([
copy('country.mmdb'),
copy('geoip.metadb'),
copy('geoip.dat'),
copy('geosite.dat'),
copy('ASN.mmdb'),
copy('sub-store.bundle.js'),
copy('sub-store-frontend')
])
}
const files = [
'country.mmdb',
'geoip.metadb',
'geoip.dat',
'geosite.dat',
'ASN.mmdb',
'sub-store.bundle.cjs',
'sub-store-frontend'
]
const criticalFiles = ['country.mmdb', 'geoip.dat', 'geosite.dat']
const results = await Promise.allSettled(files.map(copyFile))
for (let i = 0; i < results.length; i++) {
const result = results[i]
if (result.status === 'rejected') {
const file = files[i]
await initLogger.error(`Failed to copy ${file}`, result.reason)
if (criticalFiles.includes(file)) {
throw new Error(`Failed to copy critical file ${file}: ${result.reason}`)
async function cleanup(): Promise<void> {
// update cache
const files = await readdir(dataDir())
for (const file of files) {
if (file.endsWith('.exe') || file.endsWith('.pkg') || file.endsWith('.7z')) {
try {
await rm(path.join(dataDir(), file))
} catch {
// ignore
}
}
}
// logs
const { maxLogDays = 7 } = await getAppConfig()
const logs = await readdir(logDir())
for (const log of logs) {
const date = new Date(log.split('.')[0])
const diff = Date.now() - date.getTime()
if (diff > maxLogDays * 24 * 60 * 60 * 1000) {
try {
await rm(path.join(logDir(), log))
} catch {
// ignore
}
}
}
}
async function cleanup(): Promise<void> {
const [dataFiles, logFiles] = await Promise.all([readdir(dataDir()), readdir(logDir())])
// 清理更新缓存
const cacheExtensions = ['.exe', '.pkg', '.7z']
const cacheCleanup = dataFiles
.filter((file) => cacheExtensions.some((ext) => file.endsWith(ext)))
.map((file) => rm(path.join(dataDir(), file)).catch(() => {}))
// 清理过期日志
const { maxLogDays = 7 } = await getAppConfig()
const maxAge = maxLogDays * 24 * 60 * 60 * 1000
const datePattern = /^\d{4}-\d{2}-\d{2}/
const logCleanup = logFiles
.filter((log) => {
const match = log.match(datePattern)
if (!match) return false
const date = new Date(match[0])
return !isNaN(date.getTime()) && Date.now() - date.getTime() > maxAge
})
.map((log) => rm(path.join(logDir(), log)).catch(() => {}))
await Promise.all([...cacheCleanup, ...logCleanup])
}
async function migrateSubStoreFiles(): Promise<void> {
const oldJsPath = path.join(mihomoWorkDir(), 'sub-store.bundle.js')
const newCjsPath = path.join(mihomoWorkDir(), 'sub-store.bundle.cjs')
if (existsSync(oldJsPath) && !existsSync(newCjsPath)) {
try {
await rename(oldJsPath, newCjsPath)
} catch (error) {
await initLogger.error('Failed to rename sub-store.bundle.js to sub-store.bundle.cjs', error)
}
}
}
// 迁移:添加 substore 到侧边栏
async function migrateSiderOrder(): Promise<void> {
const { siderOrder = [], useSubStore = true } = await getAppConfig()
async function migration(): Promise<void> {
const {
siderOrder = [
'sysproxy',
'tun',
'profile',
'proxy',
'rule',
'resource',
'override',
'connection',
'mihomo',
'dns',
'sniff',
'log',
'substore'
],
appTheme = 'system',
envType = [process.platform === 'win32' ? 'powershell' : 'bash'],
useSubStore = true,
showFloatingWindow = false,
disableTray = false,
encryptedPassword
} = await getAppConfig()
const {
'external-controller-pipe': externalControllerPipe,
'external-controller-unix': externalControllerUnix,
'external-controller': externalController,
'skip-auth-prefixes': skipAuthPrefixes,
authentication,
'bind-address': bindAddress,
'lan-allowed-ips': lanAllowedIps,
'lan-disallowed-ips': lanDisallowedIps
} = await getControledMihomoConfig()
// add substore sider card
if (useSubStore && !siderOrder.includes('substore')) {
await patchAppConfig({ siderOrder: [...siderOrder, 'substore'] })
}
}
// 迁移:修复 appTheme
async function migrateAppTheme(): Promise<void> {
const { appTheme = 'system' } = await getAppConfig()
// add default skip auth prefix
if (!skipAuthPrefixes) {
await patchControledMihomoConfig({ 'skip-auth-prefixes': ['127.0.0.1/32'] })
}
// add default authentication
if (!authentication) {
await patchControledMihomoConfig({ authentication: [] })
}
// add default bind address
if (!bindAddress) {
await patchControledMihomoConfig({ 'bind-address': '*' })
}
// add default lan allowed ips
if (!lanAllowedIps) {
await patchControledMihomoConfig({ 'lan-allowed-ips': ['0.0.0.0/0', '::/0'] })
}
// add default lan disallowed ips
if (!lanDisallowedIps) {
await patchControledMihomoConfig({ 'lan-disallowed-ips': [] })
}
// remove custom app theme
if (!['system', 'light', 'dark'].includes(appTheme)) {
await patchAppConfig({ appTheme: 'system' })
}
}
// 迁移envType 字符串转数组
async function migrateEnvType(): Promise<void> {
const { envType } = await getAppConfig()
// change env type
if (typeof envType === 'string') {
await patchAppConfig({ envType: [envType] })
}
}
// 迁移:禁用托盘时必须显示悬浮窗
async function migrateTraySettings(): Promise<void> {
const { showFloatingWindow = false, disableTray = false } = await getAppConfig()
// 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 })
}
}
// 迁移:移除加密密码
async function migrateRemovePassword(): Promise<void> {
const { encryptedPassword } = await getAppConfig()
// remove password
if (encryptedPassword) {
await patchAppConfig({ encryptedPassword: undefined })
}
}
// 迁移mihomo 配置默认值
async function migrateMihomoConfig(): Promise<void> {
const config = await getControledMihomoConfig()
const patches: Partial<IMihomoConfig> = {}
// skip-auth-prefixes
if (!config['skip-auth-prefixes']) {
patches['skip-auth-prefixes'] = ['127.0.0.1/32', '::1/128']
} else if (
config['skip-auth-prefixes'].length >= 1 &&
config['skip-auth-prefixes'][0] === '127.0.0.1/32' &&
!config['skip-auth-prefixes'].includes('::1/128')
) {
patches['skip-auth-prefixes'] = [
'127.0.0.1/32',
'::1/128',
...config['skip-auth-prefixes'].slice(1)
]
}
// 其他默认值
if (!config.authentication) patches.authentication = []
if (!config['bind-address']) patches['bind-address'] = '*'
if (!config['lan-allowed-ips']) patches['lan-allowed-ips'] = ['0.0.0.0/0', '::/0']
if (!config['lan-disallowed-ips']) patches['lan-disallowed-ips'] = []
// tun device
if (!config.tun?.device || (process.platform === 'darwin' && config.tun.device === 'Mihomo')) {
patches.tun = {
...config.tun,
device: process.platform === 'darwin' ? 'utun1500' : 'Mihomo'
}
}
// 移除废弃配置
if (config['external-controller-unix']) patches['external-controller-unix'] = undefined
if (config['external-controller-pipe']) patches['external-controller-pipe'] = undefined
if (config['external-controller'] === undefined) patches['external-controller'] = ''
if (Object.keys(patches).length > 0) {
await patchControledMihomoConfig(patches)
}
}
async function migration(): Promise<void> {
await Promise.all([
migrateSiderOrder(),
migrateAppTheme(),
migrateEnvType(),
migrateTraySettings(),
migrateRemovePassword(),
migrateMihomoConfig()
])
}
function initDeeplink(): void {
if (process.defaultApp) {
if (process.argv.length >= 2) {
@ -375,41 +263,24 @@ function initDeeplink(): void {
}
}
export async function initBasic(): Promise<void> {
if (isInitBasicCompleted) return
export async function init(): Promise<void> {
await initDirs()
await initConfig()
await migration()
await migrateSubStoreFiles()
await initFiles()
await cleanup()
isInitBasicCompleted = true
}
export async function init(): Promise<void> {
await startSubStoreFrontendServer()
await startSubStoreBackendServer()
const { sysProxy } = await getAppConfig()
try {
if (sysProxy.enable) {
await startPacServer()
}
await triggerSysProxy(sysProxy.enable)
} catch {
// ignore
}
await startSSIDCheck()
const initTasks: Promise<void>[] = [
startSubStoreFrontendServer(),
startSubStoreBackendServer(),
startSSIDCheck()
]
initTasks.push(
(async (): Promise<void> => {
try {
if (sysProxy.enable) {
await startPacServer()
}
await triggerSysProxy(sysProxy.enable)
} catch {
// ignore
}
})()
)
await Promise.all(initTasks)
initDeeplink()
}

View File

@ -1,8 +1,4 @@
import path from 'path'
import v8 from 'v8'
import { readFile, writeFile } from 'fs/promises'
import { app, ipcMain } from 'electron'
import i18next from 'i18next'
import { app, dialog, ipcMain } from 'electron'
import {
mihomoChangeProxy,
mihomoCloseAllConnections,
@ -19,13 +15,8 @@ import {
mihomoUpdateRuleProviders,
mihomoUpgrade,
mihomoUpgradeGeo,
mihomoUpgradeUI,
mihomoUpgradeConfig,
mihomoVersion,
patchMihomoConfig,
mihomoSmartGroupWeights,
mihomoSmartFlushCache,
mihomoRulesDisable
patchMihomoConfig
} from '../core/mihomoApi'
import { checkAutoRun, disableAutoRun, enableAutoRun } from '../sys/autoRun'
import {
@ -52,8 +43,7 @@ import {
removeOverrideItem,
getOverride,
setOverride,
updateOverrideItem,
convertMrsRuleset
updateOverrideItem
} from '../config'
import {
startSubStoreFrontendServer,
@ -64,20 +54,7 @@ import {
subStoreFrontendPort,
subStorePort
} from '../resolve/server'
import {
quitWithoutCore,
restartCore,
checkTunPermissions,
grantTunPermissions,
manualGrantCorePermition,
checkAdminPrivileges,
restartAsAdmin,
checkMihomoCorePermissions,
requestTunPermissions,
checkHighPrivilegeCore,
showTunPermissionDialog,
showErrorDialog
} from '../core/manager'
import { manualGrantCorePermition, quitWithoutCore, restartCore } from '../core/manager'
import { triggerSysProxy } from '../sys/sysproxy'
import { checkUpdate, downloadAndInstallUpdate } from '../resolve/autoUpdater'
import {
@ -90,25 +67,11 @@ import {
setupFirewall
} from '../sys/misc'
import { getRuntimeConfig, getRuntimeConfigStr } from '../core/factory'
import {
listWebdavBackups,
webdavBackup,
webdavDelete,
webdavRestore,
exportLocalBackup,
importLocalBackup,
reinitScheduler
} from '../resolve/backup'
import { listWebdavBackups, webdavBackup, webdavDelete, webdavRestore } from '../resolve/backup'
import { getInterfaces } from '../sys/interface'
import {
closeTrayIcon,
copyEnv,
showTrayIcon,
updateTrayIcon,
updateTrayIconImmediate
} from '../resolve/tray'
import { closeTrayIcon, copyEnv, showTrayIcon } from '../resolve/tray'
import { registerShortcut } from '../resolve/shortcut'
import { closeMainWindow, mainWindow, showMainWindow, triggerMainWindow } from '../window'
import { closeMainWindow, mainWindow, showMainWindow, triggerMainWindow } from '..'
import {
applyTheme,
fetchThemes,
@ -118,246 +81,188 @@ import {
writeTheme
} from '../resolve/theme'
import { subStoreCollections, subStoreSubs } from '../core/subStoreApi'
import { logDir } from './dirs'
import path from 'path'
import v8 from 'v8'
import { getGistUrl } from '../resolve/gistApi'
import { getImageDataURL } from './image'
import { startMonitor } from '../resolve/trafficMonitor'
import { closeFloatingWindow, showContextMenu, showFloatingWindow } from '../resolve/floatingWindow'
import { addProfileUpdater, removeProfileUpdater } from '../core/profileUpdater'
import { getImageDataURL } from './image'
import { getIconDataURL } from './icon'
import { getAppName } from './appName'
import { logDir, rulePath } from './dirs'
import { installMihomoCore, getGitHubTags, clearVersionCache } from './github'
import i18next from 'i18next'
import { addProfileUpdater } from '../core/profileUpdater'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AsyncFn = (...args: any[]) => Promise<any>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SyncFn = (...args: any[]) => any
function wrapAsync<T extends AsyncFn>(
fn: T
): (...args: Parameters<T>) => Promise<ReturnType<T> | { invokeError: unknown }> {
return async (...args) => {
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
): (...args: any[]) => Promise<T | { invokeError: unknown }> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return async (...args: any[]) => {
try {
return await fn(...args)
} catch (e) {
if (e && typeof e === 'object' && 'message' in e) {
return { invokeError: e.message }
if (e && typeof e === 'object') {
if ('message' in e) {
return { invokeError: e.message }
} else {
return { invokeError: JSON.stringify(e) }
}
}
return { invokeError: typeof e === 'string' ? e : 'Unknown Error' }
if (e instanceof Error || typeof e === 'string') {
return { invokeError: e }
}
return { invokeError: 'Unknown Error' }
}
}
}
function registerHandlers(handlers: Record<string, AsyncFn | SyncFn>, async = true): void {
for (const [channel, handler] of Object.entries(handlers)) {
if (async) {
ipcMain.handle(channel, (_e, ...args) => wrapAsync(handler as AsyncFn)(...args))
} else {
ipcMain.handle(channel, (_e, ...args) => (handler as SyncFn)(...args))
}
}
}
async function fetchMihomoTags(
forceRefresh = false
): Promise<{ name: string; zipball_url: string; tarball_url: string }[]> {
return await getGitHubTags('MetaCubeX', 'mihomo', forceRefresh)
}
async function installSpecificMihomoCore(version: string): Promise<void> {
clearVersionCache('MetaCubeX', 'mihomo')
return await installMihomoCore(version)
}
async function clearMihomoVersionCache(): Promise<void> {
clearVersionCache('MetaCubeX', 'mihomo')
}
async function getRuleStr(id: string): Promise<string> {
return await readFile(rulePath(id), 'utf-8')
}
async function setRuleStr(id: string, str: string): Promise<void> {
await writeFile(rulePath(id), str, 'utf-8')
}
async function getSmartOverrideContent(): Promise<string | null> {
try {
const override = await getOverrideItem('smart-core-override')
return override?.file || null
} catch {
return null
}
}
async function changeLanguage(lng: string): Promise<void> {
await i18next.changeLanguage(lng)
ipcMain.emit('updateTrayMenu')
}
async function setTitleBarOverlay(overlay: Electron.TitleBarOverlayOptions): Promise<void> {
if (mainWindow && typeof mainWindow.setTitleBarOverlay === 'function') {
mainWindow.setTitleBarOverlay(overlay)
}
}
const asyncHandlers: Record<string, AsyncFn> = {
// Mihomo API
mihomoVersion,
mihomoCloseConnection,
mihomoCloseAllConnections,
mihomoRules,
mihomoRulesDisable,
mihomoProxies,
mihomoGroups,
mihomoProxyProviders,
mihomoUpdateProxyProviders,
mihomoRuleProviders,
mihomoUpdateRuleProviders,
mihomoChangeProxy,
mihomoUnfixedProxy,
mihomoUpgradeGeo,
mihomoUpgrade,
mihomoUpgradeUI,
mihomoUpgradeConfig,
mihomoProxyDelay,
mihomoGroupDelay,
patchMihomoConfig,
mihomoSmartGroupWeights,
mihomoSmartFlushCache,
// AutoRun
checkAutoRun,
enableAutoRun,
disableAutoRun,
// Config
getAppConfig,
patchAppConfig,
getControledMihomoConfig,
patchControledMihomoConfig,
// Profile
getProfileConfig,
setProfileConfig,
getCurrentProfileItem,
getProfileItem,
getProfileStr,
setProfileStr,
addProfileItem,
removeProfileItem,
updateProfileItem,
changeCurrentProfile,
addProfileUpdater,
removeProfileUpdater,
// Override
getOverrideConfig,
setOverrideConfig,
getOverrideItem,
addOverrideItem,
removeOverrideItem,
updateOverrideItem,
getOverride,
setOverride,
// File
getFileStr,
setFileStr,
convertMrsRuleset,
getRuntimeConfig,
getRuntimeConfigStr,
getSmartOverrideContent,
getRuleStr,
setRuleStr,
readTextFile,
// Core
restartCore,
startMonitor,
quitWithoutCore,
// System
triggerSysProxy,
checkTunPermissions,
grantTunPermissions,
manualGrantCorePermition,
checkAdminPrivileges,
restartAsAdmin,
checkMihomoCorePermissions,
requestTunPermissions,
checkHighPrivilegeCore,
showTunPermissionDialog,
showErrorDialog,
openUWPTool,
setupFirewall,
copyEnv,
// Update
checkUpdate,
downloadAndInstallUpdate,
fetchMihomoTags,
installSpecificMihomoCore,
clearMihomoVersionCache,
// Backup
webdavBackup,
webdavRestore,
listWebdavBackups,
webdavDelete,
reinitWebdavBackupScheduler: reinitScheduler,
exportLocalBackup,
importLocalBackup,
// SubStore
startSubStoreFrontendServer,
stopSubStoreFrontendServer,
startSubStoreBackendServer,
stopSubStoreBackendServer,
downloadSubStore,
subStoreSubs,
subStoreCollections,
// Theme
resolveThemes,
fetchThemes,
importThemes,
readTheme,
writeTheme,
applyTheme,
// Tray
showTrayIcon,
closeTrayIcon,
updateTrayIcon,
// Floating Window
showFloatingWindow,
closeFloatingWindow,
showContextMenu,
// Misc
getGistUrl,
getImageDataURL,
getIconDataURL,
getAppName,
changeLanguage,
setTitleBarOverlay,
registerShortcut
}
const syncHandlers: Record<string, SyncFn> = {
resetAppConfig,
getFilePath,
openFile,
getInterfaces,
setNativeTheme,
getVersion: () => app.getVersion(),
platform: () => process.platform,
subStorePort: () => subStorePort,
subStoreFrontendPort: () => subStoreFrontendPort,
updateTrayIconImmediate,
showMainWindow,
closeMainWindow,
triggerMainWindow,
setAlwaysOnTop: (alwaysOnTop: boolean) => mainWindow?.setAlwaysOnTop(alwaysOnTop),
isAlwaysOnTop: () => mainWindow?.isAlwaysOnTop(),
openDevTools: () => mainWindow?.webContents.openDevTools(),
createHeapSnapshot: () => v8.writeHeapSnapshot(path.join(logDir(), `${Date.now()}.heapsnapshot`)),
relaunchApp: () => {
export function registerIpcMainHandlers(): void {
ipcMain.handle('mihomoVersion', ipcErrorWrapper(mihomoVersion))
ipcMain.handle('mihomoCloseConnection', (_e, id) => ipcErrorWrapper(mihomoCloseConnection)(id))
ipcMain.handle('mihomoCloseAllConnections', ipcErrorWrapper(mihomoCloseAllConnections))
ipcMain.handle('mihomoRules', ipcErrorWrapper(mihomoRules))
ipcMain.handle('mihomoProxies', ipcErrorWrapper(mihomoProxies))
ipcMain.handle('mihomoGroups', ipcErrorWrapper(mihomoGroups))
ipcMain.handle('mihomoProxyProviders', ipcErrorWrapper(mihomoProxyProviders))
ipcMain.handle('mihomoUpdateProxyProviders', (_e, name) =>
ipcErrorWrapper(mihomoUpdateProxyProviders)(name)
)
ipcMain.handle('mihomoRuleProviders', ipcErrorWrapper(mihomoRuleProviders))
ipcMain.handle('mihomoUpdateRuleProviders', (_e, name) =>
ipcErrorWrapper(mihomoUpdateRuleProviders)(name)
)
ipcMain.handle('mihomoChangeProxy', (_e, group, proxy) =>
ipcErrorWrapper(mihomoChangeProxy)(group, proxy)
)
ipcMain.handle('mihomoUnfixedProxy', (_e, group) => ipcErrorWrapper(mihomoUnfixedProxy)(group))
ipcMain.handle('mihomoUpgradeGeo', ipcErrorWrapper(mihomoUpgradeGeo))
ipcMain.handle('mihomoUpgrade', ipcErrorWrapper(mihomoUpgrade))
ipcMain.handle('mihomoProxyDelay', (_e, proxy, url) =>
ipcErrorWrapper(mihomoProxyDelay)(proxy, url)
)
ipcMain.handle('mihomoGroupDelay', (_e, group, url) =>
ipcErrorWrapper(mihomoGroupDelay)(group, url)
)
ipcMain.handle('patchMihomoConfig', (_e, patch) => ipcErrorWrapper(patchMihomoConfig)(patch))
ipcMain.handle('checkAutoRun', ipcErrorWrapper(checkAutoRun))
ipcMain.handle('enableAutoRun', ipcErrorWrapper(enableAutoRun))
ipcMain.handle('disableAutoRun', ipcErrorWrapper(disableAutoRun))
ipcMain.handle('getAppConfig', (_e, force) => ipcErrorWrapper(getAppConfig)(force))
ipcMain.handle('patchAppConfig', (_e, config) => ipcErrorWrapper(patchAppConfig)(config))
ipcMain.handle('getControledMihomoConfig', (_e, force) =>
ipcErrorWrapper(getControledMihomoConfig)(force)
)
ipcMain.handle('patchControledMihomoConfig', (_e, config) =>
ipcErrorWrapper(patchControledMihomoConfig)(config)
)
ipcMain.handle('getProfileConfig', (_e, force) => ipcErrorWrapper(getProfileConfig)(force))
ipcMain.handle('setProfileConfig', (_e, config) => ipcErrorWrapper(setProfileConfig)(config))
ipcMain.handle('getCurrentProfileItem', ipcErrorWrapper(getCurrentProfileItem))
ipcMain.handle('getProfileItem', (_e, id) => ipcErrorWrapper(getProfileItem)(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('updateProfileItem', (_e, item) => ipcErrorWrapper(updateProfileItem)(item))
ipcMain.handle('changeCurrentProfile', (_e, id) => ipcErrorWrapper(changeCurrentProfile)(id))
ipcMain.handle('addProfileItem', (_e, item) => ipcErrorWrapper(addProfileItem)(item))
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('setOverrideConfig', (_e, config) => ipcErrorWrapper(setOverrideConfig)(config))
ipcMain.handle('getOverrideItem', (_e, id) => ipcErrorWrapper(getOverrideItem)(id))
ipcMain.handle('addOverrideItem', (_e, item) => ipcErrorWrapper(addOverrideItem)(item))
ipcMain.handle('removeOverrideItem', (_e, id) => ipcErrorWrapper(removeOverrideItem)(id))
ipcMain.handle('updateOverrideItem', (_e, item) => ipcErrorWrapper(updateOverrideItem)(item))
ipcMain.handle('getOverride', (_e, id, ext) => ipcErrorWrapper(getOverride)(id, ext))
ipcMain.handle('setOverride', (_e, id, ext, str) => ipcErrorWrapper(setOverride)(id, ext, str))
ipcMain.handle('restartCore', ipcErrorWrapper(restartCore))
ipcMain.handle('startMonitor', (_e, detached) => ipcErrorWrapper(startMonitor)(detached))
ipcMain.handle('triggerSysProxy', (_e, enable) => ipcErrorWrapper(triggerSysProxy)(enable))
ipcMain.handle('manualGrantCorePermition', () => ipcErrorWrapper(manualGrantCorePermition)())
ipcMain.handle('getFilePath', (_e, ext) => getFilePath(ext))
ipcMain.handle('readTextFile', (_e, filePath) => ipcErrorWrapper(readTextFile)(filePath))
ipcMain.handle('getRuntimeConfigStr', ipcErrorWrapper(getRuntimeConfigStr))
ipcMain.handle('getRuntimeConfig', ipcErrorWrapper(getRuntimeConfig))
ipcMain.handle('downloadAndInstallUpdate', (_e, version) =>
ipcErrorWrapper(downloadAndInstallUpdate)(version)
)
ipcMain.handle('checkUpdate', ipcErrorWrapper(checkUpdate))
ipcMain.handle('getVersion', () => app.getVersion())
ipcMain.handle('platform', () => process.platform)
ipcMain.handle('openUWPTool', ipcErrorWrapper(openUWPTool))
ipcMain.handle('setupFirewall', ipcErrorWrapper(setupFirewall))
ipcMain.handle('getInterfaces', getInterfaces)
ipcMain.handle('webdavBackup', ipcErrorWrapper(webdavBackup))
ipcMain.handle('webdavRestore', (_e, filename) => ipcErrorWrapper(webdavRestore)(filename))
ipcMain.handle('listWebdavBackups', ipcErrorWrapper(listWebdavBackups))
ipcMain.handle('webdavDelete', (_e, filename) => ipcErrorWrapper(webdavDelete)(filename))
ipcMain.handle('registerShortcut', (_e, oldShortcut, newShortcut, action) =>
ipcErrorWrapper(registerShortcut)(oldShortcut, newShortcut, action)
)
ipcMain.handle('startSubStoreFrontendServer', () =>
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('subStoreFrontendPort', () => subStoreFrontendPort)
ipcMain.handle('subStoreSubs', () => ipcErrorWrapper(subStoreSubs)())
ipcMain.handle('subStoreCollections', () => ipcErrorWrapper(subStoreCollections)())
ipcMain.handle('getGistUrl', ipcErrorWrapper(getGistUrl))
ipcMain.handle('setNativeTheme', (_e, theme) => {
setNativeTheme(theme)
})
ipcMain.handle('setTitleBarOverlay', (_e, overlay) =>
ipcErrorWrapper(async (overlay): Promise<void> => {
if (mainWindow && typeof mainWindow.setTitleBarOverlay === 'function') {
mainWindow.setTitleBarOverlay(overlay)
}
})(overlay)
)
ipcMain.handle('setAlwaysOnTop', (_e, alwaysOnTop) => {
mainWindow?.setAlwaysOnTop(alwaysOnTop)
})
ipcMain.handle('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('openDevTools', () => {
mainWindow?.webContents.openDevTools()
})
ipcMain.handle('createHeapSnapshot', () => {
v8.writeHeapSnapshot(path.join(logDir(), `${Date.now()}.heapsnapshot`))
})
ipcMain.handle('getImageDataURL', (_e, url) => ipcErrorWrapper(getImageDataURL)(url))
ipcMain.handle('resolveThemes', () => ipcErrorWrapper(resolveThemes)())
ipcMain.handle('fetchThemes', () => ipcErrorWrapper(fetchThemes)())
ipcMain.handle('importThemes', (_e, file) => ipcErrorWrapper(importThemes)(file))
ipcMain.handle('readTheme', (_e, theme) => ipcErrorWrapper(readTheme)(theme))
ipcMain.handle('writeTheme', (_e, theme, css) => ipcErrorWrapper(writeTheme)(theme, css))
ipcMain.handle('applyTheme', (_e, theme) => ipcErrorWrapper(applyTheme)(theme))
ipcMain.handle('copyEnv', (_e, type) => ipcErrorWrapper(copyEnv)(type))
ipcMain.handle('alert', (_e, msg) => {
dialog.showErrorBox('Mihomo Party', msg)
})
ipcMain.handle('resetAppConfig', resetAppConfig)
ipcMain.handle('relaunchApp', () => {
app.relaunch()
app.quit()
},
quitApp: () => app.quit()
}
})
ipcMain.handle('quitWithoutCore', ipcErrorWrapper(quitWithoutCore))
ipcMain.handle('quitApp', () => app.quit())
export function registerIpcMainHandlers(): void {
registerHandlers(asyncHandlers, true)
registerHandlers(syncHandlers, false)
// Add language change handler
ipcMain.handle('changeLanguage', async (_e, lng) => {
await i18next.changeLanguage(lng)
// 触发托盘菜单更新
ipcMain.emit('updateTrayMenu')
})
}

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