Repository: mihomo-party-org/clash-party
Branch: smart_core
Commit: e2fbb2a8ad9b
Files: 216
Total size: 3.4 MB
Directory structure:
gitextract_lnqrk73k/
├── .editorconfig
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report_zh.yml
│ │ ├── config.yml
│ │ └── feature_request_zh.yml
│ └── workflows/
│ ├── build.yml
│ └── issues.yml
├── .gitignore
├── .npmrc
├── .prettierignore
├── .prettierrc.yaml
├── .vscode/
│ ├── extensions.json
│ ├── launch.json
│ └── settings.json
├── LICENSE
├── README.md
├── aur/
│ ├── mihomo-party/
│ │ ├── PKGBUILD
│ │ ├── mihomo-party.install
│ │ └── mihomo-party.sh
│ ├── mihomo-party-bin/
│ │ ├── PKGBUILD
│ │ ├── mihomo-party.install
│ │ └── mihomo-party.sh
│ ├── mihomo-party-electron/
│ │ ├── PKGBUILD
│ │ ├── mihomo-party.desktop
│ │ ├── mihomo-party.install
│ │ └── mihomo-party.sh
│ ├── mihomo-party-electron-bin/
│ │ ├── PKGBUILD
│ │ ├── mihomo-party.desktop
│ │ ├── mihomo-party.install
│ │ └── mihomo-party.sh
│ └── mihomo-party-git/
│ ├── PKGBUILD
│ ├── mihomo-party.install
│ └── mihomo-party.sh
├── build/
│ ├── entitlements.mac.plist
│ ├── icon.icns
│ ├── linux/
│ │ ├── postinst
│ │ └── postuninst
│ └── pkg-scripts/
│ ├── postinstall
│ └── preinstall
├── changelog.md
├── electron-builder.yml
├── electron.vite.config.ts
├── eslint.config.cjs
├── package.json
├── scripts/
│ ├── checksum.mjs
│ ├── cleanup-mac.sh
│ ├── copy-legacy-artifacts.mjs
│ ├── prepare.mjs
│ ├── telegram.mjs
│ ├── update-version.mjs
│ ├── updater.mjs
│ └── version-utils.mjs
├── src/
│ ├── main/
│ │ ├── config/
│ │ │ ├── app.ts
│ │ │ ├── controledMihomo.ts
│ │ │ ├── index.ts
│ │ │ ├── override.ts
│ │ │ ├── profile.ts
│ │ │ └── smartOverride.ts
│ │ ├── core/
│ │ │ ├── dns.ts
│ │ │ ├── factory.ts
│ │ │ ├── manager.ts
│ │ │ ├── mihomoApi.ts
│ │ │ ├── permissions.ts
│ │ │ ├── process.ts
│ │ │ ├── profileUpdater.ts
│ │ │ └── subStoreApi.ts
│ │ ├── deeplink.ts
│ │ ├── index.ts
│ │ ├── lifecycle.ts
│ │ ├── resolve/
│ │ │ ├── autoUpdater.ts
│ │ │ ├── backup.ts
│ │ │ ├── floatingWindow.ts
│ │ │ ├── gistApi.ts
│ │ │ ├── server.ts
│ │ │ ├── shortcut.ts
│ │ │ ├── theme.ts
│ │ │ ├── trafficMonitor.ts
│ │ │ └── tray.ts
│ │ ├── sys/
│ │ │ ├── autoRun.ts
│ │ │ ├── interface.ts
│ │ │ ├── misc.ts
│ │ │ ├── ssid.ts
│ │ │ └── sysproxy.ts
│ │ ├── utils/
│ │ │ ├── appName.ts
│ │ │ ├── calc.ts
│ │ │ ├── chromeRequest.ts
│ │ │ ├── defaultIcon.ts
│ │ │ ├── dirs.ts
│ │ │ ├── github.ts
│ │ │ ├── icon.ts
│ │ │ ├── image.ts
│ │ │ ├── init.ts
│ │ │ ├── ipc.ts
│ │ │ ├── logger.ts
│ │ │ ├── merge.ts
│ │ │ ├── template.ts
│ │ │ └── yaml.ts
│ │ └── window.ts
│ ├── native/
│ │ └── sysproxy/
│ │ ├── index.d.ts
│ │ ├── index.js
│ │ └── package.json
│ ├── preload/
│ │ ├── index.d.ts
│ │ └── index.ts
│ ├── renderer/
│ │ ├── floating.html
│ │ ├── index.html
│ │ └── src/
│ │ ├── App.tsx
│ │ ├── FloatingApp.tsx
│ │ ├── assets/
│ │ │ ├── floating.css
│ │ │ ├── hero.ts
│ │ │ └── main.css
│ │ ├── components/
│ │ │ ├── base/
│ │ │ │ ├── base-confirm-modal.tsx
│ │ │ │ ├── base-editor.tsx
│ │ │ │ ├── base-error-boundary.tsx
│ │ │ │ ├── base-page.tsx
│ │ │ │ ├── base-setting-card.tsx
│ │ │ │ ├── base-setting-item.tsx
│ │ │ │ ├── border-switch.css
│ │ │ │ ├── border-swtich.tsx
│ │ │ │ ├── collapse-input.tsx
│ │ │ │ ├── mihomo-icon.tsx
│ │ │ │ ├── substore-icon.tsx
│ │ │ │ └── toast.tsx
│ │ │ ├── connections/
│ │ │ │ ├── connection-detail-modal.tsx
│ │ │ │ ├── connection-item.tsx
│ │ │ │ └── connection-table.tsx
│ │ │ ├── logs/
│ │ │ │ └── log-item.tsx
│ │ │ ├── mihomo/
│ │ │ │ └── interface-modal.tsx
│ │ │ ├── override/
│ │ │ │ ├── edit-file-modal.tsx
│ │ │ │ ├── edit-info-modal.tsx
│ │ │ │ ├── exec-log-modal.tsx
│ │ │ │ └── override-item.tsx
│ │ │ ├── profiles/
│ │ │ │ ├── edit-file-modal.tsx
│ │ │ │ ├── edit-info-modal.tsx
│ │ │ │ ├── edit-rules-modal.tsx
│ │ │ │ └── profile-item.tsx
│ │ │ ├── proxies/
│ │ │ │ └── proxy-item.tsx
│ │ │ ├── resources/
│ │ │ │ ├── geo-data.tsx
│ │ │ │ ├── proxy-provider.tsx
│ │ │ │ ├── rule-provider.tsx
│ │ │ │ └── viewer.tsx
│ │ │ ├── rules/
│ │ │ │ └── rule-item.tsx
│ │ │ ├── settings/
│ │ │ │ ├── actions.tsx
│ │ │ │ ├── css-editor-modal.tsx
│ │ │ │ ├── general-config.tsx
│ │ │ │ ├── local-backup-config.tsx
│ │ │ │ ├── mihomo-config.tsx
│ │ │ │ ├── shortcut-config.tsx
│ │ │ │ ├── sider-config.tsx
│ │ │ │ ├── substore-config.tsx
│ │ │ │ ├── webdav-config.tsx
│ │ │ │ └── webdav-restore-modal.tsx
│ │ │ ├── sider/
│ │ │ │ ├── config-viewer.tsx
│ │ │ │ ├── conn-card.tsx
│ │ │ │ ├── dns-card.tsx
│ │ │ │ ├── log-card.tsx
│ │ │ │ ├── mihomo-core-card.tsx
│ │ │ │ ├── outbound-mode-switcher.tsx
│ │ │ │ ├── override-card.tsx
│ │ │ │ ├── profile-card.tsx
│ │ │ │ ├── proxy-card.tsx
│ │ │ │ ├── resource-card.tsx
│ │ │ │ ├── rule-card.tsx
│ │ │ │ ├── sniff-card.tsx
│ │ │ │ ├── substore-card.tsx
│ │ │ │ ├── sysproxy-switcher.tsx
│ │ │ │ └── tun-switcher.tsx
│ │ │ ├── sysproxy/
│ │ │ │ └── pac-editor-modal.tsx
│ │ │ └── updater/
│ │ │ ├── updater-button.tsx
│ │ │ └── updater-modal.tsx
│ │ ├── floating.tsx
│ │ ├── hooks/
│ │ │ ├── create-config-context.tsx
│ │ │ ├── use-app-config.tsx
│ │ │ ├── use-controled-mihomo-config.tsx
│ │ │ ├── use-groups.tsx
│ │ │ ├── use-override-config.tsx
│ │ │ ├── use-profile-config.tsx
│ │ │ └── use-rules.tsx
│ │ ├── i18n.ts
│ │ ├── locales/
│ │ │ ├── en-US.json
│ │ │ ├── fa-IR.json
│ │ │ ├── ru-RU.json
│ │ │ ├── zh-CN.json
│ │ │ └── zh-TW.json
│ │ ├── main.tsx
│ │ ├── pages/
│ │ │ ├── connections.tsx
│ │ │ ├── dns.tsx
│ │ │ ├── logs.tsx
│ │ │ ├── mihomo.tsx
│ │ │ ├── override.tsx
│ │ │ ├── profiles.tsx
│ │ │ ├── proxies.tsx
│ │ │ ├── resources.tsx
│ │ │ ├── rules.tsx
│ │ │ ├── settings.tsx
│ │ │ ├── sniffer.tsx
│ │ │ ├── substore.tsx
│ │ │ ├── sysproxy.tsx
│ │ │ └── tun.tsx
│ │ ├── routes/
│ │ │ └── index.tsx
│ │ └── utils/
│ │ ├── calc.ts
│ │ ├── dayjs.ts
│ │ ├── debounce.ts
│ │ ├── env.d.ts
│ │ ├── error-display.ts
│ │ ├── hash.ts
│ │ ├── icon-cache.ts
│ │ ├── image.ts
│ │ ├── includes.ts
│ │ ├── init.ts
│ │ ├── ipc.ts
│ │ ├── tour.ts
│ │ └── validate.ts
│ └── shared/
│ ├── i18n.ts
│ └── types.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── tsconfig.web.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report_zh.yml
================================================
name: 错误反馈
description: '提交 clash-party 漏洞'
title: '[Bug] '
body:
- type: checkboxes
id: ensure
attributes:
label: Verify steps
description: 在提交之前,请勾选以下所有选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。
options:
- label: 我已在标题简短的描述了我所遇到的问题
- label: 我已在 [Issue Tracker](./?q=is%3Aissue) 中寻找过我要提出的问题,但未找到相同的问题
- label: 我已在 [常见问题](https://clashparty.org/docs/issues/common) 中寻找过我要提出的问题,并没有找到答案
- label: 这是 GUI 程序的问题,而不是内核程序的问题
- label: 我已经关闭所有杀毒软件/代理软件后测试过,问题依旧存在
- label: 我已经使用最新的测试版本测试过,问题依旧存在
- type: dropdown
attributes:
label: 操作系统
description: 请提供操作系统类型
multiple: true
options:
- MacOS
- Windows
- Linux
validations:
required: true
- type: input
attributes:
label: 系统版本
description: 请提供出现问题的操作系统版本
validations:
required: true
- type: input
attributes:
label: 发生问题 clash-party 版本
validations:
required: true
- type: textarea
attributes:
label: 描述
description: 请提供错误的详细描述。
validations:
required: true
- type: textarea
attributes:
label: 重现方式
description: 请提供重现错误的步骤
validations:
required: true
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
contact_links:
- name: '常见问题'
about: '提出问题前请先查看常见问题'
url: 'https://clashparty.org/docs/issues/common'
- name: '交流群组'
about: '提问/讨论性质的问题请勿提交issue'
url: 'https://t.me/mihomo_party_group'
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request_zh.yml
================================================
name: 功能请求
description: '请求 clash-party 功能'
title: '[Feature] '
body:
- type: checkboxes
id: ensure
attributes:
label: Verify steps
description: 在提交之前,请勾选以下所有选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。
options:
- label: 我已在标题简短的描述了我所需的功能
- label: 我已在 [Issue Tracker](./?q=is%3Aissue) 中寻找过,但未找到我所需的功能
- label: 这是向 GUI 程序提出的功能请求,而不是内核程序
- label: 我未在最新的测试版本找到我所需的功能
- type: dropdown
attributes:
label: 操作系统
description: 请提供操作系统类型
multiple: true
options:
- MacOS
- Windows
- Linux
validations:
required: true
- type: textarea
attributes:
label: 描述
description: 请提供所需功能的详细描述
validations:
required: true
================================================
FILE: .github/workflows/build.yml
================================================
name: Build
on:
push:
tags:
- v*
paths-ignore:
- 'README.md'
- '.github/ISSUE_TEMPLATE/**'
- '.github/workflows/issues.yml'
workflow_dispatch:
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:
arch:
- x64
- ia32
- arm64
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: '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 }}
- name: Build
env:
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
run: pnpm build:win --${{ matrix.arch }}
- name: Add Portable Flag
run: |
New-Item -Path "PORTABLE" -ItemType File
Get-ChildItem dist/*portable.7z | ForEach-Object {
7z a $_.FullName PORTABLE
}
- 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
with:
name: Windows ${{ matrix.arch }}
path: |
dist/*.sha256
dist/*setup.exe
dist/*portable.7z
if-no-files-found: error
- name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.sha256
dist/*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
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:
arch:
- x64
- ia32
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'pnpm'
- name: Install Dependencies
env:
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
run: |
pnpm install
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 }}
- 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: |
New-Item -Path "PORTABLE" -ItemType File
Get-ChildItem dist/*portable.7z | ForEach-Object {
7z a $_.FullName PORTABLE
}
- 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
with:
name: Win7 ${{ matrix.arch }}
path: |
dist/*.sha256
dist/*setup.exe
dist/*portable.7z
if-no-files-found: error
- name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.sha256
dist/*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
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:
arch:
- x64
- arm64
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: '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 }}
- name: Build
env:
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
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
with:
name: Linux ${{ matrix.arch }}
path: |
dist/*.sha256
dist/*.deb
dist/*.rpm
if-no-files-found: error
- name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.sha256
dist/*.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
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:
arch:
- x64
- arm64
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: '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 }}
- name: Build
env:
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: |
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
with:
p12-file-base64: ${{ secrets.CSC_INSTALLER_LINK }}
p12-password: ${{ secrets.CSC_INSTALLER_KEY_PASSWORD }}
- name: Sign the Apple pkg
run: |
for pkg_name in $(ls -1 dist/*.pkg); do
pkg_name=$(ls -1 dist/*.pkg)
mv $pkg_name Unsigned-Workbench.pkg
productsign --sign "Developer ID Installer: Prometheus Advertising Corp (489PDK5LP3)" Unsigned-Workbench.pkg $pkg_name
rm -f Unsigned-Workbench.pkg
xcrun notarytool submit $pkg_name --apple-id $APPLE_ID --team-id $APPLE_TEAM_ID --password $APPLE_APP_SPECIFIC_PASSWORD --wait
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Generate checksums
run: pnpm checksum .pkg
- name: Copy Legacy Artifacts
run: pnpm copy-legacy
- name: Upload Artifacts
if: startsWith(github.ref, 'refs/heads/')
uses: actions/upload-artifact@v6
with:
name: MacOS ${{ matrix.arch }}
path: |
dist/*.sha256
dist/*.pkg
if-no-files-found: error
- name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.sha256
dist/*.pkg
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
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:
arch:
- x64
- arm64
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'pnpm'
- name: Install Dependencies
env:
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
run: |
pnpm install
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 }}
- name: Build
env:
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: |
sed -i "" -e "s/macos/catalina/" electron-builder.yml
chmod +x build/pkg-scripts/postinstall build/pkg-scripts/preinstall
pnpm build:mac --${{ matrix.arch }}
- name: Setup temporary installer signing keychain
uses: apple-actions/import-codesign-certs@v6
with:
p12-file-base64: ${{ secrets.CSC_INSTALLER_LINK }}
p12-password: ${{ secrets.CSC_INSTALLER_KEY_PASSWORD }}
- name: Sign the Apple pkg
run: |
for pkg_name in $(ls -1 dist/*.pkg); do
pkg_name=$(ls -1 dist/*.pkg)
mv $pkg_name Unsigned-Workbench.pkg
productsign --sign "Developer ID Installer: Prometheus Advertising Corp (489PDK5LP3)" Unsigned-Workbench.pkg $pkg_name
rm -f Unsigned-Workbench.pkg
xcrun notarytool submit $pkg_name --apple-id $APPLE_ID --team-id $APPLE_TEAM_ID --password $APPLE_APP_SPECIFIC_PASSWORD --wait
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Generate checksums
run: pnpm checksum .pkg
- name: Copy Legacy Artifacts
run: pnpm copy-legacy
- name: Upload Artifacts
if: startsWith(github.ref, 'refs/heads/')
uses: actions/upload-artifact@v6
with:
name: Catalina ${{ matrix.arch }}
path: |
dist/*.sha256
dist/*.pkg
if-no-files-found: error
- name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.sha256
dist/*.pkg
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
token: ${{ secrets.GITHUB_TOKEN }}
updater:
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
needs: [windows, windows7, linux, macos, macos10]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: '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:
fail-fast: false
matrix:
pkgname:
- mihomo-party-electron-bin
- mihomo-party-electron
- mihomo-party-bin
- mihomo-party
if: startsWith(github.ref, 'refs/tags/v')
needs: updater
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- 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
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
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
sed -i "s/sha256sums_aarch64=.*/sha256sums_aarch64=(\"$(sha256sum ./arm64.deb | awk '{print $1}')\")/" aur/mihomo-party-electron-bin/PKGBUILD
- name: Publish AUR package
uses: KSXGitHub/github-actions-deploy-aur@v2.7.2
with:
pkgname: ${{ matrix.pkgname }}
pkgbuild: aur/${{ matrix.pkgname }}/PKGBUILD
commit_username: pompurin404
commit_email: pompurin404@mihomo.party
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_message: Update AUR package
ssh_keyscan_types: rsa,ed25519
allow_empty_commits: false
aur-git-updater:
if: startsWith(github.ref, 'refs/heads/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: update version
run: |
sed -i "s/pkgver=.*/pkgver=$(git describe --long 2>/dev/null | sed 's/\([^-]*-g\)/r\1/;s/-/./g' | tr -d 'v')/" aur/mihomo-party-git/PKGBUILD
- name: Publish AUR package
uses: KSXGitHub/github-actions-deploy-aur@v2.7.2
with:
pkgname: mihomo-party-git
pkgbuild: aur/mihomo-party-git/PKGBUILD
commit_username: pompurin404
commit_email: pompurin404@mihomo.party
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_message: Update AUR package
ssh_keyscan_types: rsa,ed25519
allow_empty_commits: false
winget:
if: startsWith(github.ref, 'refs/tags/v')
name: Update WinGet Package
needs: windows
runs-on: ubuntu-latest
steps:
- name: Get Tag Name
run: echo "VERSION=$(echo ${{ github.ref }} | tr -d 'refs/tags/v')" >> $GITHUB_ENV
- name: Submit to Winget
uses: vedantmgoyal9/winget-releaser@main
with:
identifier: Mihomo-Party.Mihomo-Party
version: ${{env.VERSION}}
release-tag: v${{env.VERSION}}
installers-regex: 'clash-party-windows-.*setup\.exe$'
token: ${{ secrets.POMPURIN404_TOKEN }}
================================================
FILE: .github/workflows/issues.yml
================================================
name: Review Issues
on:
issues:
types: [opened]
jobs:
review:
runs-on: ubuntu-latest
steps:
- name: Generate Token
uses: tibdex/github-app-token@v2
id: generate
with:
app_id: ${{ secrets.BOT_APP_ID }}
private_key: ${{ secrets.BOT_PRIVATE_KEY }}
- name: Review Issues
uses: mihomo-party-org/universal-assistant@v1.0.3
with:
github_token: ${{ steps.generate.outputs.token }}
openai_base_url: ${{ secrets.OPENAI_BASE_URL }}
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
openai_model: ${{ vars.OPENAI_MODEL }}
system_prompt: ${{ vars.SYSTEM_PROMPT }}
available_tools: ${{ vars.AVAILABLE_TOOLS }}
user_input: |
请审查如下 Issue:
标题:"${{ github.event.issue.title }}"
内容:"${{ github.event.issue.body }}"
================================================
FILE: .gitignore
================================================
node_modules
resources/files
resources/sidecar
extra
dist
out
.DS_Store
*.log*
.idea
*.ttf
party.md
CLAUDE.md
tsconfig.node.tsbuildinfo
================================================
FILE: .npmrc
================================================
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
================================================
FILE: .prettierignore
================================================
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
================================================
FILE: .prettierrc.yaml
================================================
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
================================================
FILE: .vscode/extensions.json
================================================
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss"
]
}
================================================
FILE: .vscode/launch.json
================================================
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
},
"runtimeArgs": ["--sourcemap"],
"env": {
"REMOTE_DEBUGGING_PORT": "9222"
}
},
{
"name": "Debug Renderer Process",
"port": 9222,
"request": "attach",
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer",
"timeout": 60000,
"presentation": {
"hidden": true
}
}
],
"compounds": [
{
"name": "Debug All",
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"presentation": {
"order": 1
}
}
]
}
================================================
FILE: .vscode/settings.json
================================================
{
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"terminal.integrated.defaultProfile.windows": "PowerShell"
}
================================================
FILE: LICENSE
================================================
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
================================================
FILE: README.md
================================================
### 本项目认证稳定机场推荐:“[狗狗加速](https://party.dginv.click/#/register?code=ARdo0mXx)”
##### [狗狗加速 —— 技术流机场 Doggygo VPN](https://party.dginv.click/#/register?code=ARdo0mXx)
- 高性能海外机场,稳定首选,海外团队,无跑路风险
- Clash 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食用更省心!
- 解锁流媒体及 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] 通过 WebDAV 一键备份和恢复配置
- [x] 强大的覆写功能,任意修订配置文件
- [x] 深度集成 Sub-Store,轻松管理订阅
### 安装/使用指南见 [官方文档](https://clashparty.org)
================================================
FILE: aur/mihomo-party/PKGBUILD
================================================
pkgname=mihomo-party
pkgver=0.1.3
pkgrel=1
pkgdesc="Another Mihomo GUI."
arch=('x86_64' 'aarch64')
url="https://github.com/mihomo-party-org/mihomo-party"
license=('GPL3')
conflicts=("$pkgname-git" "$pkgname-bin" "$pkgname-electron" "$pkgname-electron-bin")
depends=('gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs' 'libsecret')
optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).')
makedepends=('nodejs' 'pnpm' 'libxcrypt-compat')
install=$pkgname.install
source=(
"${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz"
"${pkgname}.sh"
)
sha256sums=("52d761e9432e17477acb8adb5744676df946476e0eb5210fee2b6d45f497f218"
"242609b1259e999e944b7187f4c03aacba8134a7671ff3a50e2e246b4c4eff48")
options=('!lto')
prepare(){
cd $srcdir/clash-party-${pkgver}
sed -i "s/productName: Clash Party/productName: clash-party/" electron-builder.yml
pnpm install
}
build(){
cd $srcdir/clash-party-${pkgver}
pnpm build:linux deb
}
package() {
cd $srcdir/clash-party-${pkgver}/dist
bsdtar -xf clash-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
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"
chown -R root:root ${pkgdir}
}
================================================
FILE: aur/mihomo-party/mihomo-party.install
================================================
# Colored makepkg-like functions
note() {
printf "${_blue}==>${_yellow} NOTE:${_bold} %s${_all_off}\n" "$1"
}
_all_off="$(tput sgr0)"
_bold="${_all_off}$(tput bold)"
_blue="${_bold}$(tput setaf 4)"
_yellow="${_bold}$(tput setaf 3)"
post_install() {
note "Custom flags should be put directly in: ~/.config/mihomo-party-flags.conf"
note "The launcher is called: 'mihomo-party'"
}
================================================
FILE: aur/mihomo-party/mihomo-party.sh
================================================
#!/usr/bin/bash
XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-~/.config}
# Allow users to override command-line options
if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
mapfile -t MIHOMO_PARTY_USER_FLAGS <<<"$(grep -v '^#' "${XDG_CONFIG_HOME}/mihomo-party-flags.conf")"
echo "User flags:" ${MIHOMO_PARTY_USER_FLAGS[@]}
fi
# Launch
exec /opt/clash-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
================================================
FILE: aur/mihomo-party-bin/PKGBUILD
================================================
pkgname=mihomo-party-bin
_pkgname=mihomo-party
pkgver=0.1.3
pkgrel=1
pkgdesc="Another Mihomo GUI."
arch=('x86_64' 'aarch64')
url="https://github.com/mihomo-party-org/mihomo-party"
license=('GPL3')
conflicts=("$_pkgname" "$_pkgname-git" "$_pkgname-electron" "$_pkgname-electron-bin")
conflicts=("mihomo-party-git" 'mihomo-party')
depends=('gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs' 'libsecret')
optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).')
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')
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
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"
chown -R root:root ${pkgdir}
}
================================================
FILE: aur/mihomo-party-bin/mihomo-party.install
================================================
# Colored makepkg-like functions
note() {
printf "${_blue}==>${_yellow} NOTE:${_bold} %s${_all_off}\n" "$1"
}
_all_off="$(tput sgr0)"
_bold="${_all_off}$(tput bold)"
_blue="${_bold}$(tput setaf 4)"
_yellow="${_bold}$(tput setaf 3)"
post_install() {
note "Custom flags should be put directly in: ~/.config/mihomo-party-flags.conf"
note "The launcher is called: 'mihomo-party'"
}
================================================
FILE: aur/mihomo-party-bin/mihomo-party.sh
================================================
#!/usr/bin/bash
XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-~/.config}
# Allow users to override command-line options
if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
mapfile -t MIHOMO_PARTY_USER_FLAGS <<<"$(grep -v '^#' "${XDG_CONFIG_HOME}/mihomo-party-flags.conf")"
echo "User flags:" ${MIHOMO_PARTY_USER_FLAGS[@]}
fi
# Launch
exec /opt/clash-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
================================================
FILE: aur/mihomo-party-electron/PKGBUILD
================================================
pkgname=mihomo-party-electron
_pkgname=mihomo-party
pkgver=0.1.3
pkgrel=1
pkgdesc="Another Mihomo GUI."
arch=('x86_64' 'aarch64')
url="https://github.com/mihomo-party-org/mihomo-party"
license=('GPL3')
conflicts=("$_pkgname" "$_pkgname-git" "$_pkgname-bin" "$_pkgname-electron-bin")
depends=('electron' 'gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs' 'libsecret')
optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).')
makedepends=('nodejs' 'pnpm' 'libxcrypt-compat' 'asar')
install=$_pkgname.install
source=(
"${_pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz"
"${_pkgname}.desktop"
"${_pkgname}.sh"
)
sha256sums=("52d761e9432e17477acb8adb5744676df946476e0eb5210fee2b6d45f497f218"
"96a6250f67517493f839f964c024434dbcf784b25a73f074bb505f1521f52844"
"87fddbcd4a4cc7bda22ec4cadff0040e54395bb13184ee4688b58788c1fa7180"
)
options=('!lto')
prepare(){
cd $srcdir/clash-party-${pkgver}
sed -i "s/productName: Clash Party/productName: clash-party/" electron-builder.yml
pnpm install
}
build(){
cd $srcdir/${_pkgname}-${pkgver}
pnpm build:linux deb
}
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
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"
chown -R root:root ${pkgdir}
}
================================================
FILE: aur/mihomo-party-electron/mihomo-party.desktop
================================================
[Desktop Entry]
Name=Clash 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
Categories=Utility;
================================================
FILE: aur/mihomo-party-electron/mihomo-party.install
================================================
# Colored makepkg-like functions
note() {
printf "${_blue}==>${_yellow} NOTE:${_bold} %s${_all_off}\n" "$1"
}
_all_off="$(tput sgr0)"
_bold="${_all_off}$(tput bold)"
_blue="${_bold}$(tput setaf 4)"
_yellow="${_bold}$(tput setaf 3)"
post_install() {
note "Custom flags should be put directly in: ~/.config/mihomo-party-flags.conf"
note "The launcher is called: 'mihomo-party'"
}
================================================
FILE: aur/mihomo-party-electron/mihomo-party.sh
================================================
#!/usr/bin/bash
XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-~/.config}
# Allow users to override command-line options
if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
mapfile -t MIHOMO_PARTY_USER_FLAGS <<<"$(grep -v '^#' "${XDG_CONFIG_HOME}/mihomo-party-flags.conf")"
echo "User flags:" ${MIHOMO_PARTY_USER_FLAGS[@]}
fi
# Launch
exec electron /opt/clash-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
================================================
FILE: aur/mihomo-party-electron-bin/PKGBUILD
================================================
pkgname=mihomo-party-electron-bin
_pkgname=mihomo-party
pkgver=0.2.2
pkgrel=1
pkgdesc="Another Mihomo GUI."
arch=('x86_64' 'aarch64')
url="https://github.com/mihomo-party-org/mihomo-party"
license=('GPL3')
conflicts=("$_pkgname" "$_pkgname-git" "$_pkgname-bin" "$_pkgname-electron")
depends=('electron' 'gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs' 'libsecret')
optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).')
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")
sha256sums=(
"96a6250f67517493f839f964c024434dbcf784b25a73f074bb505f1521f52844"
"87fddbcd4a4cc7bda22ec4cadff0040e54395bb13184ee4688b58788c1fa7180"
)
sha256sums_x86_64=("43f8b9a5818a722cdb8e5044d2a90993274860b0da96961e1a2652169539ce39")
sha256sums_aarch64=("18574fdeb01877a629aa52ac0175335ce27c83103db4fcb2f1ad69e3e42ee10f")
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
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"
chown -R root:root ${pkgdir}
}
================================================
FILE: aur/mihomo-party-electron-bin/mihomo-party.desktop
================================================
[Desktop Entry]
Name=Clash 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
Categories=Utility;
================================================
FILE: aur/mihomo-party-electron-bin/mihomo-party.install
================================================
# Colored makepkg-like functions
note() {
printf "${_blue}==>${_yellow} NOTE:${_bold} %s${_all_off}\n" "$1"
}
_all_off="$(tput sgr0)"
_bold="${_all_off}$(tput bold)"
_blue="${_bold}$(tput setaf 4)"
_yellow="${_bold}$(tput setaf 3)"
post_install() {
note "Custom flags should be put directly in: ~/.config/mihomo-party-flags.conf"
note "The launcher is called: 'mihomo-party'"
}
================================================
FILE: aur/mihomo-party-electron-bin/mihomo-party.sh
================================================
#!/usr/bin/bash
XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-~/.config}
# Allow users to override command-line options
if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
mapfile -t MIHOMO_PARTY_USER_FLAGS <<<"$(grep -v '^#' "${XDG_CONFIG_HOME}/mihomo-party-flags.conf")"
echo "User flags:" ${MIHOMO_PARTY_USER_FLAGS[@]}
fi
# Launch
exec electron /opt/clash-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
================================================
FILE: aur/mihomo-party-git/PKGBUILD
================================================
pkgname=mihomo-party-git
_pkgname=${pkgname%-git}
pkgver=0.1.3.r5.g5f5d6dd
pkgrel=1
pkgdesc="Another Mihomo GUI."
arch=('x86_64' 'aarch64')
url="https://github.com/mihomo-party-org/mihomo-party"
license=('GPL3')
conflicts=("$_pkgname" "$_pkgname-bin" "$_pkgname-electron" "$_pkgname-electron-bin")
depends=('gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs' 'libsecret')
optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).')
makedepends=('nodejs' 'pnpm' 'jq' 'libxcrypt-compat')
install=$_pkgname.install
source=("${_pkgname}.sh" "git+$url.git")
sha256sums=("242609b1259e999e944b7187f4c03aacba8134a7671ff3a50e2e246b4c4eff48" "SKIP")
options=('!lto')
pkgver() {
cd $srcdir/${_pkgname}
( set -o pipefail
git describe --long 2>/dev/null | sed 's/\([^-]*-g\)/r\1/;s/-/./g' | tr -d 'v' ||
printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
)
}
prepare(){
cd $srcdir/${_pkgname}
sed -i "s/productName: Clash Party/productName: clash-party/" electron-builder.yml
pnpm install
}
build(){
cd $srcdir/${_pkgname}
pnpm build:linux deb
}
package() {
cd $srcdir/${_pkgname}/dist
bsdtar -xf clash-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
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"
chown -R root:root ${pkgdir}
}
================================================
FILE: aur/mihomo-party-git/mihomo-party.install
================================================
# Colored makepkg-like functions
note() {
printf "${_blue}==>${_yellow} NOTE:${_bold} %s${_all_off}\n" "$1"
}
_all_off="$(tput sgr0)"
_bold="${_all_off}$(tput bold)"
_blue="${_bold}$(tput setaf 4)"
_yellow="${_bold}$(tput setaf 3)"
post_install() {
note "Custom flags should be put directly in: ~/.config/mihomo-party-flags.conf"
note "The launcher is called: 'mihomo-party'"
}
================================================
FILE: aur/mihomo-party-git/mihomo-party.sh
================================================
#!/usr/bin/bash
XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-~/.config}
# Allow users to override command-line options
if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
mapfile -t MIHOMO_PARTY_USER_FLAGS <<<"$(grep -v '^#' "${XDG_CONFIG_HOME}/mihomo-party-flags.conf")"
echo "User flags:" ${MIHOMO_PARTY_USER_FLAGS[@]}
fi
# Launch
exec /opt/clash-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
================================================
FILE: build/entitlements.mac.plist
================================================
com.apple.security.cs.allow-jit
com.apple.security.cs.allow-unsigned-executable-memory
com.apple.security.cs.allow-dyld-environment-variables
com.apple.security.cs.disable-library-validation
================================================
FILE: build/linux/postinst
================================================
#!/bin/bash
set -e
if type update-alternatives >/dev/null 2>&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'
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'
else
ln -sf '/opt/clash-party/mihomo-party' '/usr/bin/clash-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
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 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
================================================
FILE: build/linux/postuninst
================================================
#!/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
================================================
FILE: build/pkg-scripts/postinstall
================================================
#!/bin/bash
set -e
# 设置日志文件
LOG_FILE="/tmp/mihomo-party-install.log"
exec > "$LOG_FILE" 2>&1
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
}
# 检查 root 权限
if [ "$EUID" -ne 0 ]; then
log "Error: Please run as root"
exit 1
fi
# 判断 $2 是否以 .app 结尾
if [[ $2 == *".app" ]]; then
APP_PATH="$2"
else
APP_PATH="$2/Clash Party.app"
fi
HELPER_PATH="/Library/PrivilegedHelperTools/party.mihomo.helper"
LAUNCH_DAEMON="/Library/LaunchDaemons/party.mihomo.helper.plist"
log "Starting installation..."
# 创建目录并设置权限
log "Creating directories and setting permissions..."
mkdir -p "/Library/PrivilegedHelperTools"
chmod 755 "/Library/PrivilegedHelperTools"
chown root:wheel "/Library/PrivilegedHelperTools"
# 设置核心文件权限
log "Setting core file permissions..."
if [ -f "$APP_PATH/Contents/Resources/sidecar/mihomo" ]; then
chown root:admin "$APP_PATH/Contents/Resources/sidecar/mihomo"
chmod +s "$APP_PATH/Contents/Resources/sidecar/mihomo"
log "Set permissions for mihomo"
else
log "Warning: mihomo binary not found at $APP_PATH/Contents/Resources/sidecar/mihomo"
fi
if [ -f "$APP_PATH/Contents/Resources/sidecar/mihomo-alpha" ]; then
chown root:admin "$APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
chmod +s "$APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
log "Set permissions for mihomo-alpha"
else
log "Warning: mihomo-alpha binary not found at $APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
fi
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
cp -f "$APP_PATH/Contents/Resources/files/party.mihomo.helper" "$HELPER_PATH"
chown root:wheel "$HELPER_PATH"
chmod 544 "$HELPER_PATH"
log "Helper tool installed successfully"
else
log "Error: Helper file not found at $APP_PATH/Contents/Resources/files/party.mihomo.helper"
exit 1
fi
# 创建并配置 LaunchDaemon
log "Configuring LaunchDaemon..."
mkdir -p "/Library/LaunchDaemons"
cat << EOF > "$LAUNCH_DAEMON"
Label
party.mihomo.helper
AssociatedBundleIdentifiers
party.mihomo.app
KeepAlive
Program
${HELPER_PATH}
StandardErrorPath
/tmp/party.mihomo.helper.err
StandardOutPath
/tmp/party.mihomo.helper.log
EOF
chown root:wheel "$LAUNCH_DAEMON"
chmod 644 "$LAUNCH_DAEMON"
log "LaunchDaemon configured"
# 验证关键文件
log "Verifying installation..."
if [ ! -x "$HELPER_PATH" ]; then
log "Error: Helper tool is not executable: $HELPER_PATH"
exit 1
fi
# 检查二进制文件有效性
if ! file "$HELPER_PATH" | grep -q "Mach-O"; then
log "Error: Helper tool is not a valid Mach-O binary"
exit 1
fi
# 验证 plist 格式
if ! plutil -lint "$LAUNCH_DAEMON" >/dev/null 2>&1; then
log "Error: Invalid plist format"
exit 1
fi
# 获取 macOS 版本
macos_version=$(sw_vers -productVersion)
macos_major=$(echo "$macos_version" | cut -d. -f1)
log "macOS version: $macos_version"
# 启用服务(防止安全软件禁用)
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
launchctl unload "$LAUNCH_DAEMON" 2>/dev/null || true
# 加载服务
log "Loading service..."
if [ "$macos_major" -ge 11 ]; then
# macOS Big Sur 及更新版本使用 bootstrap
if ! launchctl bootstrap system "$LAUNCH_DAEMON"; then
log "Bootstrap failed, trying legacy load..."
if ! launchctl load "$LAUNCH_DAEMON"; then
log "Error: Failed to load service with both methods"
exit 1
fi
fi
else
# 旧版本使用 load
if ! launchctl load "$LAUNCH_DAEMON"; then
log "Error: Failed to load service"
exit 1
fi
fi
# 验证服务状态
log "Verifying service status..."
sleep 2
if launchctl list | grep -q "party.mihomo.helper"; then
log "Service loaded successfully"
else
log "Warning: Service may not be running properly"
fi
log "Installation completed successfully"
# Fix user data directory permissions
log "Fixing user data directory permissions..."
for user_home in /Users/*; do
if [ -d "$user_home" ] && [ "$(basename "$user_home")" != "Shared" ] && [ "$(basename "$user_home")" != ".localized" ]; then
username=$(basename "$user_home")
user_data_dir="$user_home/Library/Application Support/mihomo-party"
if [ -d "$user_data_dir" ]; then
current_owner=$(stat -f "%Su" "$user_data_dir" 2>/dev/null || echo "unknown")
if [ "$current_owner" = "root" ]; then
log "Fixing ownership for user: $username"
chown -R "$username:staff" "$user_data_dir" 2>/dev/null || true
chmod -R u+rwX "$user_data_dir" 2>/dev/null || true
fi
fi
fi
done
exit 0
================================================
FILE: build/pkg-scripts/preinstall
================================================
#!/bin/bash
set -e
# 检查 root 权限
if [ "$EUID" -ne 0 ]; then
echo "Please run as root"
exit 1
fi
HELPER_PATH="/Library/PrivilegedHelperTools/party.mihomo.helper"
LAUNCH_DAEMON="/Library/LaunchDaemons/party.mihomo.helper.plist"
# 停止并卸载现有的 LaunchDaemon
if [ -f "$LAUNCH_DAEMON" ]; then
launchctl unload "$LAUNCH_DAEMON" 2>/dev/null || true
rm -f "$LAUNCH_DAEMON"
fi
# 移除 helper 工具
rm -f "$HELPER_PATH"
# 清理可能存在的旧版本文件
rm -rf "/Applications/Clash Party.app"
rm -rf "/Applications/Clash\\ Party.app"
rm -rf "/Applications/Mihomo Party.app"
rm -rf "/Applications/Mihomo\\ Party.app"
exit 0
================================================
FILE: changelog.md
================================================
# 1.9.2
## 新功能 (Feat)
- 增加 Fish 和 Nushell 环境变量支持
- 增加规则统计和禁用开关
- 连接页面显示应用图标
- 增加订阅超时设置
- 增加 Windows 7 兼容构建支持
## 修复 (Fix)
- 修复 group.all 过滤器中代理未定义导致的错误
- 修复默认延迟测试 URL 改为 HTTPS
- 修复连接图表 z-index 和缩放问题
- 修复减少动画模式下侧边栏卡片拖拽动画异常
- 修复 IME 输入法输入时字符重复问题
- 修复配置切换失败后切换队列损坏问题
- 修复取消最大化状态未保存的问题
- 优化备份配置导入处理
- 修复禁用自动滚动时日志显示未冻结的问题
- 修复订阅页面相对时间未自动刷新的问题
- 修复连接数badge遮挡下方关闭按钮点击的问题
## 优化 (Optimize)
- 虚拟化规则编辑弹窗中的规则列表,提升渲染性能
- 优化内核启动性能
- 重构外部控制器和规则编辑器
- 优化订阅更新逻辑效率
## 其他 (Chore)
- 更新依赖
# 1.9.1
## 修复 (Fix)
- 修复 Windows 下以管理员重启开启 TUN 时因单实例锁冲突导致的闪退问题
- 修复托盘菜单开启 TUN 时管理员重启后继续执行导致的竞态问题
- 修复关键资源文件复制失败时静默跳过导致内核启动异常的问题
# 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() 弹窗
- 优化连接页面性能
================================================
FILE: electron-builder.yml
================================================
appId: party.mihomo.app
productName: Clash Party
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!aur/*'
- '!images/*'
- '!scripts/*'
- '!extra/*'
- '!tailwind.config.js'
- '!postcss.config.js'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
extraResources:
- from: './extra/'
to: ''
protocols:
name: 'Clash Party URI Scheme'
schemes:
- 'clash'
- 'mihomo'
win:
target:
- nsis
- 7z
artifactName: clash-party-windows-${version}-${arch}-portable.${ext}
nsis:
artifactName: clash-party-windows-${version}-${arch}-setup.${ext}
uninstallDisplayName: ${productName}
allowToChangeInstallationDirectory: true
oneClick: false
perMachine: true
createDesktopShortcut: always
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}
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
target:
- deb
- rpm
maintainer: mihomo-party-org
category: Utility
artifactName: clash-party-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: []
================================================
FILE: electron.vite.config.ts
================================================
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 = (
module: unknown
): module is { default: typeof monacoEditorPluginModule } =>
module != null &&
typeof module === 'object' &&
'default' in module &&
typeof module.default === 'function'
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
},
preload: {
plugins: isLegacyBuild ? [] : [externalizeDepsPlugin()],
build: {
rollupOptions: {
external: isLegacyBuild ? legacyExternal : undefined,
output: {
format: 'cjs',
entryFileNames: '[name].cjs'
}
}
}
},
renderer: {
build: {
rollupOptions: {
input: {
index: resolve('src/renderer/index.html'),
floating: resolve('src/renderer/floating.html')
}
}
},
resolve: {
alias: {
'@renderer': resolve('src/renderer/src')
}
},
plugins: [
react(),
tailwindcss(),
monacoEditorPlugin({
languageWorkers: ['editorWorkerService', 'typescript', 'css'],
customDistPath: (_, out) => `${out}/monacoeditorwork`,
customWorkers: [
{
label: 'yaml',
entry: 'monaco-yaml/yaml.worker'
}
]
})
]
}
})
================================================
FILE: eslint.config.cjs
================================================
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'
}
}
]
================================================
FILE: package.json
================================================
{
"name": "mihomo-party",
"version": "1.9.2",
"description": "Clash Party",
"type": "module",
"main": "./out/main/index.js",
"author": "mihomo-party-org",
"homepage": "https://clashparty.org",
"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",
"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",
"artifact": "node scripts/artifact.mjs",
"dev": "electron-vite dev",
"postinstall": "electron-builder install-app-deps && node node_modules/electron/install.js",
"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"
},
"dependencies": {
"@electron-toolkit/utils": "^4.0.0",
"@types/plist": "^3.0.5",
"adm-zip": "^0.5.16",
"axios": "^1.13.5",
"chokidar": "^5.0.0",
"croner": "^9.1.0",
"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"
},
"devDependencies": {
"@dnd-kit/core": "^6.3.1",
"@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",
"@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",
"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",
"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",
"types-pac": "^1.0.3",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-monaco-editor": "^1.1.0"
},
"packageManager": "pnpm@10.27.0"
}
================================================
FILE: scripts/checksum.mjs
================================================
import { readFileSync, readdirSync, writeFileSync } from 'fs'
import { createHash } from 'crypto'
const files = readdirSync('dist')
for (const file of files) {
for (const ext of process.argv.slice(2)) {
if (file.endsWith(ext)) {
const content = readFileSync(`dist/${file}`)
const checksum = createHash('sha256').update(content, 'utf8').digest('hex')
writeFileSync(`dist/${file}.sha256`, checksum)
}
}
}
================================================
FILE: scripts/cleanup-mac.sh
================================================
#!/bin/bash
echo "=== Clash Party Cleanup Tool ==="
echo "This script will remove all Clash Party related files and services."
read -p "Are you sure you want to continue? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 1
fi
# Stop and unload services
echo "Stopping services..."
sudo launchctl unload /Library/LaunchDaemons/party.mihomo.helper.plist 2>/dev/null || true
# Remove files
echo "Removing files..."
sudo rm -f /Library/LaunchDaemons/party.mihomo.helper.plist
sudo rm -f /Library/PrivilegedHelperTools/party.mihomo.helper
sudo rm -rf "/Applications/Clash Party.app"
sudo rm -rf "/Applications/Clash\\ Party.app"
sudo rm -rf ~/Library/Application\ Support/mihomo-party
sudo rm -rf ~/Library/Caches/mihomo-party
sudo rm -f ~/Library/Preferences/party.mihomo.app.helper.plist
sudo rm -f ~/Library/Preferences/party.mihomo.app.plist
echo "Cleanup complete. Please restart your computer to complete the process."
================================================
FILE: scripts/copy-legacy-artifacts.mjs
================================================
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 文件')
}
================================================
FILE: scripts/prepare.mjs
================================================
import fs from 'fs'
import AdmZip from 'adm-zip'
import path from 'path'
import zlib from 'zlib'
import { extract } from 'tar'
import { execSync } from 'child_process'
const cwd = process.cwd()
const TEMP_DIR = path.join(cwd, 'node_modules/.temp')
let arch = process.arch
const platform = process.platform
if (process.argv.slice(2).length !== 0) {
arch = process.argv.slice(2)[0].replace('--', '')
}
/* ======= mihomo alpha======= */
const MIHOMO_ALPHA_VERSION_URL =
'https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha/version.txt'
const MIHOMO_ALPHA_URL_PREFIX = `https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha`
let MIHOMO_ALPHA_VERSION
const MIHOMO_ALPHA_MAP = {
'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'
}
// Fetch the latest alpha release version from the version.txt file
async function getLatestAlphaVersion() {
try {
const response = await fetch(MIHOMO_ALPHA_VERSION_URL, {
method: 'GET'
})
let v = await response.text()
MIHOMO_ALPHA_VERSION = v.trim() // Trim to remove extra whitespaces
console.log(`Latest alpha version: ${MIHOMO_ALPHA_VERSION}`)
} catch (error) {
console.error('Error fetching latest alpha version:', error.message)
process.exit(1)
}
}
/* ======= 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'
const MIHOMO_URL_PREFIX = `https://github.com/MetaCubeX/mihomo/releases/download`
let MIHOMO_VERSION
const MIHOMO_MAP = {
'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'
}
// Fetch the latest release version from the version.txt file
async function getLatestReleaseVersion() {
try {
const response = await fetch(MIHOMO_VERSION_URL, {
method: 'GET'
})
let v = await response.text()
MIHOMO_VERSION = v.trim() // Trim to remove extra whitespaces
console.log(`Latest release version: ${MIHOMO_VERSION}`)
} catch (error) {
console.error('Error fetching latest release version:', error.message)
process.exit(1)
}
}
/*
* check available
*/
if (!MIHOMO_MAP[`${platform}-${arch}`]) {
throw new Error(`unsupported platform "${platform}-${arch}"`)
}
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
*/
function MihomoAlpha() {
const name = MIHOMO_ALPHA_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz'
const downloadURL = `${MIHOMO_ALPHA_URL_PREFIX}/${name}-${MIHOMO_ALPHA_VERSION}.${urlExt}`
const exeFile = `${name}${isWin ? '.exe' : ''}`
const zipFile = `${name}-${MIHOMO_ALPHA_VERSION}.${urlExt}`
return {
name: 'mihomo-alpha',
targetFile: `mihomo-alpha${isWin ? '.exe' : ''}`,
exeFile,
zipFile,
downloadURL
}
}
function mihomo() {
const name = MIHOMO_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz'
const downloadURL = `${MIHOMO_URL_PREFIX}/${MIHOMO_VERSION}/${name}-${MIHOMO_VERSION}.${urlExt}`
const exeFile = `${name}${isWin ? '.exe' : ''}`
const zipFile = `${name}-${MIHOMO_VERSION}.${urlExt}`
return {
name: 'mihomo',
targetFile: `mihomo${isWin ? '.exe' : ''}`,
exeFile,
zipFile,
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
*/
async function resolveSidecar(binInfo) {
const { name, targetFile, zipFile, exeFile, downloadURL } = binInfo
const sidecarDir = path.join(cwd, 'extra', 'sidecar')
const sidecarPath = path.join(sidecarDir, targetFile)
fs.mkdirSync(sidecarDir, { recursive: true })
if (fs.existsSync(sidecarPath)) {
fs.rmSync(sidecarPath)
}
const tempDir = path.join(TEMP_DIR, name)
const tempZip = path.join(tempDir, zipFile)
const tempExe = path.join(tempDir, exeFile)
fs.mkdirSync(tempDir, { recursive: true })
try {
if (!fs.existsSync(tempZip)) {
await downloadFile(downloadURL, tempZip)
}
if (zipFile.endsWith('.zip')) {
const zip = new AdmZip(tempZip)
zip.getEntries().forEach((entry) => {
console.log(`[DEBUG]: "${name}" entry name`, entry.entryName)
})
zip.extractAllTo(tempDir, true)
fs.renameSync(tempExe, sidecarPath)
console.log(`[INFO]: "${name}" unzip finished`)
} else if (zipFile.endsWith('.tgz')) {
// tgz
fs.mkdirSync(tempDir, { recursive: true })
await extract({
cwd: tempDir,
file: tempZip
})
const files = fs.readdirSync(tempDir)
console.log(`[DEBUG]: "${name}" files in tempDir:`, files)
const extractedFile = files.find((file) => file.startsWith('虚空终端-'))
if (extractedFile) {
const extractedFilePath = path.join(tempDir, extractedFile)
fs.renameSync(extractedFilePath, sidecarPath)
console.log(`[INFO]: "${name}" file renamed to "${sidecarPath}"`)
execSync(`chmod 755 ${sidecarPath}`)
console.log(`[INFO]: "${name}" chmod binary finished`)
} else {
throw new Error(`Expected file not found in ${tempDir}`)
}
} else {
// gz
const readStream = fs.createReadStream(tempZip)
const writeStream = fs.createWriteStream(sidecarPath)
await new Promise((resolve, reject) => {
const onError = (error) => {
console.error(`[ERROR]: "${name}" gz failed:`, error.message)
reject(error)
}
readStream
.pipe(zlib.createGunzip().on('error', onError))
.pipe(writeStream)
.on('finish', () => {
console.log(`[INFO]: "${name}" gunzip finished`)
execSync(`chmod 755 ${sidecarPath}`)
console.log(`[INFO]: "${name}" chmod binary finished`)
resolve()
})
.on('error', onError)
})
}
} catch (err) {
// 需要删除文件
fs.rmSync(sidecarPath)
throw err
} finally {
fs.rmSync(tempDir, { recursive: true })
}
}
/**
* download the file to the extra dir
*/
async function resolveResource(binInfo) {
const { file, downloadURL } = binInfo
const resDir = path.join(cwd, 'extra', 'files')
const targetPath = path.join(resDir, file)
if (fs.existsSync(targetPath)) {
fs.rmSync(targetPath)
}
fs.mkdirSync(resDir, { recursive: true })
await downloadFile(downloadURL, targetPath)
console.log(`[INFO]: ${file} finished`)
}
/**
* download file and save to `path`
*/
async function downloadFile(url, path) {
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/octet-stream' }
})
const buffer = await response.arrayBuffer()
fs.writeFileSync(path, new Uint8Array(buffer))
console.log(`[INFO]: download finished "${url}"`)
}
const resolveMmdb = () =>
resolveResource({
file: 'country.mmdb',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country-lite.mmdb`
})
const resolveMetadb = () =>
resolveResource({
file: 'geoip.metadb',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb`
})
const resolveGeosite = () =>
resolveResource({
file: 'geosite.dat',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat`
})
const resolveGeoIP = () =>
resolveResource({
file: 'geoip.dat',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat`
})
const resolveASN = () =>
resolveResource({
file: 'ASN.mmdb',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb`
})
const resolveEnableLoopback = () =>
resolveResource({
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 resolveMonitor = async () => {
const tempDir = path.join(TEMP_DIR, 'TrafficMonitor')
const tempZip = path.join(tempDir, `${arch}.zip`)
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true })
}
await downloadFile(
`https://github.com/mihomo-party-org/mihomo-party-run/releases/download/monitor/${arch}.zip`,
tempZip
)
const zip = new AdmZip(tempZip)
const resDir = path.join(cwd, 'extra', 'files')
const targetPath = path.join(resDir, 'TrafficMonitor')
if (fs.existsSync(targetPath)) {
fs.rmSync(targetPath, { recursive: true })
}
zip.extractAllTo(targetPath, true)
console.log(`[INFO]: TrafficMonitor finished`)
}
const resolve7zip = () =>
resolveResource({
file: '7za.exe',
downloadURL: `https://github.com/develar/7zip-bin/raw/master/win/${arch}/7za.exe`
})
const resolveSubstore = () =>
resolveResource({
file: 'sub-store.bundle.cjs',
downloadURL:
'https://github.com/sub-store-org/Sub-Store/releases/latest/download/sub-store.bundle.js'
})
const resolveHelper = () =>
resolveResource({
file: 'party.mihomo.helper',
downloadURL: `https://github.com/mihomo-party-org/mihomo-party-helper/releases/download/${arch}/party.mihomo.helper`
})
const resolveSubstoreFrontend = async () => {
const tempDir = path.join(TEMP_DIR, 'substore-frontend')
const tempZip = path.join(tempDir, 'dist.zip')
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true })
}
await downloadFile(
'https://github.com/sub-store-org/Sub-Store-Front-End/releases/latest/download/dist.zip',
tempZip
)
const zip = new AdmZip(tempZip)
const resDir = path.join(cwd, 'extra', 'files')
const targetPath = path.join(resDir, 'sub-store-frontend')
if (fs.existsSync(targetPath)) {
fs.rmSync(targetPath, { recursive: true })
}
zip.extractAllTo(resDir, true)
fs.renameSync(path.join(resDir, 'dist'), targetPath)
console.log(`[INFO]: sub-store-frontend finished`)
}
const resolveFont = async () => {
const targetPath = path.join(cwd, 'src', 'renderer', 'src', 'assets', 'NotoColorEmoji.ttf')
if (fs.existsSync(targetPath)) {
return
}
await downloadFile(
'https://github.com/googlefonts/noto-emoji/raw/main/fonts/NotoColorEmoji.ttf',
targetPath
)
console.log(`[INFO]: NotoColorEmoji.ttf finished`)
}
const tasks = [
{
name: 'mihomo-alpha',
func: () => getLatestAlphaVersion().then(() => resolveSidecar(MihomoAlpha())),
retry: 5
},
{
name: 'mihomo',
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 },
{ name: 'geoip', func: resolveGeoIP, retry: 5 },
{ name: 'asn', func: resolveASN, retry: 5 },
{
name: 'font',
func: resolveFont,
retry: 5
},
{
name: 'enableLoopback',
func: resolveEnableLoopback,
retry: 5,
winOnly: true
},
{
name: 'sysproxy',
func: resolveSysproxy,
retry: 5
},
{
name: 'monitor',
func: resolveMonitor,
retry: 5,
winOnly: true
},
{
name: 'substore',
func: resolveSubstore,
retry: 5
},
{
name: 'substorefrontend',
func: resolveSubstoreFrontend,
retry: 5
},
{
name: '7zip',
func: resolve7zip,
retry: 5,
winOnly: true
},
{
name: 'helper',
func: resolveHelper,
retry: 5,
darwinOnly: true
}
]
async function runTask() {
const task = tasks.shift()
if (!task) return
if (task.winOnly && platform !== 'win32') return runTask()
if (task.linuxOnly && platform !== 'linux') return runTask()
if (task.unixOnly && platform === 'win32') return runTask()
if (task.darwinOnly && platform !== 'darwin') return runTask()
for (let i = 0; i < task.retry; i++) {
try {
await task.func()
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
}
}
}
}
return runTask()
}
runTask()
runTask()
================================================
FILE: scripts/telegram.mjs
================================================
import axios from 'axios'
import { readFileSync } from 'fs'
import {
getProcessedVersion,
isDevBuild,
getDownloadUrl,
generateDownloadLinksMarkdown,
getGitCommitHash
} from './version-utils.mjs'
const chat_id = '@MihomoPartyChannel'
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 `${line.replace('## ', '')} `
} else if (line.startsWith('### ')) {
return `${line.replace('### ', '')} `
} else if (line.startsWith('#### ')) {
return `${line.replace('#### ', '')} `
} else {
let processedLine = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
const encodedUrl = encodeURI(url)
return `${text} `
})
processedLine = processedLine.replace(/\*\*([^*]+)\*\*/g, '$1 ')
return processedLine
}
})
.join('\n')
}
let content = ''
if (isDevRelease) {
// 版本号中提取commit hash
const shortCommitSha = getGitCommitHash(true)
const commitSha = getGitCommitHash(false)
content = `🚧 Clash Party Dev Build 开发版本发布 \n\n`
content += `基于版本: ${version}\n`
content += `提交哈希: ${shortCommitSha} \n\n`
content += `更新日志: \n`
content += convertMarkdownToTelegramHTML(changelog)
content += '\n\n⚠️ 注意:这是开发版本,可能存在不稳定性,仅供测试使用 \n'
} else {
// 正式版本通知
content = `🌟 Clash Party v${version} 正式发布 \n\n`
content += convertMarkdownToTelegramHTML(changelog)
}
// 构建下载链接
const downloadUrl = getDownloadUrl(isDevRelease, version)
const downloadLinksMarkdown = generateDownloadLinksMarkdown(downloadUrl, version)
content += convertMarkdownToTelegramHTML(downloadLinksMarkdown)
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',
prefer_large_media: true
},
parse_mode: 'HTML'
})
console.log(`${isDevRelease ? '开发版本' : '正式版本'}Telegram 通知发送成功`)
================================================
FILE: scripts/update-version.mjs
================================================
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 }
================================================
FILE: scripts/updater.mjs
================================================
import yaml from 'yaml'
import { readFileSync, writeFileSync } from 'fs'
import {
getProcessedVersion,
isDevBuild,
getDownloadUrl,
generateDownloadLinksMarkdown
} from './version-utils.mjs'
let changelog = readFileSync('changelog.md', 'utf-8')
// 获取处理后的版本号
const version = getProcessedVersion()
const isDev = isDevBuild()
const downloadUrl = getDownloadUrl(isDev, version)
const latest = {
version,
changelog
}
// 使用统一的下载链接生成函数
changelog += generateDownloadLinksMarkdown(downloadUrl, version)
changelog +=
'\n\n### 机场推荐:\n- 高性能海外机场,稳定首选:[https://狗狗加速.com](https://party.dginv.click/#/register?code=ARdo0mXx)'
writeFileSync('latest.yml', yaml.stringify(latest))
writeFileSync('changelog.md', changelog)
================================================
FILE: scripts/version-utils.mjs
================================================
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
}
================================================
FILE: src/main/config/app.ts
================================================
import { readFile, writeFile } from 'fs/promises'
import { appConfigPath } from '../utils/dirs'
import { parse, stringify } from '../utils/yaml'
import { deepMerge } from '../utils/merge'
import { defaultConfig } from '../utils/template'
let appConfig: IAppConfig // config.yaml
let appConfigWriteQueue: Promise = Promise.resolve()
export async function getAppConfig(force = false): Promise {
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
}
if (typeof appConfig !== 'object') appConfig = defaultConfig
return appConfig
}
export async function patchAppConfig(patch: Partial): Promise {
appConfigWriteQueue = appConfigWriteQueue.then(async () => {
if (patch.nameserverPolicy) {
appConfig.nameserverPolicy = patch.nameserverPolicy
}
appConfig = deepMerge(appConfig, patch)
await writeFile(appConfigPath(), stringify(appConfig))
})
await appConfigWriteQueue
}
================================================
FILE: src/main/config/controledMihomo.ts
================================================
import { readFile, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { controledMihomoConfigPath } from '../utils/dirs'
import { parse, stringify } from '../utils/yaml'
import { generateProfile } from '../core/factory'
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 // mihomo.yaml
let controledMihomoWriteQueue: Promise = Promise.resolve()
export async function getControledMihomoConfig(force = false): Promise> {
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]
}
}
}
if (typeof controledMihomoConfig !== 'object')
controledMihomoConfig = defaultControledMihomoConfig
return controledMihomoConfig
}
export async function patchControledMihomoConfig(patch: Partial): Promise {
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)
// 从不接管状态恢复
if (controlDns) {
// 确保 DNS 配置包含所有必要的默认字段,特别是新增的 fallback 等
controledMihomoConfig.dns = deepMerge(
defaultControledMihomoConfig.dns || {},
controledMihomoConfig.dns || {}
)
}
if (controlSniff && !controledMihomoConfig.sniffer) {
controledMihomoConfig.sniffer = defaultControledMihomoConfig.sniffer
}
await generateProfile()
await writeFile(controledMihomoConfigPath(), stringify(controledMihomoConfig), 'utf-8')
})
await controledMihomoWriteQueue
}
================================================
FILE: src/main/config/index.ts
================================================
export { getAppConfig, patchAppConfig } from './app'
export { getControledMihomoConfig, patchControledMihomoConfig } from './controledMihomo'
export {
getProfile,
getCurrentProfileItem,
getProfileItem,
getProfileConfig,
getFileStr,
setFileStr,
setProfileConfig,
addProfileItem,
removeProfileItem,
createProfile,
getProfileStr,
setProfileStr,
changeCurrentProfile,
updateProfileItem,
convertMrsRuleset
} from './profile'
export {
getOverrideConfig,
setOverrideConfig,
getOverrideItem,
addOverrideItem,
removeOverrideItem,
createOverride,
getOverride,
setOverride,
updateOverrideItem
} from './override'
export {
createSmartOverride,
removeSmartOverride,
manageSmartOverride,
isSmartOverrideExists
} from './smartOverride'
================================================
FILE: src/main/config/override.ts
================================================
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'
let overrideConfig: IOverrideConfig // override.yaml
let overrideConfigWriteQueue: Promise = Promise.resolve()
export async function getOverrideConfig(force = false): Promise {
if (force || !overrideConfig) {
const data = await readFile(overrideConfigPath(), 'utf-8')
overrideConfig = parse(data) || { items: [] }
}
if (typeof overrideConfig !== 'object') overrideConfig = { items: [] }
if (!Array.isArray(overrideConfig.items)) overrideConfig.items = []
return overrideConfig
}
export async function setOverrideConfig(config: IOverrideConfig): Promise {
overrideConfigWriteQueue = overrideConfigWriteQueue.then(async () => {
overrideConfig = config
await writeFile(overrideConfigPath(), stringify(overrideConfig), 'utf-8')
})
await overrideConfigWriteQueue
}
export async function getOverrideItem(id: string | undefined): Promise {
const { items } = await getOverrideConfig()
return items.find((item) => item.id === id)
}
export async function updateOverrideItem(item: IOverrideItem): Promise {
const config = await getOverrideConfig()
const index = config.items.findIndex((i) => i.id === item.id)
if (index === -1) {
throw new Error('Override not found')
}
config.items[index] = item
await setOverrideConfig(config)
}
export async function addOverrideItem(item: Partial): Promise {
const config = await getOverrideConfig()
const newItem = await createOverride(item)
if (await getOverrideItem(item.id)) {
await updateOverrideItem(newItem)
} else {
config.items.push(newItem)
await setOverrideConfig(config)
}
}
export async function removeOverrideItem(id: string): Promise {
const config = await getOverrideConfig()
const item = await getOverrideItem(id)
if (!item) return
config.items = config.items?.filter((i) => i.id !== id)
await setOverrideConfig(config)
if (existsSync(overridePath(id, item.ext))) {
await rm(overridePath(id, item.ext))
}
}
export async function createOverride(item: Partial): Promise {
const id = item.id || new Date().getTime().toString(16)
const newItem = {
id,
name: item.name || (item.type === 'remote' ? 'Remote File' : 'Local File'),
type: item.type,
ext: item.ext || 'js',
url: item.url,
global: item.global || false,
updated: new Date().getTime()
} as IOverrideItem
switch (newItem.type) {
case 'remote': {
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
if (!item.url) throw new Error('Empty URL')
const res = await chromeRequest.get(item.url, {
proxy: {
protocol: 'http',
host: '127.0.0.1',
port: mixedPort
},
responseType: 'text'
})
const data = res.data as string
await setOverride(id, newItem.ext, data)
break
}
case 'local': {
const data = item.file || ''
await setOverride(id, newItem.ext, data)
break
}
}
return newItem
}
export async function getOverride(id: string, ext: 'js' | 'yaml' | 'log'): Promise {
if (!existsSync(overridePath(id, ext))) {
return ''
}
return await readFile(overridePath(id, ext), 'utf-8')
}
export async function setOverride(id: string, ext: 'js' | 'yaml', content: string): Promise {
await writeFile(overridePath(id, ext), content, 'utf-8')
}
================================================
FILE: src/main/config/profile.ts
================================================
import { readFile, rm, writeFile } from 'fs/promises'
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 { 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'
const profileLogger = createLogger('Profile')
let profileConfig: IProfileConfig
let profileConfigWriteQueue: Promise = Promise.resolve()
let changeProfileQueue: Promise = Promise.resolve()
export async function getProfileConfig(force = false): Promise {
if (force || !profileConfig) {
const data = await readFile(profileConfigPath(), 'utf-8')
profileConfig = parse(data) || { items: [] }
}
if (typeof profileConfig !== 'object') profileConfig = { items: [] }
if (!Array.isArray(profileConfig.items)) profileConfig.items = []
return structuredClone(profileConfig)
}
export async function setProfileConfig(config: IProfileConfig): Promise {
profileConfigWriteQueue = profileConfigWriteQueue.then(async () => {
profileConfig = config
await writeFile(profileConfigPath(), stringify(config), 'utf-8')
})
await profileConfigWriteQueue
}
export async function updateProfileConfig(
updater: (config: IProfileConfig) => IProfileConfig | Promise
): Promise {
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)
}
export async function getProfileItem(id: string | undefined): Promise {
const { items } = await getProfileConfig()
if (!id || id === 'default')
return { id: 'default', type: 'local', name: i18next.t('profiles.emptyProfile') }
return items.find((item) => item.id === id)
}
export async function changeCurrentProfile(id: string): Promise {
// 使用队列确保 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
}
}
export async function updateProfileItem(item: IProfileItem): Promise {
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
})
}
export async function addProfileItem(item: Partial): Promise {
const newItem = await createProfile(item)
let shouldChangeCurrent = false
let newProfileIsCurrentAfterUpdate = 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
newProfileIsCurrentAfterUpdate = true
}
return config
})
// If the new profile will become the current profile, ensure generateProfile is called
// to prepare working directory before restarting core
if (newProfileIsCurrentAfterUpdate) {
const { diffWorkDir } = await getAppConfig()
if (diffWorkDir) {
try {
const { generateProfile } = await import('../core/factory')
await generateProfile()
} catch (error) {
profileLogger.warn('Failed to generate profile for new subscription', error)
}
}
}
if (shouldChangeCurrent) {
await changeCurrentProfile(newItem.id)
}
await addProfileUpdater(newItem)
}
export async function removeProfileItem(id: string): Promise {
await removeProfileUpdater(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
}
return config
})
if (existsSync(profilePath(id))) {
await rm(profilePath(id))
}
if (shouldRestart) {
await restartCore()
}
if (existsSync(mihomoProfileWorkDir(id))) {
await rm(mihomoProfileWorkDir(id), { recursive: true })
}
}
export async function getCurrentProfileItem(): Promise {
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
}
async function fetchAndValidateSubscription(options: FetchOptions): Promise {
const { url, useProxy, mixedPort, userAgent, authToken, timeout, substore } = options
const headers: Record = {
'User-Agent': userAgent,
'Accept-Encoding': 'identity'
}
if (authToken) headers['Authorization'] = authToken
let res: chromeRequest.Response
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 | 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 }
}
export async function createProfile(item: Partial): Promise {
const id = item.id || new Date().getTime().toString(16)
const newItem: IProfileItem = {
id,
name: item.name || (item.type === 'remote' ? 'Remote File' : 'Local File'),
type: item.type || 'local',
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 = {
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
}
}
}
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
}
export async function getProfileStr(id: string | undefined): Promise {
if (existsSync(profilePath(id || 'default'))) {
return await readFile(profilePath(id || 'default'), 'utf-8')
} else {
return stringify(defaultProfile)
}
}
export async function setProfileStr(id: string, content: string): Promise {
// 读取最新的配置
const { current } = await getProfileConfig(true)
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
}
}
}
}
export async function getProfile(id: string | undefined): Promise {
const profile = await getProfileStr(id)
// 检测是否为 HTML 内容(订阅返回错误页面)
const trimmed = profile.trim()
if (
trimmed.startsWith(']*>/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}`)
}
}
// 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 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 obj = {} as ISubscriptionUserInfo
parts.forEach((part) => {
const [key, value] = part.split('=')
obj[key] = parseInt(value)
})
return obj
}
function isAbsolutePath(path: string): boolean {
return path.startsWith('/') || /^[a-zA-Z]:\\/.test(path)
}
export async function getFileStr(path: string): Promise {
const { diffWorkDir = false } = await getAppConfig()
const { current } = await getProfileConfig()
if (isAbsolutePath(path)) {
return await readFile(path, 'utf-8')
} else {
return await readFile(
join(diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), path),
'utf-8'
)
}
}
export async function setFileStr(path: string, content: string): Promise {
const { diffWorkDir = false } = await getAppConfig()
const { current } = await getProfileConfig()
if (isAbsolutePath(path)) {
await writeFile(path, content, 'utf-8')
} else {
await writeFile(
join(diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), path),
content,
'utf-8'
)
}
}
export async function convertMrsRuleset(filePath: string, behavior: string): Promise {
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
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
}
}
================================================
FILE: src/main/config/smartOverride.ts
================================================
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 {
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 {
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 {
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 {
try {
const override = await getOverrideItem(SMART_OVERRIDE_ID)
return !!override
} catch {
return false
}
}
================================================
FILE: src/main/core/dns.ts
================================================
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 {
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 {
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 {
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 {
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 {
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 {
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)
}
}
================================================
FILE: src/main/core/factory.ts
================================================
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,
getProfile,
getProfileItem,
getOverride,
getOverrideItem,
getOverrideConfig,
getAppConfig
} from '../config'
import {
mihomoProfileWorkDir,
mihomoWorkConfigPath,
mihomoWorkDir,
overridePath,
rulePath
} from '../utils/dirs'
import { parse, stringify } from '../utils/yaml'
import { deepMerge } from '../utils/merge'
import { createLogger } from '../utils/logger'
const factoryLogger = createLogger('Factory')
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 {
// 读取最新的配置
const { current } = await getProfileConfig(true)
const {
diffWorkDir = false,
controlDns = true,
controlSniff = true,
useNameserverPolicy
} = 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 profile = deepMerge(currentProfile, controledMihomoConfig)
// 确保可以拿到基础日志信息
// 使用 debug 可以调试内核相关问题 `debug/pprof`
if (['info', 'debug'].includes(profile['log-level']) === false) {
profile['log-level'] = 'info'
}
runtimeConfig = profile
runtimeConfigStr = stringify(profile)
if (diffWorkDir) {
await prepareProfileWorkDir(current)
}
await writeFile(
diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work'),
runtimeConfigStr
)
return current
}
async function prepareProfileWorkDir(current: string | undefined): Promise {
if (!existsSync(mihomoProfileWorkDir(current))) {
await mkdir(mihomoProfileWorkDir(current), { recursive: true })
}
const isSourceNewer = async (sourcePath: string, targetPath: string): Promise => {
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 => {
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) {
await copyFile(sourcePath, targetPath)
}
}
await Promise.all([
copy('country.mmdb'),
copy('geoip.metadb'),
copy('geoip.dat'),
copy('geosite.dat'),
copy('ASN.mmdb')
])
}
async function overrideProfile(
current: string | undefined,
profile: IMihomoConfig
): Promise {
const { items = [] } = (await getOverrideConfig()) || {}
const globalOverride = items.filter((item) => item.global).map((item) => item.id)
const { override = [] } = (await getProfileItem(current)) || {}
for (const ov of new Set(globalOverride.concat(override))) {
const item = await getOverrideItem(ov)
const content = await getOverride(ov, item?.ext || 'js')
switch (item?.ext) {
case 'js':
profile = runOverrideScript(profile, content, item)
break
case 'yaml': {
let patch = parse(content) || {}
if (typeof patch !== 'object') patch = {}
profile = deepMerge(profile, patch)
break
}
}
}
return profile
}
function runOverrideScript(
profile: IMihomoConfig,
script: string,
item: IOverrideItem
): IMihomoConfig {
const log = (type: string, data: string, flag = 'a'): void => {
writeFileSync(overridePath(item.id, 'log'), `[${type}] ${data}\n`, {
encoding: 'utf-8',
flag
})
}
try {
const ctx = {
console: Object.freeze({
log(data: never) {
log('log', JSON.stringify(data))
},
info(data: never) {
log('info', JSON.stringify(data))
},
error(data: never) {
log('error', JSON.stringify(data))
},
debug(data: never) {
log('debug', JSON.stringify(data))
}
})
}
vm.createContext(ctx)
const code = `${script} main(${JSON.stringify(profile)})`
log('info', '开始执行脚本', 'w')
const newProfile = vm.runInContext(code, ctx)
if (typeof newProfile !== 'object') {
throw new Error('脚本返回值必须是对象')
}
log('info', '脚本执行成功')
return newProfile
} catch (e) {
log('exception', `脚本执行失败:${e}`)
return profile
}
}
export async function getRuntimeConfigStr(): Promise {
return runtimeConfigStr
}
export async function getRuntimeConfig(): Promise {
return runtimeConfig
}
================================================
FILE: src/main/core/manager.ts
================================================
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 {
dataDir,
coreLogPath,
mihomoCoreDir,
mihomoCorePath,
mihomoProfileWorkDir,
mihomoTestDir,
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 {
startMihomoTraffic,
startMihomoConnections,
startMihomoLogs,
startMihomoMemory,
stopMihomoConnections,
stopMihomoTraffic,
stopMihomoLogs,
stopMihomoMemory,
patchMihomoConfig,
getAxios
} from './mihomoApi'
import { generateProfile } from './factory'
import { getSessionAdminStatus } from './permissions'
import {
cleanupSocketFile,
cleanupWindowsNamedPipes,
validateWindowsPipeAccess,
waitForCoreReady
} from './process'
import { setPublicDNS, recoverDNS } from './dns'
// 重新导出权限相关函数
export {
initAdminStatus,
getSessionAdminStatus,
checkAdminPrivileges,
checkMihomoCorePermissions,
checkHighPrivilegeCore,
grantTunPermissions,
restartAsAdmin,
requestTunPermissions,
showTunPermissionDialog,
showErrorDialog,
checkTunPermissions,
manualGrantCorePermition
} from './permissions'
export { getDefaultDevice } from './dns'
const execFilePromise = promisify(execFile)
const ctlParam = process.platform === 'win32' ? '-ext-ctl-pipe' : '-ext-ctl-unix'
// 核心进程状态
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 {
const [appConfig, mihomoConfig] = await Promise.all([getAppConfig(), getControledMihomoConfig()])
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'))
try {
process.kill(pid, 'SIGINT')
} catch {
// ignore
} finally {
await rm(pidPath)
}
}
// 管理 Smart 内核覆写配置
await manageSmartOverride()
// generateProfile 返回实际使用的 current
const current = await generateProfile()
await checkProfile(current, core, diffWorkDir)
if (!skipStop) {
await stopCore()
}
await cleanupSocketFile()
// 设置 DNS
if (tun?.enable && autoSetDNS) {
try {
await setPublicDNS()
} catch (error) {
managerLogger.error('set dns failed', error)
}
}
// 获取动态 IPC 路径
const ipcPath = getMihomoIpcPath()
managerLogger.info(`Using IPC path: ${ipcPath}`)
if (process.platform === 'win32') {
await validateWindowsPipeAccess(ipcPath)
}
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,
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
}
if (retry) {
managerLogger.info('Try Restart Core')
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)
}
}
reject(i18next.t('mihomo.error.externalControllerListenError'))
return
}
// 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
}
await patchMihomoConfig({ 'log-level': logLevel })
innerResolve()
}
})
})
])
await waitForCoreReady()
await getAxios(true)
await startMihomoTraffic()
await startMihomoConnections()
await startMihomoLogs()
await startMihomoMemory()
retry = 10
}
})
}
// 启动核心
export async function startCore(detached = false, skipStop = false): Promise[]> {
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 {
try {
if (!force) {
await recoverDNS()
}
} catch (error) {
managerLogger.error('recover dns failed', error)
}
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 {
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
}
}
// 保持核心运行
export async function keepCoreAlive(): Promise {
try {
await startCore(true)
if (child?.pid) {
await writeFile(path.join(dataDir(), 'core.pid'), child.pid.toString())
}
} catch (e) {
safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`)
}
}
// 退出但保持核心运行
export async function quitWithoutCore(): Promise {
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 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 {
const corePath = mihomoCorePath(core)
try {
await execFilePromise(corePath, [
'-t',
'-f',
diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work'),
'-d',
mihomoTestDir()
])
} 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 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')}`
)
}
} else {
throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}: ${error}`)
}
}
}
// 权限检查入口(从 permissions.ts 调用)
export async function checkAdminRestartForTun(): Promise {
const { checkAdminRestartForTun: check } = await import('./permissions')
await check(restartCore)
}
================================================
FILE: src/main/core/mihomoApi.ts
================================================
import axios, { AxiosInstance } from 'axios'
import WebSocket from 'ws'
import { getAppConfig, getControledMihomoConfig } from '../config'
import { mainWindow } from '../window'
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'
const mihomoApiLogger = createLogger('MihomoApi')
let axiosIns: AxiosInstance | null = null
let currentIpcPath: string = ''
let mihomoTrafficWs: WebSocket | null = null
let trafficRetry = 10
let mihomoMemoryWs: WebSocket | null = null
let memoryRetry = 10
let mihomoLogsWs: WebSocket | null = null
let logsRetry = 10
let mihomoConnectionsWs: WebSocket | null = null
let connectionsRetry = 10
const MAX_RETRY = 10
export const getAxios = async (force: boolean = false): Promise => {
const dynamicIpcPath = getMihomoIpcPath()
if (axiosIns && !force && currentIpcPath === dynamicIpcPath) {
return axiosIns
}
currentIpcPath = dynamicIpcPath
mihomoApiLogger.info(`Creating axios instance with path: ${dynamicIpcPath}`)
axiosIns = axios.create({
baseURL: `http://localhost`,
socketPath: dynamicIpcPath,
timeout: 15000
})
axiosIns.interceptors.response.use(
(response) => {
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)
}
return Promise.reject(error)
}
)
return axiosIns
}
export async function mihomoVersion(): Promise {
const instance = await getAxios()
return await instance.get('/version')
}
export const patchMihomoConfig = async (patch: Partial): Promise => {
const instance = await getAxios()
return await instance.patch('/configs', patch)
}
export const mihomoCloseConnection = async (id: string): Promise => {
const instance = await getAxios()
return await instance.delete(`/connections/${encodeURIComponent(id)}`)
}
export const mihomoCloseAllConnections = async (): Promise => {
const instance = await getAxios()
return await instance.delete('/connections')
}
export const mihomoRules = async (): Promise => {
const instance = await getAxios()
return await instance.get('/rules')
}
export const mihomoRulesDisable = async (rules: Record): Promise => {
const instance = await getAxios()
return await instance.patch('/rules/disable', rules)
}
export const mihomoProxies = async (): Promise => {
const instance = await getAxios()
const proxies = (await instance.get('/proxies')) as IMihomoProxies
if (!proxies.proxies['GLOBAL']) {
throw new Error('GLOBAL proxy not found')
}
return proxies
}
export const mihomoGroups = async (): Promise => {
const { mode = 'rule' } = await getControledMihomoConfig()
if (mode === 'direct') return []
const proxies = await mihomoProxies()
const runtime = await getRuntimeConfig()
const groups: IMihomoMixedGroup[] = []
runtime?.['proxy-groups']?.forEach((group: { name: string; url?: string }) => {
const { name, url } = group
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])
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])
groups.push({ ...newGlobal, all: newAll })
}
}
if (mode === 'global') {
const global = groups.findIndex((group) => group.name === 'GLOBAL')
groups.unshift(groups.splice(global, 1)[0])
}
return groups
}
export const mihomoProxyProviders = async (): Promise => {
const instance = await getAxios()
return await instance.get('/providers/proxies')
}
export const mihomoUpdateProxyProviders = async (name: string): Promise => {
const instance = await getAxios()
return await instance.put(`/providers/proxies/${encodeURIComponent(name)}`)
}
export const mihomoRuleProviders = async (): Promise => {
const instance = await getAxios()
return await instance.get('/providers/rules')
}
export const mihomoUpdateRuleProviders = async (name: string): Promise => {
const instance = await getAxios()
return await instance.put(`/providers/rules/${encodeURIComponent(name)}`)
}
export const mihomoChangeProxy = async (group: string, proxy: string): Promise => {
const instance = await getAxios()
return await instance.put(`/proxies/${encodeURIComponent(group)}`, { name: proxy })
}
export const mihomoUnfixedProxy = async (group: string): Promise => {
const instance = await getAxios()
return await instance.delete(`/proxies/${encodeURIComponent(group)}`)
}
export const mihomoUpgradeGeo = async (): Promise => {
const instance = await getAxios()
return await instance.post('/configs/geo')
}
export const mihomoProxyDelay = async (proxy: string, url?: string): Promise => {
const appConfig = await getAppConfig()
const { delayTestUrl, delayTestTimeout } = appConfig
const instance = await getAxios()
return await instance.get(`/proxies/${encodeURIComponent(proxy)}/delay`, {
params: {
url: url || delayTestUrl || 'https://www.gstatic.com/generate_204',
timeout: delayTestTimeout || 5000
}
})
}
export const mihomoGroupDelay = async (group: string, url?: string): Promise => {
const appConfig = await getAppConfig()
const { delayTestUrl, delayTestTimeout } = appConfig
const instance = await getAxios()
return await instance.get(`/group/${encodeURIComponent(group)}/delay`, {
params: {
url: url || delayTestUrl || 'https://www.gstatic.com/generate_204',
timeout: delayTestTimeout || 5000
}
})
}
export const mihomoUpgrade = async (): Promise => {
const instance = await getAxios()
return await instance.post('/upgrade')
}
export const mihomoUpgradeUI = async (): Promise => {
const instance = await getAxios()
return await instance.post('/upgrade/ui')
}
export const mihomoUpgradeConfig = async (): Promise => {
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> => {
const instance = await getAxios()
return await instance.get(`/group/${encodeURIComponent(groupName)}/weights`)
}
export const mihomoSmartFlushCache = async (configName?: string): Promise => {
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 => {
trafficRetry = MAX_RETRY
await mihomoTraffic()
}
export const stopMihomoTraffic = (): void => {
trafficRetry = 0
if (mihomoTrafficWs) {
mihomoTrafficWs.removeAllListeners()
if (mihomoTrafficWs.readyState === WebSocket.OPEN) {
mihomoTrafficWs.close()
}
mihomoTrafficWs = null
}
}
const mihomoTraffic = async (): Promise => {
const dynamicIpcPath = getMihomoIpcPath()
const wsUrl = `ws+unix:${dynamicIpcPath}:/traffic`
mihomoApiLogger.info(`Creating traffic WebSocket with URL: ${wsUrl}`)
mihomoTrafficWs = new WebSocket(wsUrl)
mihomoTrafficWs.onmessage = async (e): Promise => {
const data = e.data as string
const json = JSON.parse(data) as IMihomoTrafficInfo
trafficRetry = MAX_RETRY
try {
mainWindow?.webContents.send('mihomoTraffic', json)
if (process.platform !== 'linux') {
tray?.setToolTip(
'↑' +
`${calcTraffic(json.up)}/s`.padStart(9) +
'\n↓' +
`${calcTraffic(json.down)}/s`.padStart(9)
)
}
floatingWindow?.webContents.send('mihomoTraffic', json)
} catch {
// ignore
}
}
mihomoTrafficWs.onclose = (): void => {
if (trafficRetry) {
trafficRetry--
setTimeout(mihomoTraffic, 1000)
}
}
mihomoTrafficWs.onerror = (error): void => {
mihomoApiLogger.error('Traffic WebSocket error', error)
if (mihomoTrafficWs) {
mihomoTrafficWs.close()
mihomoTrafficWs = null
}
}
}
export const startMihomoMemory = async (): Promise => {
memoryRetry = MAX_RETRY
await mihomoMemory()
}
export const stopMihomoMemory = (): void => {
memoryRetry = 0
if (mihomoMemoryWs) {
mihomoMemoryWs.removeAllListeners()
if (mihomoMemoryWs.readyState === WebSocket.OPEN) {
mihomoMemoryWs.close()
}
mihomoMemoryWs = null
}
}
const mihomoMemory = async (): Promise => {
const dynamicIpcPath = getMihomoIpcPath()
const wsUrl = `ws+unix:${dynamicIpcPath}:/memory`
mihomoMemoryWs = new WebSocket(wsUrl)
mihomoMemoryWs.onmessage = (e): void => {
const data = e.data as string
memoryRetry = MAX_RETRY
try {
mainWindow?.webContents.send('mihomoMemory', JSON.parse(data) as IMihomoMemoryInfo)
} catch {
// ignore
}
}
mihomoMemoryWs.onclose = (): void => {
if (memoryRetry) {
memoryRetry--
setTimeout(mihomoMemory, 1000)
}
}
mihomoMemoryWs.onerror = (): void => {
if (mihomoMemoryWs) {
mihomoMemoryWs.close()
mihomoMemoryWs = null
}
}
}
export const startMihomoLogs = async (): Promise => {
logsRetry = MAX_RETRY
await mihomoLogs()
}
export const stopMihomoLogs = (): void => {
logsRetry = 0
if (mihomoLogsWs) {
mihomoLogsWs.removeAllListeners()
if (mihomoLogsWs.readyState === WebSocket.OPEN) {
mihomoLogsWs.close()
}
mihomoLogsWs = null
}
}
const mihomoLogs = async (): Promise => {
const { 'log-level': logLevel = 'info' } = await getControledMihomoConfig()
const dynamicIpcPath = getMihomoIpcPath()
const wsUrl = `ws+unix:${dynamicIpcPath}:/logs?level=${logLevel}`
mihomoLogsWs = new WebSocket(wsUrl)
mihomoLogsWs.onmessage = (e): void => {
const data = e.data as string
logsRetry = MAX_RETRY
try {
mainWindow?.webContents.send('mihomoLogs', JSON.parse(data) as IMihomoLogInfo)
} catch {
// ignore
}
}
mihomoLogsWs.onclose = (): void => {
if (logsRetry) {
logsRetry--
setTimeout(mihomoLogs, 1000)
}
}
mihomoLogsWs.onerror = (): void => {
if (mihomoLogsWs) {
mihomoLogsWs.close()
mihomoLogsWs = null
}
}
}
export const startMihomoConnections = async (): Promise => {
connectionsRetry = MAX_RETRY
await mihomoConnections()
}
export const stopMihomoConnections = (): void => {
connectionsRetry = 0
if (mihomoConnectionsWs) {
mihomoConnectionsWs.removeAllListeners()
if (mihomoConnectionsWs.readyState === WebSocket.OPEN) {
mihomoConnectionsWs.close()
}
mihomoConnectionsWs = null
}
}
const mihomoConnections = async (): Promise => {
const dynamicIpcPath = getMihomoIpcPath()
const wsUrl = `ws+unix:${dynamicIpcPath}:/connections`
mihomoConnectionsWs = new WebSocket(wsUrl)
mihomoConnectionsWs.onmessage = (e): void => {
const data = e.data as string
connectionsRetry = MAX_RETRY
try {
mainWindow?.webContents.send('mihomoConnections', JSON.parse(data) as IMihomoConnectionsInfo)
} catch {
// ignore
}
}
mihomoConnectionsWs.onclose = (): void => {
if (connectionsRetry) {
connectionsRetry--
setTimeout(mihomoConnections, 1000)
}
}
mihomoConnectionsWs.onerror = (): void => {
if (mihomoConnectionsWs) {
mihomoConnectionsWs.close()
mihomoConnectionsWs = null
}
}
}
export async function SysProxyStatus(): Promise {
const appConfig = await getAppConfig()
return appConfig.sysProxy.enable
}
export const TunStatus = async (): Promise => {
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)
}
================================================
FILE: src/main/core/permissions.ts
================================================
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
if (process.platform === 'win32') {
await restartAsAdmin()
} else {
const hasPermissions = await checkMihomoCorePermissions()
if (!hasPermissions) {
await grantTunPermissions()
}
}
}
export async function showTunPermissionDialog(): Promise {
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 {
const okText = i18next.t('common.confirm') || '确认'
dialog.showMessageBoxSync({
type: 'error',
title,
message,
buttons: [okText],
defaultId: 0
})
}
export async function validateTunPermissionsOnStartup(
_restartCore: () => Promise
): Promise {
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): Promise {
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 {
return checkMihomoCorePermissions()
}
export function manualGrantCorePermition(): Promise {
return grantTunPermissions()
}
================================================
FILE: src/main/core/process.ts
================================================
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 {
if (process.platform === 'win32') {
await cleanupWindowsNamedPipes()
} else {
await cleanupUnixSockets()
}
}
export async function cleanupWindowsNamedPipes(): Promise {
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 {
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 {
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 {
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 {
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 {
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))
}
}
}
================================================
FILE: src/main/core/profileUpdater.ts
================================================
import { Cron } from 'croner'
import { addProfileItem, getCurrentProfileItem, getProfileConfig, getProfileItem } from '../config'
import { logger } from '../utils/logger'
const intervalPool: Record = {}
const delayedUpdatePool: Record = {}
async function updateProfile(id: string): Promise {
const item = await getProfileItem(id)
if (item && item.type === 'remote') {
await addProfileItem(item)
}
}
export async function initProfileUpdater(): Promise {
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 () => {
try {
await updateProfile(itemId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update profile ${itemId}:`, e)
}
})
}
try {
await addProfileItem(item)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to init profile ${item.name}:`, e)
}
}
}
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 () => {
try {
await updateProfile(currentId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update current profile:`, e)
}
})
}
try {
await addProfileItem(currentItem)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to init current profile:`, e)
}
}
}
export async function addProfileUpdater(item: IProfileItem): Promise {
if (item.type === 'remote' && item.autoUpdate && 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)
}
}
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 () => {
try {
await updateProfile(itemId)
} catch (e) {
await logger.warn(`[ProfileUpdater] Failed to update profile ${itemId}:`, e)
}
})
}
}
}
export async function removeProfileUpdater(id: string): Promise {
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]
}
}
================================================
FILE: src/main/core/subStoreApi.ts
================================================
import * as chromeRequest from '../utils/chromeRequest'
import { subStorePort } from '../resolve/server'
import { getAppConfig } from '../config'
export async function subStoreSubs(): Promise {
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
}
export async function subStoreCollections(): Promise {
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
}
================================================
FILE: src/main/deeplink.ts
================================================
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 {
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
}
}
}
================================================
FILE: src/main/index.ts
================================================
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, dialog } from 'electron'
import i18next from 'i18next'
import { initI18n } from '../shared/i18n'
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 { initShortcut } from './resolve/shortcut'
import { initProfileUpdater } from './core/profileUpdater'
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'
const mainLogger = createLogger('Main')
export { mainWindow, showMainWindow, triggerMainWindow, closeMainWindow }
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
}
async function initApp(): Promise {
await fixUserDataPermissions()
}
initApp().catch((e) => {
safeShowErrorBox('common.error.initFailed', `${e}`)
app.quit()
})
setupPlatformSpecifics()
async function checkHighPrivilegeCoreEarly(): Promise {
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' })
}
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)
}
} catch (e) {
mainLogger.error('Failed to check high privilege core', e)
}
}
async function initHardwareAcceleration(): Promise {
try {
await initBasic()
const { disableHardwareAcceleration = false } = await getAppConfig()
if (disableHardwareAcceleration) {
app.disableHardwareAcceleration()
}
} catch (e) {
mainLogger.warn('Failed to read hardware acceleration config', e)
}
}
initHardwareAcceleration()
setupAppLifecycle()
app.on('second-instance', async (_event, commandline) => {
showMainWindow()
const url = commandline.pop()
if (url) {
await handleDeepLink(url)
}
})
app.on('open-url', async (_event, url) => {
showMainWindow()
await handleDeepLink(url)
})
const initPromise = (async () => {
await initBasic()
await checkHighPrivilegeCoreEarly()
await initAdminStatus()
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
} catch (e) {
safeShowErrorBox('common.error.initFailed', `${e}`)
app.quit()
throw e
}
})()
app.whenReady().then(async () => {
electronApp.setAppUserModelId('party.mihomo.app')
const appConfig = await initPromise
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
registerIpcMainHandlers()
const createWindowPromise = createWindow()
let coreStarted = false
const coreStartPromise = (async (): Promise => {
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 => {
try {
await startMonitor()
} catch {
// ignore
}
})()
await createWindowPromise
const { showFloatingWindow: showFloating = false, disableTray = false } = appConfig
const uiTasks: Promise[] = [initShortcut()]
if (showFloating) {
uiTasks.push(
(async () => {
try {
await showFloatingWindow()
} catch (error) {
await logger.error('Failed to create floating window on startup', error)
}
})()
)
}
if (!disableTray) {
uiTasks.push(createTray())
}
await Promise.all(uiTasks)
await Promise.all([coreStartPromise, monitorPromise])
if (coreStarted) {
mainWindow?.webContents.send('core-started')
}
app.on('activate', () => {
showMainWindow()
})
})
================================================
FILE: src/main/lifecycle.ts
================================================
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 {
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'
}
================================================
FILE: src/main/resolve/autoUpdater.ts
================================================
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 { createHash } from 'crypto'
import { app, shell } from 'electron'
import i18next from 'i18next'
import { mainWindow } from '../window'
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 {
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
const res = await chromeRequest.get(
'https://github.com/mihomo-party-org/mihomo-party/releases/latest/download/latest.yml',
{
headers: { 'Content-Type': 'application/octet-stream' },
proxy: {
protocol: 'http',
host: '127.0.0.1',
port: mixedPort
},
responseType: 'text'
}
)
const latest = parse(res.data as string) as IAppVersion
const currentVersion = app.getVersion()
if (compareVersions(latest.version, currentVersion) > 0) {
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 {
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`
}
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'))
}
if (process.platform === 'win32' && parseInt(os.release()) < 10) {
file = file.replace('windows', 'win7')
}
if (process.platform === 'darwin') {
const productVersion = execSync('sw_vers -productVersion', { encoding: 'utf8' })
.toString()
.trim()
if (parseInt(productVersion) < 11) {
file = file.replace('macos', 'catalina')
}
}
try {
if (!existsSync(path.join(dataDir(), file))) {
const sha256Res = await chromeRequest.get(`${baseUrl}${file}.sha256`, {
proxy: {
protocol: 'http',
host: '127.0.0.1',
port: mixedPort
},
responseType: 'text'
})
const expectedHash = (sha256Res.data as string).trim().split(/\s+/)[0]
const res = await chromeRequest.get(`${baseUrl}${file}`, {
responseType: 'arraybuffer',
timeout: 0,
proxy: {
protocol: 'http',
host: '127.0.0.1',
port: mixedPort
},
headers: {
'Content-Type': 'application/octet-stream'
},
onProgress: (loaded, total) => {
mainWindow?.webContents.send('updateDownloadProgress', {
status: 'downloading',
percent: Math.round((loaded / total) * 100)
})
}
})
mainWindow?.webContents.send('updateDownloadProgress', { status: 'verifying' })
const fileBuffer = Buffer.from(res.data as ArrayBuffer)
const actualHash = createHash('sha256').update(fileBuffer).digest('hex')
if (actualHash.toLowerCase() !== expectedHash.toLowerCase()) {
throw new Error(`File integrity check failed: expected ${expectedHash}, got ${actualHash}`)
}
await writeFile(path.join(dataDir(), file), fileBuffer)
}
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}`
)
}
}
}
if (file.endsWith('.7z')) {
await copyFile(path.join(resourcesFilesDir(), '7za.exe'), path.join(dataDir(), '7za.exe'))
spawn(
'cmd',
[
'/C',
`"timeout /t 2 /nobreak >nul && "${path.join(dataDir(), '7za.exe')}" x -o"${exeDir()}" -y "${path.join(dataDir(), file)}" & start "" "${exePath()}""`
],
{
shell: true,
detached: true
}
).unref()
app.quit()
}
if (file.endsWith('.pkg')) {
try {
const execPromise = promisify(exec)
const shell = `installer -pkg ${path.join(dataDir(), file).replace(' ', '\\\\ ')} -target /`
const command = `do shell script "${shell}" with administrator privileges`
await execPromise(`osascript -e '${command}'`)
app.relaunch()
app.quit()
} catch {
shell.openPath(path.join(dataDir(), file))
}
}
} catch (e) {
rm(path.join(dataDir(), file))
throw e
}
}
================================================
FILE: src/main/resolve/backup.ts
================================================
import https from 'https'
import { existsSync } from 'fs'
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,
dataDir,
overrideConfigPath,
overrideDir,
profileConfigPath,
profilesDir,
rulesDir,
subStoreDir,
themesDir
} from '../utils/dirs'
import { getAppConfig } from '../config'
let backupCronJob: Cron | null = null
interface WebDAVContext {
client: ReturnType['createClient']>
webdavDir: string
webdavMaxBackups: number
}
async function getWebDAVClient(): Promise {
const { createClient } = await import('webdav/dist/node/index.js')
const {
webdavUrl = '',
webdavUsername = '',
webdavPassword = '',
webdavDir = 'clash-party',
webdavMaxBackups = 0,
webdavIgnoreCert = false
} = await getAppConfig()
const clientOptions: Parameters[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 {
const { client, webdavDir, webdavMaxBackups } = await getWebDAVClient()
const zip = createBackupZip()
const date = new Date()
const zipFileName = `${process.platform}_${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip`
try {
await client.createDirectory(webdavDir)
} catch {
// ignore
}
const result = await client.putFileContents(`${webdavDir}/${zipFileName}`, zip.toBuffer())
if (webdavMaxBackups > 0) {
try {
const files = await client.getDirectoryContents(webdavDir, { glob: '*.zip' })
const fileList = Array.isArray(files) ? files : files.data
const currentPlatformFiles = fileList.filter((file) => {
return file.basename.startsWith(`${process.platform}_`)
})
currentPlatformFiles.sort((a, b) => {
const timeA = a.basename.match(/_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.zip$/)?.[1] || ''
const timeB = b.basename.match(/_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.zip$/)?.[1] || ''
return timeB.localeCompare(timeA)
})
if (currentPlatformFiles.length > webdavMaxBackups) {
const filesToDelete = currentPlatformFiles.slice(webdavMaxBackups)
for (let i = 0; i < filesToDelete.length; i++) {
const file = filesToDelete[i]
await client.deleteFile(`${webdavDir}/${file.basename}`)
if (i < filesToDelete.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
}
} catch (error) {
await systemLogger.error('Failed to clean up old backup files', error)
}
}
return result
}
export async function webdavRestore(filename: string): Promise {
const { client, webdavDir } = await getWebDAVClient()
const zipData = await client.getFileContents(`${webdavDir}/${filename}`)
const zip = new AdmZip(zipData as Buffer)
zip.extractAllTo(dataDir(), true)
}
export async function listWebdavBackups(): Promise {
const { client, webdavDir } = await getWebDAVClient()
const files = await client.getDirectoryContents(webdavDir, { glob: '*.zip' })
if (Array.isArray(files)) {
return files.map((file) => file.basename)
} else {
return files.data.map((file) => file.basename)
}
}
export async function webdavDelete(filename: string): Promise {
const { client, webdavDir } = await getWebDAVClient()
await client.deleteFile(`${webdavDir}/${filename}`)
}
/**
* 初始化 WebDAV 定时备份任务
*/
export async function initWebdavBackupScheduler(): Promise {
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 {
if (backupCronJob) {
backupCronJob.stop()
backupCronJob = null
await systemLogger.info('WebDAV backup scheduler stopped')
}
}
/**
* 重新初始化 WebDAV 定时备份任务
* 先停止现有任务,然后重新启动
*/
export async function reinitScheduler(): Promise {
await systemLogger.info('Reinitializing WebDAV backup scheduler...')
await stopWebdavBackupScheduler()
await initWebdavBackupScheduler()
await systemLogger.info('WebDAV backup scheduler reinitialized successfully')
}
/**
* 导出本地备份
*/
export async function exportLocalBackup(): Promise {
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 {
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
}
================================================
FILE: src/main/resolve/floatingWindow.ts
================================================
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import { BrowserWindow, ipcMain } from 'electron'
import windowStateKeeper from 'electron-window-state'
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 {
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
}
}
if (process.platform === 'win32') {
windowOptions.hasShadow = !safeMode
if (windowOptions.webPreferences) {
windowOptions.webPreferences.offscreen = false
}
}
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
}
}
export async function showFloatingWindow(): Promise {
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
}
}
export async function triggerFloatingWindow(): Promise {
if (floatingWindow?.isVisible()) {
await patchAppConfig({ showFloatingWindow: false })
await closeFloatingWindow()
} else {
await patchAppConfig({ showFloatingWindow: true })
await showFloatingWindow()
}
}
export async function closeFloatingWindow(): Promise {
if (floatingWindow) {
ipcMain.removeAllListeners('updateFloatingWindow')
floatingWindow.destroy()
floatingWindow = null
}
await showTrayIcon()
await patchAppConfig({ disableTray: false })
}
export async function showContextMenu(): Promise {
const menu = await buildContextMenu()
menu.popup()
}
================================================
FILE: src/main/resolve/gistApi.ts
================================================
import * as chromeRequest from '../utils/chromeRequest'
import { getAppConfig, getControledMihomoConfig } from '../config'
import { getRuntimeConfigStr } from '../core/factory'
interface GistInfo {
id: string
description: string
html_url: string
}
async function listGists(token: string): Promise {
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
const res = await chromeRequest.get('https://api.github.com/gists', {
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28'
},
proxy: {
protocol: 'http',
host: '127.0.0.1',
port
},
responseType: 'json'
})
return Array.isArray(res.data) ? res.data : []
}
async function createGist(token: string, content: string): Promise {
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
await chromeRequest.post(
'https://api.github.com/gists',
{
description: 'Auto Synced Clash Party Runtime Config',
public: false,
files: { 'clash-party.yaml': { content } }
},
{
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28'
},
proxy: {
protocol: 'http',
host: '127.0.0.1',
port
}
}
)
}
async function updateGist(token: string, id: string, content: string): Promise {
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
await chromeRequest.patch(
`https://api.github.com/gists/${id}`,
{
description: 'Auto Synced Clash Party Runtime Config',
files: { 'clash-party.yaml': { content } }
},
{
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28'
},
proxy: {
protocol: 'http',
host: '127.0.0.1',
port
}
}
)
}
export async function getGistUrl(): Promise {
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')
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')
if (!gist) throw new Error('Gist not found')
return gist.html_url
}
}
export async function uploadRuntimeConfig(): Promise {
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 config = await getRuntimeConfigStr()
if (gist) {
await updateGist(githubToken, gist.id, config)
} else {
await createGist(githubToken, config)
}
}
================================================
FILE: src/main/resolve/server.ts
================================================
import { Worker } from 'worker_threads'
import { createWriteStream, existsSync, mkdirSync } from 'fs'
import { writeFile, rm, cp } from 'fs/promises'
import http from 'http'
import net from 'net'
import path from 'path'
import { nativeImage } from 'electron'
import express from 'express'
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
export let subStoreFrontendPort: number
let subStoreFrontendServer: http.Server
let subStoreBackendWorker: Worker
const defaultPacScript = `
function FindProxyForURL(url, host) {
return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
}
`
export function findAvailablePort(startPort: number): Promise {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.on('error', (err) => {
if (startPort <= 65535) {
resolve(findAvailablePort(startPort + 1))
} else {
reject(err)
}
})
server.on('listening', () => {
server.close(() => {
resolve(startPort)
})
})
server.listen(startPort, '127.0.0.1')
})
}
let pacServer: http.Server
export async function startPacServer(): Promise {
await stopPacServer()
const { sysProxy } = await getAppConfig()
const { mode = 'manual', host: cHost, pacScript } = sysProxy
if (mode !== 'auto') {
return
}
const host = cHost || '127.0.0.1'
let script = pacScript || defaultPacScript
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
script = script.replaceAll('%mixed-port%', port.toString())
pacPort = await findAvailablePort(10000)
pacServer = http
.createServer(async (_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/x-ns-proxy-autoconfig' })
res.end(script)
})
.listen(pacPort, host)
}
export async function stopPacServer(): Promise {
if (pacServer) {
pacServer.close()
}
}
export async function startSubStoreFrontendServer(): Promise {
const { useSubStore = true, subStoreHost = '127.0.0.1' } = await getAppConfig()
if (!useSubStore) return
await stopSubStoreFrontendServer()
subStoreFrontendPort = await findAvailablePort(14122)
const app = express()
app.use(express.static(path.join(mihomoWorkDir(), 'sub-store-frontend')))
subStoreFrontendServer = app.listen(subStoreFrontendPort, subStoreHost)
}
export async function stopSubStoreFrontendServer(): Promise {
if (subStoreFrontendServer) {
subStoreFrontendServer.close()
}
}
export async function startSubStoreBackendServer(): Promise {
const {
useSubStore = true,
useCustomSubStore = false,
useProxyInSubStore = false,
subStoreHost = '127.0.0.1',
subStoreBackendSyncCron = '',
subStoreBackendDownloadCron = '',
subStoreBackendUploadCron = ''
} = await getAppConfig()
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
if (!useSubStore) return
if (!useCustomSubStore) {
await stopSubStoreBackendServer()
subStorePort = await findAvailablePort(38324)
const icon = nativeImage.createFromPath(subStoreIcon)
icon.toDataURL()
const stdout = createWriteStream(substoreLogPath(), { flags: 'a' })
const stderr = createWriteStream(substoreLogPath(), { flags: 'a' })
const env = {
SUB_STORE_BACKEND_API_PORT: subStorePort.toString(),
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_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'), {
env: useProxyInSubStore
? {
...env,
HTTP_PROXY: `http://127.0.0.1:${port}`,
HTTPS_PROXY: `http://127.0.0.1:${port}`,
ALL_PROXY: `http://127.0.0.1:${port}`
}
: env
})
subStoreBackendWorker.stdout.pipe(stdout)
subStoreBackendWorker.stderr.pipe(stderr)
}
}
export async function stopSubStoreBackendServer(): Promise {
if (subStoreBackendWorker) {
subStoreBackendWorker.terminate()
}
}
export async function downloadSubStore(): Promise {
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 tempDir = path.join(dataDir(), 'temp')
try {
// 创建临时目录
if (existsSync(tempDir)) {
await rm(tempDir, { recursive: true })
}
mkdirSync(tempDir, { recursive: true })
// 下载后端文件
const tempBackendPath = path.join(tempDir, 'sub-store.bundle.cjs')
const backendRes = await chromeRequest.get(
'https://github.com/sub-store-org/Sub-Store/releases/latest/download/sub-store.bundle.js',
{
responseType: 'arraybuffer',
headers: { 'Content-Type': 'application/octet-stream' },
proxy: {
protocol: 'http',
host: '127.0.0.1',
port: mixedPort
}
}
)
await writeFile(tempBackendPath, Buffer.from(backendRes.data as Buffer))
// 下载前端文件
const frontendRes = await chromeRequest.get(
'https://github.com/sub-store-org/Sub-Store-Front-End/releases/latest/download/dist.zip',
{
responseType: 'arraybuffer',
headers: { 'Content-Type': 'application/octet-stream' },
proxy: {
protocol: 'http',
host: '127.0.0.1',
port: mixedPort
}
}
)
// 先解压到临时目录
const zip = new AdmZip(Buffer.from(frontendRes.data as Buffer))
zip.extractAllTo(tempDir, true)
await cp(tempBackendPath, backendPath)
if (existsSync(frontendDir)) {
await rm(frontendDir, { recursive: true })
}
mkdirSync(frontendDir, { recursive: true })
await cp(path.join(tempDir, 'dist'), frontendDir, { recursive: true })
await rm(tempDir, { recursive: true })
} catch (error) {
await systemLogger.error('substore.downloadFailed', error)
throw error
}
}
================================================
FILE: src/main/resolve/shortcut.ts
================================================
import { app, globalShortcut, ipcMain, Notification } from 'electron'
import { mainWindow, triggerMainWindow } from '../window'
import {
getAppConfig,
getControledMihomoConfig,
patchAppConfig,
patchControledMihomoConfig
} from '../config'
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'
export async function registerShortcut(
oldShortcut: string,
newShortcut: string,
action: string
): Promise {
if (oldShortcut !== '') {
globalShortcut.unregister(oldShortcut)
}
if (newShortcut === '') {
return true
}
switch (action) {
case 'showWindowShortcut': {
return globalShortcut.register(newShortcut, () => {
triggerMainWindow(true)
})
}
case 'showFloatingWindowShortcut': {
return globalShortcut.register(newShortcut, async () => {
await triggerFloatingWindow()
})
}
case 'triggerSysProxyShortcut': {
return globalShortcut.register(newShortcut, async () => {
const {
sysProxy: { enable }
} = await getAppConfig()
try {
await triggerSysProxy(!enable)
await patchAppConfig({ sysProxy: { enable: !enable } })
new Notification({
title: i18next.t(
!enable
? 'common.notification.systemProxyEnabled'
: 'common.notification.systemProxyDisabled'
)
}).show()
mainWindow?.webContents.send('appConfigUpdated')
floatingWindow?.webContents.send('appConfigUpdated')
} catch {
// ignore
} finally {
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
})
}
case 'triggerTunShortcut': {
return globalShortcut.register(newShortcut, async () => {
const { tun } = await getControledMihomoConfig()
const enable = tun?.enable ?? false
try {
if (!enable) {
await patchControledMihomoConfig({ tun: { enable: !enable }, dns: { enable: true } })
} else {
await patchControledMihomoConfig({ tun: { enable: !enable } })
}
await restartCore()
new Notification({
title: i18next.t(
!enable ? 'common.notification.tunEnabled' : 'common.notification.tunDisabled'
)
}).show()
mainWindow?.webContents.send('controledMihomoConfigUpdated')
floatingWindow?.webContents.send('appConfigUpdated')
} catch {
// ignore
} finally {
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
})
}
case 'ruleModeShortcut': {
return globalShortcut.register(newShortcut, async () => {
await patchControledMihomoConfig({ mode: 'rule' })
await patchMihomoConfig({ mode: 'rule' })
new Notification({
title: i18next.t('common.notification.ruleMode')
}).show()
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
})
}
case 'globalModeShortcut': {
return globalShortcut.register(newShortcut, async () => {
await patchControledMihomoConfig({ mode: 'global' })
await patchMihomoConfig({ mode: 'global' })
new Notification({
title: i18next.t('common.notification.globalMode')
}).show()
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
})
}
case 'directModeShortcut': {
return globalShortcut.register(newShortcut, async () => {
await patchControledMihomoConfig({ mode: 'direct' })
await patchMihomoConfig({ mode: 'direct' })
new Notification({
title: i18next.t('common.notification.directMode')
}).show()
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
})
}
case 'quitWithoutCoreShortcut': {
return globalShortcut.register(newShortcut, async () => {
await quitWithoutCore()
})
}
case 'restartAppShortcut': {
return globalShortcut.register(newShortcut, () => {
app.relaunch()
app.quit()
})
}
}
throw new Error('Unknown action')
}
export async function initShortcut(): Promise {
const {
showFloatingWindowShortcut,
showWindowShortcut,
triggerSysProxyShortcut,
triggerTunShortcut,
ruleModeShortcut,
globalModeShortcut,
directModeShortcut,
quitWithoutCoreShortcut,
restartAppShortcut
} = await getAppConfig()
if (showWindowShortcut) {
try {
await registerShortcut('', showWindowShortcut, 'showWindowShortcut')
} catch {
// ignore
}
}
if (showFloatingWindowShortcut) {
try {
await registerShortcut('', showFloatingWindowShortcut, 'showFloatingWindowShortcut')
} catch {
// ignore
}
}
if (triggerSysProxyShortcut) {
try {
await registerShortcut('', triggerSysProxyShortcut, 'triggerSysProxyShortcut')
} catch {
// ignore
}
}
if (triggerTunShortcut) {
try {
await registerShortcut('', triggerTunShortcut, 'triggerTunShortcut')
} catch {
// ignore
}
}
if (ruleModeShortcut) {
try {
await registerShortcut('', ruleModeShortcut, 'ruleModeShortcut')
} catch {
// ignore
}
}
if (globalModeShortcut) {
try {
await registerShortcut('', globalModeShortcut, 'globalModeShortcut')
} catch {
// ignore
}
}
if (directModeShortcut) {
try {
await registerShortcut('', directModeShortcut, 'directModeShortcut')
} catch {
// ignore
}
}
if (quitWithoutCoreShortcut) {
try {
await registerShortcut('', quitWithoutCoreShortcut, 'quitWithoutCoreShortcut')
} catch {
// ignore
}
}
if (restartAppShortcut) {
try {
await registerShortcut('', restartAppShortcut, 'restartAppShortcut')
} catch {
// ignore
}
}
}
================================================
FILE: src/main/resolve/theme.ts
================================================
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 { getControledMihomoConfig } from '../config'
import { mainWindow } from '../window'
import { floatingWindow } from './floatingWindow'
let insertedCSSKeyMain: string | undefined = undefined
let insertedCSSKeyFloating: string | undefined = undefined
export async function resolveThemes(): Promise<{ key: string; label: string }[]> {
const files = await readdir(themesDir())
const themes = await Promise.all(
files
.filter((file) => file.endsWith('.css'))
.map(async (file) => {
const css = (await readFile(path.join(themesDir(), file), 'utf-8')) || ''
let name = file
if (css.startsWith('/*')) {
name = css.split('\n')[0].replace('/*', '').replace('*/', '').trim() || file
}
return { key: file, label: name }
})
)
if (themes.find((theme) => theme.key === 'default.css')) {
return themes
} else {
return [{ key: 'default.css', label: t('common.default') }, ...themes]
}
}
export async function fetchThemes(): Promise {
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, {
responseType: 'arraybuffer',
headers: { 'Content-Type': 'application/octet-stream' },
proxy: {
protocol: 'http',
host: '127.0.0.1',
port: mixedPort
}
})
const zip = new AdmZip(Buffer.from(zipData.data as Buffer))
zip.extractAllTo(themesDir(), true)
}
export async function importThemes(files: string[]): Promise {
for (const file of files) {
if (existsSync(file))
await copyFile(
file,
path.join(themesDir(), `${new Date().getTime().toString(16)}-${path.basename(file)}`)
)
}
}
export async function readTheme(theme: string): Promise {
if (!existsSync(path.join(themesDir(), theme))) return ''
return await readFile(path.join(themesDir(), theme), 'utf-8')
}
export async function writeTheme(theme: string, css: string): Promise {
await writeFile(path.join(themesDir(), theme), css)
}
export async function applyTheme(theme: string): Promise {
const css = await readTheme(theme)
await mainWindow?.webContents.removeInsertedCSS(insertedCSSKeyMain || '')
insertedCSSKeyMain = await mainWindow?.webContents.insertCSS(css)
try {
await floatingWindow?.webContents.removeInsertedCSS(insertedCSSKeyFloating || '')
insertedCSSKeyFloating = await floatingWindow?.webContents.insertCSS(css)
} catch {
// ignore
}
}
================================================
FILE: src/main/resolve/trafficMonitor.ts
================================================
import { ChildProcess, spawn } from 'child_process'
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
export async function startMonitor(detached = false): Promise {
if (process.platform !== 'win32') return
if (existsSync(path.join(dataDir(), 'monitor.pid'))) {
const pid = parseInt(await readFile(path.join(dataDir(), 'monitor.pid'), 'utf-8'))
try {
process.kill(pid, 'SIGINT')
} catch {
// ignore
} finally {
await rm(path.join(dataDir(), 'monitor.pid'))
}
}
await stopMonitor()
const { showTraffic = false } = await getAppConfig()
if (!showTraffic) return
child = spawn(path.join(resourcesFilesDir(), 'TrafficMonitor/TrafficMonitor.exe'), [], {
cwd: path.join(resourcesFilesDir(), 'TrafficMonitor'),
detached: detached,
stdio: detached ? 'ignore' : undefined
})
if (detached) {
if (child && child.pid) {
await writeFile(path.join(dataDir(), 'monitor.pid'), child.pid.toString())
}
child.unref()
}
}
async function stopMonitor(): Promise {
if (child) {
child.kill('SIGINT')
}
}
================================================
FILE: src/main/resolve/tray.ts
================================================
import { app, clipboard, ipcMain, Menu, nativeImage, shell, Tray } from 'electron'
import { t } from 'i18next'
import {
changeCurrentProfile,
getAppConfig,
getControledMihomoConfig,
getProfileConfig,
patchAppConfig,
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
} from '../core/mihomoApi'
import { mainWindow, showMainWindow, triggerMainWindow } from '../window'
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 { floatingWindow, triggerFloatingWindow } from './floatingWindow'
export let tray: Tray | null = null
// macOS 流量显示状态,避免异步读取配置导致的时序问题
let macTrafficIconEnabled = false
export const buildContextMenu = async (): Promise => {
// 添加调试日志
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')
)
const { mode, tun } = await getControledMihomoConfig()
const {
sysProxy,
envType = process.platform === 'win32' ? ['powershell'] : ['bash'],
autoCloseConnection,
proxyInTray = true,
showCurrentProxyInTray = false,
trayProxyGroupStyle = 'default',
triggerSysProxyShortcut = '',
showFloatingWindowShortcut = '',
showWindowShortcut = '',
triggerTunShortcut = '',
ruleModeShortcut = '',
globalModeShortcut = '',
directModeShortcut = '',
quitWithoutCoreShortcut = '',
restartAppShortcut = ''
} = await getAppConfig()
let groupsMenu: Electron.MenuItemConstructorOptions[] = []
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
return {
id: group.name,
label: groupLabel,
type: 'submenu' as const,
submenu: group.all.map((proxy) => {
const delay = proxy.history.length ? proxy.history[proxy.history.length - 1].delay : -1
let displayDelay = `(${delay}ms)`
if (delay === -1) {
displayDelay = ''
}
if (delay === 0) {
displayDelay = '(Timeout)'
}
return {
id: proxy.name,
label: `${proxy.name} ${displayDelay}`,
type: 'radio' as const,
checked: proxy.name === group.now,
click: async (): Promise => {
await mihomoChangeProxy(group.name, proxy.name)
if (autoCloseConnection) {
await mihomoCloseAllConnections()
}
}
}
})
}
})
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 {
// ignore
// 避免出错时无法创建托盘菜单
}
}
const { current, items = [] } = await getProfileConfig()
const contextMenu = [
{
id: 'show',
accelerator: showWindowShortcut,
label: t('tray.showWindow'),
type: 'normal',
click: (): void => {
showMainWindow()
}
},
{
id: 'show-floating',
accelerator: showFloatingWindowShortcut,
label: floatingWindow?.isVisible()
? t('tray.hideFloatingWindow')
: t('tray.showFloatingWindow'),
type: 'normal',
click: async (): Promise => {
await triggerFloatingWindow()
}
},
{
id: 'rule',
label: t('tray.ruleMode'),
accelerator: ruleModeShortcut,
type: 'radio',
checked: mode === 'rule',
click: async (): Promise => {
await patchControledMihomoConfig({ mode: 'rule' })
await patchMihomoConfig({ mode: 'rule' })
mainWindow?.webContents.send('controledMihomoConfigUpdated')
mainWindow?.webContents.send('groupsUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
},
{
id: 'global',
label: t('tray.globalMode'),
accelerator: globalModeShortcut,
type: 'radio',
checked: mode === 'global',
click: async (): Promise => {
await patchControledMihomoConfig({ mode: 'global' })
await patchMihomoConfig({ mode: 'global' })
mainWindow?.webContents.send('controledMihomoConfigUpdated')
mainWindow?.webContents.send('groupsUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
},
{
id: 'direct',
label: t('tray.directMode'),
accelerator: directModeShortcut,
type: 'radio',
checked: mode === 'direct',
click: async (): Promise => {
await patchControledMihomoConfig({ mode: 'direct' })
await patchMihomoConfig({ mode: 'direct' })
mainWindow?.webContents.send('controledMihomoConfigUpdated')
mainWindow?.webContents.send('groupsUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
},
{ type: 'separator' },
{
type: 'checkbox',
label: t('tray.systemProxy'),
accelerator: triggerSysProxyShortcut,
checked: sysProxy.enable,
click: async (item): Promise => {
const enable = item.checked
try {
await triggerSysProxy(enable)
await patchAppConfig({ sysProxy: { enable } })
mainWindow?.webContents.send('appConfigUpdated')
floatingWindow?.webContents.send('appConfigUpdated')
} catch {
// ignore
} finally {
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
}
},
{
type: 'checkbox',
label: t('tray.tun'),
accelerator: triggerTunShortcut,
checked: tun?.enable ?? false,
click: async (item): Promise => {
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 } })
}
mainWindow?.webContents.send('controledMihomoConfigUpdated')
floatingWindow?.webContents.send('controledMihomoConfigUpdated')
await restartCore()
} catch {
// ignore
} finally {
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
}
},
...groupsMenu,
{ type: 'separator' },
{
type: 'submenu',
label: t('tray.profiles'),
submenu: items.map((item) => {
return {
type: 'radio',
label: item.name,
checked: item.id === current,
click: async (): Promise => {
if (item.id === current) return
await changeCurrentProfile(item.id)
mainWindow?.webContents.send('profileConfigUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
}
})
},
{ type: 'separator' },
{
type: 'submenu',
label: t('tray.openDirectories.title'),
submenu: [
{
type: 'normal',
label: t('tray.openDirectories.appDir'),
click: (): Promise => shell.openPath(dataDir())
},
{
type: 'normal',
label: t('tray.openDirectories.workDir'),
click: (): Promise => shell.openPath(mihomoWorkDir())
},
{
type: 'normal',
label: t('tray.openDirectories.coreDir'),
click: (): Promise => shell.openPath(mihomoCoreDir())
},
{
type: 'normal',
label: t('tray.openDirectories.logDir'),
click: (): Promise => shell.openPath(logDir())
}
]
},
envType.length > 1
? {
type: 'submenu',
label: t('tray.copyEnv'),
submenu: envType.map((type) => {
return {
id: type,
label: type,
type: 'normal',
click: async (): Promise => {
await copyEnv(type)
}
}
})
}
: {
id: 'copyenv',
label: t('tray.copyEnv'),
type: 'normal',
click: async (): Promise => {
await copyEnv(envType[0])
}
},
{ type: 'separator' },
{
id: 'quitWithoutCore',
label: t('actions.lightMode.button'),
type: 'normal',
accelerator: quitWithoutCoreShortcut,
click: quitWithoutCore
},
{
id: 'restart',
label: t('actions.restartApp'),
type: 'normal',
accelerator: restartAppShortcut,
click: (): void => {
app.relaunch()
app.quit()
}
},
{
id: 'quit',
label: t('actions.quit.button'),
type: 'normal',
accelerator: 'CommandOrControl+Q',
click: (): void => app.quit()
}
] as Electron.MenuItemConstructorOptions[]
return Menu.buildFromTemplate(contextMenu)
}
export async function createTray(): Promise {
const { useDockIcon = true, swapTrayClick = false } = await getAppConfig()
if (process.platform === 'linux') {
tray = new Tray(pngIcon)
const menu = await buildContextMenu()
tray.setContextMenu(menu)
}
if (process.platform === 'darwin') {
const icon = nativeImage.createFromPath(templateIcon).resize({ height: 16 })
icon.setTemplateImage(true)
tray = new Tray(icon)
}
if (process.platform === 'win32') {
tray = new Tray(icoIcon)
}
tray?.setToolTip('Clash 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
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()
}
})
}
if (process.platform === 'win32') {
tray?.addListener('click', async () => {
if (swapTrayClick) {
await updateTrayMenu()
} else {
triggerMainWindow()
}
})
tray?.addListener('right-click', async () => {
if (swapTrayClick) {
triggerMainWindow()
} else {
await updateTrayMenu()
}
})
}
if (process.platform === 'linux') {
tray?.addListener('click', async () => {
if (swapTrayClick) {
await updateTrayMenu()
} else {
triggerMainWindow()
}
})
// 移除旧监听器防止累积
ipcMain.removeAllListeners('updateTrayMenu')
ipcMain.on('updateTrayMenu', async () => {
await updateTrayMenu()
})
}
}
async function updateTrayMenu(): Promise {
const menu = await buildContextMenu()
tray?.popUpContextMenu(menu) // 弹出菜单
if (process.platform === 'linux') {
tray?.setContextMenu(menu)
}
}
export async function copyEnv(
type: 'bash' | 'cmd' | 'powershell' | 'fish' | 'nushell'
): Promise {
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}`
)
break
}
case 'cmd': {
clipboard.writeText(
`set http_proxy=${proxyUrl}\r\nset https_proxy=${proxyUrl}`
)
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}"`
)
break
}
}
}
export async function showTrayIcon(): Promise {
if (!tray) {
await createTray()
}
}
export async function closeTrayIcon(): Promise {
if (tray) {
tray.destroy()
}
tray = null
}
export async function showDockIcon(): Promise {
if (process.platform === 'darwin' && app.dock && !app.dock.isVisible()) {
await app.dock.show()
}
}
export async function hideDockIcon(): Promise {
if (process.platform === 'darwin' && app.dock && 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 {
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
}
}
================================================
FILE: src/main/sys/autoRun.ts
================================================
import { tmpdir } from 'os'
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'
return `
true
PT3S
InteractiveToken
${runLevel}
Parallel
false
false
false
false
false
false
false
true
true
false
false
false
PT0S
3
"${exePath()}"
`
}
export async function checkAutoRun(): Promise {
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 {
return false
}
}
if (process.platform === 'darwin') {
const execPromise = promisify(exec)
const { stdout } = await execPromise(
`osascript -e 'tell application "System Events" to get the name of every login item'`
)
return stdout.includes(exePath().split('.app')[0].replace('/Applications/', ''))
}
if (process.platform === 'linux') {
return existsSync(path.join(homeDir, '.config', 'autostart', `${appName}.desktop`))
}
return false
}
export async function enableAutoRun(): Promise {
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)
}
}
}
if (process.platform === 'darwin') {
const execPromise = promisify(exec)
await execPromise(
`osascript -e 'tell application "System Events" to make login item at end with properties {path:"${exePath().split('.app')[0]}.app", hidden:false}'`
)
}
if (process.platform === 'linux') {
let desktop = `
[Desktop Entry]
Name=mihomo-party
Exec=${exePath()} %U
Terminal=false
Type=Application
Icon=mihomo-party
StartupWMClass=mihomo-party
Comment=Clash Party
Categories=Utility;
`
if (existsSync(`/usr/share/applications/${appName}.desktop`)) {
desktop = await readFile(`/usr/share/applications/${appName}.desktop`, 'utf8')
}
const autostartDir = path.join(homeDir, '.config', 'autostart')
if (!existsSync(autostartDir)) {
await mkdir(autostartDir, { recursive: true })
}
const desktopFilePath = path.join(autostartDir, `${appName}.desktop`)
await writeFile(desktopFilePath, desktop)
}
}
export async function disableAutoRun(): Promise {
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 {
// 注册表项可能不存在,忽略错误
}
}
if (process.platform === 'darwin') {
const execPromise = promisify(exec)
await execPromise(
`osascript -e 'tell application "System Events" to delete login item "${exePath().split('.app')[0].replace('/Applications/', '')}"'`
)
}
if (process.platform === 'linux') {
const desktopFilePath = path.join(homeDir, '.config', 'autostart', `${appName}.desktop`)
await rm(desktopFilePath)
}
}
================================================
FILE: src/main/sys/interface.ts
================================================
import os from 'os'
export function getInterfaces(): NodeJS.Dict {
return os.networkInterfaces()
}
================================================
FILE: src/main/sys/misc.ts
================================================
import { exec, execFile, spawn } from 'child_process'
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
} from '../utils/dirs'
export function getFilePath(ext: string[]): string[] | undefined {
return dialog.showOpenDialogSync({
title: i18next.t('common.dialog.selectSubscriptionFile'),
filters: [{ name: `${ext} file`, extensions: ext }],
properties: ['openFile']
})
}
export async function readTextFile(filePath: string): Promise {
return await readFile(filePath, 'utf8')
}
export function openFile(type: 'profile' | 'override', id: string, ext?: 'yaml' | 'js'): void {
if (type === 'profile') {
shell.openPath(profilePath(id))
}
if (type === 'override') {
shell.openPath(overridePath(id, ext || 'js'))
}
}
export async function openUWPTool(): Promise {
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)
}
export async function setupFirewall(): Promise {
const execPromise = promisify(exec)
const removeCommand = `
$rules = @("mihomo", "mihomo-alpha", "Mihomo Party")
foreach ($rule in $rules) {
if (Get-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue) {
Remove-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue
}
}
`
const createCommand = `
New-NetFirewallRule -DisplayName "mihomo" -Direction Inbound -Action Allow -Program "${mihomoCorePath('mihomo')}" -Enabled True -Profile Any -ErrorAction SilentlyContinue
New-NetFirewallRule -DisplayName "mihomo-alpha" -Direction Inbound -Action Allow -Program "${mihomoCorePath('mihomo-alpha')}" -Enabled True -Profile Any -ErrorAction SilentlyContinue
New-NetFirewallRule -DisplayName "Mihomo Party" -Direction Inbound -Action Allow -Program "${exePath()}" -Enabled True -Profile Any -ErrorAction SilentlyContinue
`
if (process.platform === 'win32') {
await execPromise(removeCommand, { shell: 'powershell' })
await execPromise(createCommand, { shell: 'powershell' })
}
}
export function setNativeTheme(theme: 'system' | 'light' | 'dark'): void {
nativeTheme.themeSource = theme
}
export function resetAppConfig(): void {
if (process.platform === 'win32') {
spawn(
'cmd',
[
'/C',
`"timeout /t 2 /nobreak >nul && rmdir /s /q "${dataDir()}" && start "" "${exePath()}""`
],
{
shell: true,
detached: true
}
).unref()
} else {
const script = `while kill -0 ${process.pid} 2>/dev/null; do
sleep 0.1
done
rm -rf '${dataDir()}'
${process.argv.join(' ')} & disown
exit
`
spawn('sh', ['-c', `"${script}"`], {
shell: true,
detached: true,
stdio: 'ignore'
})
}
app.quit()
}
================================================
FILE: src/main/sys/ssid.ts
================================================
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 { getDefaultDevice } from '../core/manager'
export async function getCurrentSSID(): Promise {
if (process.platform === 'win32') {
try {
return await getSSIDByNetsh()
} catch {
return undefined
}
}
if (process.platform === 'linux') {
try {
return await getSSIDByIwconfig()
} catch {
return undefined
}
}
if (process.platform === 'darwin') {
try {
return await getSSIDByAirport()
} catch {
return await getSSIDByNetworksetup()
}
}
return undefined
}
let lastSSID: string | undefined
let ssidCheckInterval: NodeJS.Timeout | null = null
export async function checkSSID(): Promise {
try {
const { pauseSSID = [] } = await getAppConfig()
if (pauseSSID.length === 0) return
const currentSSID = await getCurrentSSID()
if (currentSSID === lastSSID) return
lastSSID = currentSSID
if (currentSSID && pauseSSID.includes(currentSSID)) {
await patchControledMihomoConfig({ mode: 'direct' })
await patchMihomoConfig({ mode: 'direct' })
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
} else {
await patchControledMihomoConfig({ mode: 'rule' })
await patchMihomoConfig({ mode: 'rule' })
mainWindow?.webContents.send('controledMihomoConfigUpdated')
ipcMain.emit('updateTrayMenu')
}
} catch {
// ignore
}
}
export async function startSSIDCheck(): Promise {
if (ssidCheckInterval) {
clearInterval(ssidCheckInterval)
}
await checkSSID()
ssidCheckInterval = setInterval(checkSSID, 30000)
}
export function stopSSIDCheck(): void {
if (ssidCheckInterval) {
clearInterval(ssidCheckInterval)
ssidCheckInterval = null
}
}
async function getSSIDByAirport(): Promise {
const execPromise = promisify(exec)
const { stdout } = await execPromise(
'/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -I'
)
if (stdout.trim().startsWith('WARNING')) {
throw new Error('airport cannot be used')
}
for (const line of stdout.split('\n')) {
if (line.trim().startsWith('SSID')) {
return line.split(': ')[1].trim()
}
}
return undefined
}
async function getSSIDByNetworksetup(): Promise {
const execPromise = promisify(exec)
if (net.isOnline()) {
const service = await getDefaultDevice()
const { stdout } = await execPromise(`networksetup -listpreferredwirelessnetworks ${service}`)
if (stdout.trim().startsWith('Preferred networks on')) {
if (stdout.split('\n').length > 1) {
return stdout.split('\n')[1].trim()
}
}
}
return undefined
}
async function getSSIDByNetsh(): Promise {
const execPromise = promisify(exec)
const { stdout } = await execPromise('netsh wlan show interfaces')
for (const line of stdout.split('\n')) {
if (line.trim().startsWith('SSID')) {
return line.split(': ')[1].trim()
}
}
return undefined
}
async function getSSIDByIwconfig(): Promise {
const execPromise = promisify(exec)
const { stdout } = await execPromise(
`iwconfig 2>/dev/null | grep 'ESSID' | awk -F'"' '{print $2}'`
)
if (stdout.trim() !== '') {
return stdout.trim()
}
return undefined
}
================================================
FILE: src/main/sys/sysproxy.ts
================================================
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 { getAppConfig, getControledMihomoConfig } from '../config'
import { pacPort, startPacServer, stopPacServer } from '../resolve/server'
import { proxyLogger } from '../utils/logger'
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',
''
]
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.*',
''
]
default:
return ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1']
}
})()
export async function triggerSysProxy(enable: boolean): Promise {
if (net.isOnline()) {
if (enable) {
await disableSysProxy()
await enableSysProxy()
} else {
await disableSysProxy()
}
} else {
if (triggerSysProxyTimer) clearTimeout(triggerSysProxyTimer)
triggerSysProxyTimer = setTimeout(() => triggerSysProxy(enable), 5000)
}
}
async function enableSysProxy(): Promise {
await startPacServer()
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 }
)
)
} 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(','))
}
} catch (error) {
await proxyLogger.error('Failed to enable system proxy', error)
throw error
}
}
}
async function disableSysProxy(): Promise {
await stopPacServer()
if (process.platform === 'darwin') {
await helperRequest(() => axios.get('http://localhost/off', { socketPath: helperSocketPath }))
} else {
// Windows / Linux 直接使用 sysproxy-rs
try {
triggerAutoProxy(false, '')
triggerManualProxy(false, '', 0, '')
} catch (error) {
await proxyLogger.error('Failed to disable system proxy', error)
throw error
}
}
}
function isSocketFileExists(): boolean {
try {
return fs.existsSync(helperSocketPath)
} catch {
return false
}
}
async function isHelperRunning(): Promise {
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 {
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))
}
async function requestSocketRecreation(): Promise {
try {
const execPromise = promisify(exec)
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))
} catch (error) {
await proxyLogger.error('Failed to send signal to helper', error)
throw error
}
}
async function helperRequest(requestFn: () => Promise, maxRetries = 2): Promise {
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...')
try {
await requestSocketRecreation()
await proxyLogger.info('Socket recreation requested, retrying...')
continue
} catch (signalError) {
await proxyLogger.warn('Failed to request socket recreation', signalError)
}
}
}
if (attempt === maxRetries) {
throw lastError
}
}
}
throw lastError
}
================================================
FILE: src/main/utils/appName.ts
================================================
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 {
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
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
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()
}
================================================
FILE: src/main/utils/calc.ts
================================================
export function calcTraffic(byte: number): string {
if (byte < 1024) return `${byte} B`
byte /= 1024
if (byte < 1024) return `${formatNumString(byte)} KB`
byte /= 1024
if (byte < 1024) return `${formatNumString(byte)} MB`
byte /= 1024
if (byte < 1024) return `${formatNumString(byte)} GB`
byte /= 1024
if (byte < 1024) return `${formatNumString(byte)} TB`
byte /= 1024
if (byte < 1024) return `${formatNumString(byte)} PB`
byte /= 1024
if (byte < 1024) return `${formatNumString(byte)} EB`
byte /= 1024
if (byte < 1024) return `${formatNumString(byte)} ZB`
byte /= 1024
return `${formatNumString(byte)} YB`
}
function formatNumString(num: number): string {
let str = num.toFixed(2)
if (str.length <= 5) return str
if (str.length === 6) {
str = num.toFixed(1)
return str
} else {
str = Math.round(num).toString()
return str
}
}
================================================
FILE: src/main/utils/chromeRequest.ts
================================================
import { net, session } from 'electron'
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
headers?: Record
body?: string | Buffer
proxy?:
| {
protocol: 'http' | 'https' | 'socks5'
host: string
port: number
}
| false
timeout?: number
responseType?: 'text' | 'json' | 'arraybuffer'
followRedirect?: boolean
maxRedirects?: number
onProgress?: (loaded: number, total: number) => void
}
export interface Response {
data: T
status: number
statusText: string
headers: Record
url: string
}
// 复用单个 session 用于代理请求
let proxySession: Electron.Session | null = null
let currentProxyUrl: string | null = null
let proxySetupPromise: Promise | null = null
async function getProxySession(proxyUrl: string): Promise