Compare commits
353 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f58f780275 | ||
|
|
f959801db2 | ||
|
|
d2e651e79d | ||
|
|
8530cdefcc | ||
|
|
2a4b9f4e5b | ||
|
|
07dd85db84 | ||
|
|
a068c74307 | ||
|
|
3270e47a7b | ||
|
|
7600f82c34 | ||
|
|
c57741fcc5 | ||
|
|
ff70058e8c | ||
|
|
25a3bbe557 | ||
|
|
5445a11ac8 | ||
|
|
85f9e4755a | ||
|
|
58d9e564e5 | ||
|
|
e2fbb2a8ad | ||
|
|
5d4c0cbfb9 | ||
|
|
34fc4d2d96 | ||
|
|
b9a7e04eee | ||
|
|
1a52bff8af | ||
|
|
4fd7dec4db | ||
|
|
9ddb0f15ff | ||
|
|
f5cff160f2 | ||
|
|
46f0389770 | ||
|
|
25b20250a1 | ||
|
|
31bec33064 | ||
|
|
9bbb495dae | ||
|
|
7dbd33fad6 | ||
|
|
9568586f90 | ||
|
|
6425a09fb8 | ||
|
|
8d827d40b7 | ||
|
|
c35d150157 | ||
|
|
33a6e4517e | ||
|
|
a692dc9ead | ||
|
|
7c46a0e4fa | ||
|
|
56880867f3 | ||
|
|
e767ed0da0 | ||
|
|
d5b5a249f7 | ||
|
|
01b5ed1a8f | ||
|
|
4bfa02023c | ||
|
|
dae4939390 | ||
|
|
d2f700a0ef | ||
|
|
1c17cfb683 | ||
|
|
8e6bfb0bd1 | ||
|
|
842e7f1002 | ||
|
|
e9c72ce448 | ||
|
|
d3a23a0601 | ||
|
|
767cdfeef3 | ||
|
|
197c9d3af8 | ||
|
|
388581d75e | ||
|
|
3c148f2c01 | ||
|
|
56e328191f | ||
|
|
0cc1d238c9 | ||
|
|
3f7b85afc1 | ||
|
|
72dd214ef0 | ||
|
|
c7190a311e | ||
|
|
87ccffef7e | ||
|
|
dd16eabb2a | ||
|
|
4a868e53ae | ||
|
|
9e0f27aea3 | ||
|
|
a28f576d78 | ||
|
|
2cfcf8be66 | ||
|
|
e21558ac37 | ||
|
|
228e2cbffc | ||
|
|
ab58248d7b | ||
|
|
295c4400e9 | ||
|
|
bc4b59c66b | ||
|
|
490f559306 | ||
|
|
6bb2304f52 | ||
|
|
ea190b9bc1 | ||
|
|
5f179d3ea5 | ||
|
|
e6cba388b2 | ||
|
|
291abc0a0d | ||
|
|
ae42750f34 | ||
|
|
7b104df463 | ||
|
|
7aea4af2d0 | ||
|
|
1d053fe636 | ||
|
|
b071154263 | ||
|
|
026d9d30f9 | ||
|
|
3d68e57158 | ||
|
|
8af815ee60 | ||
|
|
fdb57431ba | ||
|
|
5eee22292e | ||
|
|
36027cecea | ||
|
|
727eceb0cf | ||
|
|
3d9507b10c | ||
|
|
9d5d2bb73d | ||
|
|
ccaabb7b1a | ||
|
|
45fd8e6870 | ||
|
|
5947394338 | ||
|
|
5f5ca0fd27 | ||
|
|
bab949e16a | ||
|
|
a0bac512dd | ||
|
|
f9176f3fa0 | ||
|
|
2bbf896584 | ||
|
|
30f87b8439 | ||
|
|
075132397c | ||
|
|
19ff003352 | ||
|
|
52cefa39d3 | ||
|
|
e6548a0bc3 | ||
|
|
a5d2114363 | ||
|
|
e70ca694b9 | ||
|
|
6a9edd8665 | ||
|
|
b42287d104 | ||
|
|
9963f67433 | ||
|
|
0b65eb490f | ||
|
|
bff3aedf86 | ||
|
|
2c638f56c0 | ||
|
|
38389e0c3c | ||
|
|
5d10c45206 | ||
|
|
0e0b337a8b | ||
|
|
7b7333d271 | ||
|
|
923bd8d7ee | ||
|
|
2c639d5bff | ||
|
|
8384953fb7 | ||
|
|
85f430f188 | ||
|
|
7634177c5c | ||
|
|
3097019e9e | ||
|
|
9e5d11c3c8 | ||
|
|
676743d1b0 | ||
|
|
c1f7a862aa | ||
|
|
3f1d1f84a1 | ||
|
|
abcbb6388b | ||
|
|
5eced51979 | ||
|
|
34d2b31579 | ||
|
|
0198630e57 | ||
|
|
3d6d545a93 | ||
|
|
fbde5c3f09 | ||
|
|
75f2522a99 | ||
|
|
a4ab3cb448 | ||
|
|
818b546817 | ||
|
|
b60c01bb4c | ||
|
|
ae91194a74 | ||
|
|
38d9e8b81b | ||
|
|
dacb77f414 | ||
|
|
a159974142 | ||
|
|
2467306903 | ||
|
|
573be5501e | ||
|
|
98c8280d48 | ||
|
|
54bb819e28 | ||
|
|
b7d6ea8e7a | ||
|
|
51d169d2e8 | ||
|
|
55416f32cd | ||
|
|
041a81cfd4 | ||
|
|
d811f76bb4 | ||
|
|
393a32bcfe | ||
|
|
6542be8490 | ||
|
|
8f5486064b | ||
|
|
ba10dfd3df | ||
|
|
7a79adef2e | ||
|
|
94f52cf636 | ||
|
|
19ae63b253 | ||
|
|
34fdd21878 | ||
|
|
ddd0077a61 | ||
|
|
972d2fe946 | ||
|
|
dcbd837949 | ||
|
|
8f2e956fd0 | ||
|
|
67aa17f6bb | ||
|
|
51720296cc | ||
|
|
7619b4d3e5 | ||
|
|
392285058e | ||
|
|
508d7d63c9 | ||
|
|
9863c1a1de | ||
|
|
b10075737f | ||
|
|
f541b5ead1 | ||
|
|
47fd7add5f | ||
|
|
b76757bc19 | ||
|
|
0753e5d138 | ||
|
|
583ece0a64 | ||
|
|
4b8ae4063d | ||
|
|
3670f23a1c | ||
|
|
485a936d83 | ||
|
|
fb33f37652 | ||
|
|
46088f5234 | ||
|
|
70dcf5e598 | ||
|
|
f953dca228 | ||
|
|
e1b8c9960a | ||
|
|
9a0eb26ef2 | ||
|
|
8ebe99a8ca | ||
|
|
4af5cae356 | ||
|
|
0e58f6f314 | ||
|
|
80b59fc9de | ||
|
|
98be9d3065 | ||
|
|
0b2f64f42d | ||
|
|
9f46ccf99a | ||
|
|
96552778e6 | ||
|
|
55860af9b3 | ||
|
|
f67d4150f1 | ||
|
|
605351a498 | ||
|
|
b272634c11 | ||
|
|
b02d794092 | ||
|
|
154e2787d5 | ||
|
|
236ad0ab43 | ||
|
|
877a84dc32 | ||
|
|
f61072c309 | ||
|
|
67d378f3f3 | ||
|
|
51b8c879ea | ||
|
|
a5a583fdc5 | ||
|
|
2eb10df116 | ||
|
|
2bf54446df | ||
|
|
6429e93adf | ||
|
|
d8fdbebc4b | ||
|
|
96ef2d2bbc | ||
|
|
4f5af4ee30 | ||
|
|
1ff165d61b | ||
|
|
b1d02c9f82 | ||
|
|
3aff005f81 | ||
|
|
7bdebcf298 | ||
|
|
a7e769f402 | ||
|
|
a7de9b2588 | ||
|
|
f34cc976b4 | ||
|
|
814112f541 | ||
|
|
717239f56b | ||
|
|
9681f77e20 | ||
|
|
f488cc3643 | ||
|
|
6832db788a | ||
|
|
99a5505d16 | ||
|
|
76a849e376 | ||
|
|
74b65430be | ||
|
|
848f6277cb | ||
|
|
bd49f1884a | ||
|
|
eb69bd51a6 | ||
|
|
b30f49c9f4 | ||
|
|
29a8904e03 | ||
|
|
1674bd97ba | ||
|
|
ae8582bf94 | ||
|
|
b21381062f | ||
|
|
ecd92417e4 | ||
|
|
75762c1263 | ||
|
|
e03d519371 | ||
|
|
4137f91ccb | ||
|
|
e075bd5d8c | ||
|
|
a24432f8ce | ||
|
|
252ceb8053 | ||
|
|
199ecd26dd | ||
|
|
23854a9666 | ||
|
|
b05fb02e67 | ||
|
|
e663d07b48 | ||
|
|
3fbd606b82 | ||
|
|
305210cb96 | ||
|
|
c8d83f45ac | ||
|
|
062566f966 | ||
|
|
66a41306d6 | ||
|
|
75db218888 | ||
|
|
bcecca7ab7 | ||
|
|
6d337818d0 | ||
|
|
dfbe11deb4 | ||
|
|
895b74ca3f | ||
|
|
b2a1080fcd | ||
|
|
43eef7fa92 | ||
|
|
e7eff4ca93 | ||
|
|
b53961201f | ||
|
|
9c592c9282 | ||
|
|
6b1815c666 | ||
|
|
29584d3ba9 | ||
|
|
68bbde0d16 | ||
|
|
d51d6ed0d7 | ||
|
|
b61d0e68ed | ||
|
|
c73d147938 | ||
|
|
489dc8ac67 | ||
|
|
195306f251 | ||
|
|
4946d73183 | ||
|
|
e1c4a94e02 | ||
|
|
80c611aec8 | ||
|
|
d86106ac00 | ||
|
|
77058594ec | ||
|
|
fb3860edaa | ||
|
|
06ff6351a2 | ||
|
|
bf79ec2c62 | ||
|
|
8672693c33 | ||
|
|
17c1c8c56d | ||
|
|
0c38d3eb26 | ||
|
|
7743097b51 | ||
|
|
6d21f904b3 | ||
|
|
1da82c1b3b | ||
|
|
84c89105b4 | ||
|
|
f7716ae448 | ||
|
|
e35fa316fc | ||
|
|
4bea1b93cc | ||
|
|
7089b3ca5b | ||
|
|
add4196dc5 | ||
|
|
78ec7f9822 | ||
|
|
4dbf054334 | ||
|
|
b2aab804a2 | ||
|
|
e75fb996c2 | ||
|
|
941831728c | ||
|
|
3c57f7d99b | ||
|
|
a57ea34f1b | ||
|
|
e357700d60 | ||
|
|
109bbef3cf | ||
|
|
c01c985c91 | ||
|
|
2ebb5db055 | ||
|
|
4325c77b4c | ||
|
|
d6b88d407c | ||
|
|
71f7b7b3c0 | ||
|
|
e176f6db14 | ||
|
|
68dd3fd01a | ||
|
|
ee19749009 | ||
|
|
c506d60e66 | ||
|
|
e20e46aebe | ||
|
|
a23e23a697 | ||
|
|
73068f6544 | ||
|
|
fd708ec8bd | ||
|
|
35f51e1e39 | ||
|
|
348c429855 | ||
|
|
bcf104e085 | ||
|
|
9f8c70c8a8 | ||
|
|
0ea9528b70 | ||
|
|
4997e098ba | ||
|
|
d8d79f7b7d | ||
|
|
a45bc89b33 | ||
|
|
6bdb133cea | ||
|
|
db605f24fc | ||
|
|
f005a4f4cd | ||
|
|
cb3eedfcb8 | ||
|
|
d030a8722d | ||
|
|
a8f8cd0fd3 | ||
|
|
defcbbca5c | ||
|
|
dbfd25f481 | ||
|
|
eb41bae23b | ||
|
|
4a192586fc | ||
|
|
6172cadca8 | ||
|
|
b5ee701530 | ||
|
|
58732ce653 | ||
|
|
294dd75b48 | ||
|
|
f56c585818 | ||
|
|
ef96819621 | ||
|
|
5e0c5b6e69 | ||
|
|
8cfee2f5e5 | ||
|
|
b5f6658b72 | ||
|
|
6b93a59616 | ||
|
|
e27ddbd16e | ||
|
|
5c1d30b454 | ||
|
|
45484ffff2 | ||
|
|
73161d0cc2 | ||
|
|
578a8a559f | ||
|
|
470adeb519 | ||
|
|
58e0925c5b | ||
|
|
0a064bdbb8 | ||
|
|
d6f0d30f9a | ||
|
|
f00600a83c | ||
|
|
b1871591c0 | ||
|
|
10b7c5c851 | ||
|
|
cccd66bb21 | ||
|
|
866cdb4661 | ||
|
|
2e4090460d | ||
|
|
f2ed0caced | ||
|
|
6993015ce1 | ||
|
|
e3e373d579 | ||
|
|
be7f8677b0 | ||
|
|
dab2ead5fd | ||
|
|
1260b8fb4e | ||
|
|
46aa654ee3 |
@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.gitignore
|
||||
@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'@electron-toolkit/eslint-config-ts/recommended',
|
||||
'@electron-toolkit/eslint-config-prettier'
|
||||
]
|
||||
}
|
||||
6
.github/ISSUE_TEMPLATE/bug_report_zh.yml
vendored
@ -1,5 +1,5 @@
|
||||
name: 错误反馈
|
||||
description: '提交 mihomo-party 漏洞'
|
||||
description: '提交 clash-party 漏洞'
|
||||
title: '[Bug] '
|
||||
body:
|
||||
- type: checkboxes
|
||||
@ -10,7 +10,7 @@ body:
|
||||
options:
|
||||
- label: 我已在标题简短的描述了我所遇到的问题
|
||||
- label: 我已在 [Issue Tracker](./?q=is%3Aissue) 中寻找过我要提出的问题,但未找到相同的问题
|
||||
- label: 我已在 [常见问题](https://mihomo.party/docs/issues/common) 中寻找过我要提出的问题,并没有找到答案
|
||||
- label: 我已在 [常见问题](https://clashparty.org/docs/issues/common) 中寻找过我要提出的问题,并没有找到答案
|
||||
- label: 这是 GUI 程序的问题,而不是内核程序的问题
|
||||
- label: 我已经关闭所有杀毒软件/代理软件后测试过,问题依旧存在
|
||||
- label: 我已经使用最新的测试版本测试过,问题依旧存在
|
||||
@ -34,7 +34,7 @@ body:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: 发生问题 mihomo-party 版本
|
||||
label: 发生问题 clash-party 版本
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -2,7 +2,7 @@ blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: '常见问题'
|
||||
about: '提出问题前请先查看常见问题'
|
||||
url: 'https://mihomo.party/docs/issues/common'
|
||||
url: 'https://clashparty.org/docs/issues/common'
|
||||
- name: '交流群组'
|
||||
about: '提问/讨论性质的问题请勿提交issue'
|
||||
url: 'https://t.me/mihomo_party_group'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
name: 功能请求
|
||||
description: '请求 mihomo-party 功能'
|
||||
description: '请求 clash-party 功能'
|
||||
title: '[Feature] '
|
||||
body:
|
||||
- type: checkboxes
|
||||
|
||||
329
.github/workflows/build.yml
vendored
@ -12,7 +12,86 @@ on:
|
||||
permissions: write-all
|
||||
|
||||
jobs:
|
||||
cleanup-dev-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delete Dev Release Assets
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Get release ID for dev tag
|
||||
echo "🔍 Looking for existing dev release..."
|
||||
RELEASE_ID=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/tags/dev" | \
|
||||
jq -r '.id // empty')
|
||||
|
||||
if [ ! -z "$RELEASE_ID" ] && [ "$RELEASE_ID" != "empty" ]; then
|
||||
echo "✅ Found dev release with ID: $RELEASE_ID"
|
||||
|
||||
echo "📋 Getting list of assets with pagination..."
|
||||
ALL_ASSETS="[]"
|
||||
PAGE=1
|
||||
PER_PAGE=100
|
||||
|
||||
while true; do
|
||||
echo "📄 Fetching page $PAGE..."
|
||||
ASSETS_PAGE=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID/assets?page=$PAGE&per_page=$PER_PAGE")
|
||||
|
||||
PAGE_COUNT=$(echo "$ASSETS_PAGE" | jq '. | length')
|
||||
echo "📦 Found $PAGE_COUNT assets on page $PAGE"
|
||||
|
||||
if [ "$PAGE_COUNT" -eq 0 ]; then
|
||||
echo "📋 No more assets found, stopping pagination"
|
||||
break
|
||||
fi
|
||||
|
||||
ALL_ASSETS=$(echo "$ALL_ASSETS" "$ASSETS_PAGE" | jq -s '.[0] + .[1]')
|
||||
|
||||
if [ "$PAGE_COUNT" -lt "$PER_PAGE" ]; then
|
||||
echo "📋 Last page reached (got $PAGE_COUNT < $PER_PAGE), stopping pagination"
|
||||
break
|
||||
fi
|
||||
|
||||
PAGE=$((PAGE + 1))
|
||||
done
|
||||
|
||||
TOTAL_ASSET_COUNT=$(echo "$ALL_ASSETS" | jq '. | length')
|
||||
echo "📦 Total assets found across all pages: $TOTAL_ASSET_COUNT"
|
||||
|
||||
if [ "$TOTAL_ASSET_COUNT" -gt 0 ]; then
|
||||
# Delete each asset with detailed logging
|
||||
echo "$ALL_ASSETS" | jq -r '.[].id' | while read asset_id; do
|
||||
if [ ! -z "$asset_id" ]; then
|
||||
echo "🗑️ Deleting asset ID: $asset_id"
|
||||
RESPONSE=$(curl -s -w "HTTPSTATUS:%{http_code}" -X DELETE \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/assets/$asset_id")
|
||||
|
||||
HTTP_CODE=$(echo $RESPONSE | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
if [ "$HTTP_CODE" = "204" ]; then
|
||||
echo "✅ Successfully deleted asset $asset_id"
|
||||
else
|
||||
echo "❌ Failed to delete asset $asset_id (HTTP: $HTTP_CODE)"
|
||||
echo "Response: $(echo $RESPONSE | sed -e 's/HTTPSTATUS:.*//')"
|
||||
fi
|
||||
# Add small delay to avoid rate limiting
|
||||
sleep 0.5
|
||||
fi
|
||||
done
|
||||
echo "🎉 Finished deleting all $TOTAL_ASSET_COUNT assets"
|
||||
else
|
||||
echo "ℹ️ No assets found to delete"
|
||||
fi
|
||||
else
|
||||
echo "ℹ️ No existing dev release found"
|
||||
fi
|
||||
- name: Skip for Tag Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: echo "Skipping cleanup for tag release"
|
||||
windows:
|
||||
needs: [cleanup-dev-release]
|
||||
if: always() && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@ -23,17 +102,26 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup pnpm
|
||||
run: npm install -g 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 @mihomo-party/sysproxy-win32-${{ matrix.arch }}-msvc
|
||||
pnpm prepare --${{ 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 }}
|
||||
@ -47,9 +135,11 @@ jobs:
|
||||
}
|
||||
- name: Generate checksums
|
||||
run: pnpm checksum setup.exe portable.7z
|
||||
- name: Copy Legacy Artifacts
|
||||
run: pnpm copy-legacy
|
||||
- name: Upload Artifacts
|
||||
if: startsWith(github.ref, 'refs/heads/')
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
path: |
|
||||
@ -65,10 +155,23 @@ jobs:
|
||||
dist/*.sha256
|
||||
dist/*setup.exe
|
||||
dist/*portable.7z
|
||||
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: |
|
||||
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:
|
||||
@ -78,23 +181,38 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup pnpm
|
||||
run: npm install -g 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 @mihomo-party/sysproxy-win32-${{ matrix.arch }}-msvc
|
||||
pnpm add -D electron@22.3.27
|
||||
(Get-Content electron-builder.yml) -replace 'windows', 'win7' | Set-Content electron-builder.yml
|
||||
pnpm prepare --${{ matrix.arch }}
|
||||
# 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: |
|
||||
@ -104,9 +222,11 @@ jobs:
|
||||
}
|
||||
- name: Generate checksums
|
||||
run: pnpm checksum setup.exe portable.7z
|
||||
- name: Copy Legacy Artifacts
|
||||
run: pnpm copy-legacy
|
||||
- name: Upload Artifacts
|
||||
if: startsWith(github.ref, 'refs/heads/')
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Win7 ${{ matrix.arch }}
|
||||
path: |
|
||||
@ -122,10 +242,23 @@ jobs:
|
||||
dist/*.sha256
|
||||
dist/*setup.exe
|
||||
dist/*portable.7z
|
||||
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: |
|
||||
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:
|
||||
@ -135,18 +268,28 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup pnpm
|
||||
run: npm install -g 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 @mihomo-party/sysproxy-linux-${{ matrix.arch }}-gnu
|
||||
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
|
||||
pnpm prepare --${{ matrix.arch }}
|
||||
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 }}
|
||||
@ -154,9 +297,11 @@ jobs:
|
||||
run: pnpm build:linux --${{ matrix.arch }}
|
||||
- name: Generate checksums
|
||||
run: pnpm checksum .deb .rpm
|
||||
- name: Copy Legacy Artifacts
|
||||
run: pnpm copy-legacy
|
||||
- name: Upload Artifacts
|
||||
if: startsWith(github.ref, 'refs/heads/')
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
path: |
|
||||
@ -172,10 +317,23 @@ jobs:
|
||||
dist/*.sha256
|
||||
dist/*.deb
|
||||
dist/*.rpm
|
||||
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: |
|
||||
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:
|
||||
@ -185,17 +343,26 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup pnpm
|
||||
run: npm install -g 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 @mihomo-party/sysproxy-darwin-${{ matrix.arch }}
|
||||
pnpm prepare --${{ 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 }}
|
||||
@ -209,7 +376,7 @@ jobs:
|
||||
chmod +x build/pkg-scripts/postinstall build/pkg-scripts/preinstall
|
||||
pnpm build:mac --${{ matrix.arch }}
|
||||
- name: Setup temporary installer signing keychain
|
||||
uses: apple-actions/import-codesign-certs@v3
|
||||
uses: apple-actions/import-codesign-certs@v6
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.CSC_INSTALLER_LINK }}
|
||||
p12-password: ${{ secrets.CSC_INSTALLER_KEY_PASSWORD }}
|
||||
@ -229,9 +396,11 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
- name: Generate checksums
|
||||
run: pnpm checksum .pkg
|
||||
- name: Copy Legacy Artifacts
|
||||
run: pnpm copy-legacy
|
||||
- name: Upload Artifacts
|
||||
if: startsWith(github.ref, 'refs/heads/')
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: MacOS ${{ matrix.arch }}
|
||||
path: |
|
||||
@ -245,10 +414,22 @@ jobs:
|
||||
files: |
|
||||
dist/*.sha256
|
||||
dist/*.pkg
|
||||
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: |
|
||||
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:
|
||||
@ -258,18 +439,28 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup pnpm
|
||||
run: npm install -g 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 @mihomo-party/sysproxy-darwin-${{ matrix.arch }}
|
||||
pnpm add -D electron@32.2.2
|
||||
pnpm prepare --${{ matrix.arch }}
|
||||
- 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 }}
|
||||
@ -284,7 +475,7 @@ jobs:
|
||||
chmod +x build/pkg-scripts/postinstall build/pkg-scripts/preinstall
|
||||
pnpm build:mac --${{ matrix.arch }}
|
||||
- name: Setup temporary installer signing keychain
|
||||
uses: apple-actions/import-codesign-certs@v3
|
||||
uses: apple-actions/import-codesign-certs@v6
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.CSC_INSTALLER_LINK }}
|
||||
p12-password: ${{ secrets.CSC_INSTALLER_KEY_PASSWORD }}
|
||||
@ -304,9 +495,11 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
- name: Generate checksums
|
||||
run: pnpm checksum .pkg
|
||||
- name: Copy Legacy Artifacts
|
||||
run: pnpm copy-legacy
|
||||
- name: Upload Artifacts
|
||||
if: startsWith(github.ref, 'refs/heads/')
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Catalina ${{ matrix.arch }}
|
||||
path: |
|
||||
@ -320,32 +513,72 @@ jobs:
|
||||
files: |
|
||||
dist/*.sha256
|
||||
dist/*.pkg
|
||||
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: |
|
||||
dist/*.sha256
|
||||
dist/*.pkg
|
||||
prerelease: true
|
||||
draft: false
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
updater:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [windows, macos, windows7, macos10]
|
||||
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@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup pnpm
|
||||
run: npm install -g 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:
|
||||
@ -357,25 +590,25 @@ jobs:
|
||||
- mihomo-party-bin
|
||||
- mihomo-party
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: linux
|
||||
needs: updater
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
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/mihomo-party-org/mihomo-party/archive/refs/tags/$(echo ${{ github.ref }} | tr -d 'refs/tags/').tar.gz -O release.tar.gz
|
||||
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/mihomo-party-org/mihomo-party/releases/download/$(echo ${{ github.ref }} | tr -d 'refs/tags/')/mihomo-party-linux-$(echo ${{ github.ref }} | tr -d 'refs/tags/v')-amd64.deb -O amd64.deb
|
||||
wget https://github.com/mihomo-party-org/mihomo-party/releases/download/$(echo ${{ github.ref }} | tr -d 'refs/tags/')/mihomo-party-linux-$(echo ${{ github.ref }} | tr -d 'refs/tags/v')-arm64.deb -O arm64.deb
|
||||
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
|
||||
@ -396,7 +629,7 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/heads/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: update version
|
||||
@ -428,5 +661,5 @@ jobs:
|
||||
identifier: Mihomo-Party.Mihomo-Party
|
||||
version: ${{env.VERSION}}
|
||||
release-tag: v${{env.VERSION}}
|
||||
installers-regex: 'mihomo-party-windows-.*setup\.exe$'
|
||||
installers-regex: 'clash-party-windows-.*setup\.exe$'
|
||||
token: ${{ secrets.POMPURIN404_TOKEN }}
|
||||
|
||||
3
.gitignore
vendored
@ -8,3 +8,6 @@ out
|
||||
*.log*
|
||||
.idea
|
||||
*.ttf
|
||||
party.md
|
||||
CLAUDE.md
|
||||
tsconfig.node.tsbuildinfo
|
||||
|
||||
5
.npmrc
@ -1,3 +1,6 @@
|
||||
shamefully-hoist=true
|
||||
virtual-store-dir-max-length=80
|
||||
public-hoist-pattern[]=*@heroui/*
|
||||
public-hoist-pattern[]=*@heroui/*
|
||||
only-built-dependencies[]=electron
|
||||
only-built-dependencies[]=esbuild
|
||||
only-built-dependencies[]=meta-json-schema
|
||||
|
||||
6
.vscode/extensions.json
vendored
@ -1,3 +1,7 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint"]
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
|
||||
3
.vscode/settings.json
vendored
@ -7,5 +7,6 @@
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
},
|
||||
"terminal.integrated.defaultProfile.windows": "PowerShell"
|
||||
}
|
||||
|
||||
18
README.md
@ -6,8 +6,8 @@
|
||||
<h3 align="center">Another <a href="https://github.com/MetaCubeX/mihomo">Mihomo</a> GUI</h3>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/mihomo-party-org/mihomo-party/releases">
|
||||
<img src="https://img.shields.io/github/release/mihomo-party-org/mihomo-party/all.svg">
|
||||
<a href="https://github.com/mihomo-party-org/clash-party/releases">
|
||||
<img src="https://img.shields.io/github/release/mihomo-party-org/clash-party/all.svg">
|
||||
</a>
|
||||
<a href="https://t.me/mihomo_party_group">
|
||||
<img src="https://img.shields.io/badge/Telegram-Group-blue?logo=telegram">
|
||||
@ -17,25 +17,27 @@
|
||||
<img width='90%' src="./images/preview.jpg">
|
||||
</div>
|
||||
|
||||
### 本项目由“[狗狗加速](https://party.dginv.click/#/register?code=ARdo0mXx)”赞助
|
||||
### 本项目认证稳定机场推荐:“[狗狗加速](https://party.dginv.click/#/register?code=ARdo0mXx)”
|
||||
|
||||
##### [狗狗加速 —— 技术流机场 Doggygo VPN](https://party.dginv.click/#/register?code=ARdo0mXx)
|
||||
|
||||
- 高性能海外机场,稳定首选,海外团队,无跑路风险
|
||||
- Mihomo Party专属8折优惠码:party,仅有500份
|
||||
- Clash Party专属8折优惠码:party,仅有500份
|
||||
- Party专属链接注册送 3 天,每天 1G 流量 [免费试用](https://party.dginv.click/#/register?code=ARdo0mXx)
|
||||
- 优惠套餐每月仅需 15.8 元,160G 流量,年付 8 折
|
||||
- 全球首家支持Hysteria1/2 协议,集群负载均衡设计,高速专线,基于最新UDP quic技术,极低延迟,无视晚高峰,4K 秒开,配合Mihomo Party食用更省心!
|
||||
- 全球首家支持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 常用配置修改
|
||||
- [x] 内置稳定版和预览版 Mihomo 内核
|
||||
- [x] 支持大部分 Mihomo(Clash Meta) 常用配置修改
|
||||
- [x] 内置 Smart内核 与 Mihomo(Clash Meta) 内核
|
||||
- [x] 通过 WebDAV 一键备份和恢复配置
|
||||
- [x] 强大的覆写功能,任意修订配置文件
|
||||
- [x] 深度集成 Sub-Store,轻松管理订阅
|
||||
|
||||
### 安装/使用指南见 [官方文档](https://mihomo.party)
|
||||
### 安装/使用指南见 [官方文档](https://clashparty.org)
|
||||
|
||||
@ -12,19 +12,20 @@ depends=('gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core'
|
||||
optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).')
|
||||
install=$_pkgname.install
|
||||
source=("${_pkgname}.sh")
|
||||
source_x86_64=("${_pkgname}-${pkgver}-x86_64.deb::${url}/releases/download/v${pkgver}/mihomo-party-linux-${pkgver}-amd64.deb")
|
||||
source_aarch64=("${_pkgname}-${pkgver}-aarch64.deb::${url}/releases/download/v${pkgver}/mihomo-party-linux-${pkgver}-arm64.deb")
|
||||
sha256sums=('f8049c1f26d5a92fbcebd7bebbdedbb3eab53422b21cf6127418251ccd061282')
|
||||
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/mihomo-party/mihomo-party
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
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/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
|
||||
sed -i '3s!/opt/clash-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
|
||||
|
||||
chown -R root:root ${pkgdir}
|
||||
}
|
||||
|
||||
@ -9,4 +9,4 @@ if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
|
||||
fi
|
||||
|
||||
# Launch
|
||||
exec /opt/mihomo-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
|
||||
exec /opt/clash-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
|
||||
|
||||
@ -12,11 +12,11 @@ optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayata
|
||||
makedepends=('asar')
|
||||
install=$_pkgname.install
|
||||
source=("${_pkgname}.desktop" "${_pkgname}.sh")
|
||||
source_x86_64=("${_pkgname}-${pkgver}-x86_64.deb::${url}/releases/download/v${pkgver}/mihomo-party-linux-${pkgver}-amd64.deb")
|
||||
source_aarch64=("${_pkgname}-${pkgver}-aarch64.deb::${url}/releases/download/v${pkgver}/mihomo-party-linux-${pkgver}-arm64.deb")
|
||||
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"
|
||||
"560733f0e5bd9b47ff50c849301c8a22ae17a5df26830d8c97033dfcbd392382"
|
||||
"87fddbcd4a4cc7bda22ec4cadff0040e54395bb13184ee4688b58788c1fa7180"
|
||||
)
|
||||
sha256sums_x86_64=("43f8b9a5818a722cdb8e5044d2a90993274860b0da96961e1a2652169539ce39")
|
||||
sha256sums_aarch64=("18574fdeb01877a629aa52ac0175335ce27c83103db4fcb2f1ad69e3e42ee10f")
|
||||
@ -24,14 +24,15 @@ options=('!lto')
|
||||
|
||||
package() {
|
||||
bsdtar -xf data.tar.xz -C $srcdir
|
||||
asar extract $srcdir/opt/mihomo-party/resources/app.asar ${pkgdir}/opt/mihomo-party
|
||||
cp -r $srcdir/opt/mihomo-party/resources/sidecar ${pkgdir}/opt/mihomo-party/resources/
|
||||
cp -r $srcdir/opt/mihomo-party/resources/files ${pkgdir}/opt/mihomo-party/resources/
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
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/mihomo-party/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png"
|
||||
install -Dm644 "${pkgdir}/opt/clash-party/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png"
|
||||
|
||||
chown -R root:root ${pkgdir}
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Name=Mihomo Party
|
||||
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=Mihomo Party
|
||||
Comment=Clash Party
|
||||
Categories=Utility;
|
||||
|
||||
@ -9,4 +9,4 @@ if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
|
||||
fi
|
||||
|
||||
# Launch
|
||||
exec electron /opt/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
|
||||
exec electron /opt/clash-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
|
||||
|
||||
@ -18,13 +18,13 @@ source=(
|
||||
)
|
||||
sha256sums=("52d761e9432e17477acb8adb5744676df946476e0eb5210fee2b6d45f497f218"
|
||||
"96a6250f67517493f839f964c024434dbcf784b25a73f074bb505f1521f52844"
|
||||
"560733f0e5bd9b47ff50c849301c8a22ae17a5df26830d8c97033dfcbd392382"
|
||||
"87fddbcd4a4cc7bda22ec4cadff0040e54395bb13184ee4688b58788c1fa7180"
|
||||
)
|
||||
options=('!lto')
|
||||
|
||||
prepare(){
|
||||
cd $srcdir/${_pkgname}-${pkgver}
|
||||
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
|
||||
cd $srcdir/clash-party-${pkgver}
|
||||
sed -i "s/productName: Clash Party/productName: clash-party/" electron-builder.yml
|
||||
pnpm install
|
||||
}
|
||||
|
||||
@ -37,11 +37,12 @@ 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/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
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/mihomo-party/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png"
|
||||
install -Dm644 "${pkgdir}/opt/clash-party/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png"
|
||||
|
||||
chown -R root:root ${pkgdir}
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Name=Mihomo Party
|
||||
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=Mihomo Party
|
||||
Comment=Clash Party
|
||||
Categories=Utility;
|
||||
|
||||
@ -9,4 +9,4 @@ if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
|
||||
fi
|
||||
|
||||
# Launch
|
||||
exec electron /opt/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
|
||||
exec electron /opt/clash-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
|
||||
|
||||
@ -12,7 +12,7 @@ optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayata
|
||||
makedepends=('nodejs' 'pnpm' 'jq' 'libxcrypt-compat')
|
||||
install=$_pkgname.install
|
||||
source=("${_pkgname}.sh" "git+$url.git")
|
||||
sha256sums=("f8049c1f26d5a92fbcebd7bebbdedbb3eab53422b21cf6127418251ccd061282" "SKIP")
|
||||
sha256sums=("242609b1259e999e944b7187f4c03aacba8134a7671ff3a50e2e246b4c4eff48" "SKIP")
|
||||
options=('!lto')
|
||||
|
||||
pkgver() {
|
||||
@ -25,7 +25,7 @@ pkgver() {
|
||||
|
||||
prepare(){
|
||||
cd $srcdir/${_pkgname}
|
||||
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
|
||||
sed -i "s/productName: Clash Party/productName: clash-party/" electron-builder.yml
|
||||
pnpm install
|
||||
}
|
||||
|
||||
@ -36,13 +36,14 @@ build(){
|
||||
|
||||
package() {
|
||||
cd $srcdir/${_pkgname}/dist
|
||||
bsdtar -xf mihomo-party-linux-$(jq '.version' $srcdir/${_pkgname}/package.json | tr -d 'v"')*.deb
|
||||
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/mihomo-party/mihomo-party
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
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/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
|
||||
sed -i '3s!/opt/clash-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
|
||||
|
||||
chown -R root:root ${pkgdir}
|
||||
}
|
||||
|
||||
@ -9,4 +9,4 @@ if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
|
||||
fi
|
||||
|
||||
# Launch
|
||||
exec /opt/mihomo-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
|
||||
exec /opt/clash-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
|
||||
|
||||
@ -15,29 +15,30 @@ source=(
|
||||
"${pkgname}.sh"
|
||||
)
|
||||
sha256sums=("52d761e9432e17477acb8adb5744676df946476e0eb5210fee2b6d45f497f218"
|
||||
"f8049c1f26d5a92fbcebd7bebbdedbb3eab53422b21cf6127418251ccd061282")
|
||||
"242609b1259e999e944b7187f4c03aacba8134a7671ff3a50e2e246b4c4eff48")
|
||||
options=('!lto')
|
||||
|
||||
prepare(){
|
||||
cd $srcdir/${pkgname}-${pkgver}
|
||||
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
|
||||
cd $srcdir/clash-party-${pkgver}
|
||||
sed -i "s/productName: Clash Party/productName: clash-party/" electron-builder.yml
|
||||
pnpm install
|
||||
}
|
||||
|
||||
build(){
|
||||
cd $srcdir/${pkgname}-${pkgver}
|
||||
cd $srcdir/clash-party-${pkgver}
|
||||
pnpm build:linux deb
|
||||
}
|
||||
|
||||
package() {
|
||||
cd $srcdir/${pkgname}-${pkgver}/dist
|
||||
bsdtar -xf mihomo-party-linux-${pkgver}*.deb
|
||||
cd $srcdir/clash-party-${pkgver}/dist
|
||||
bsdtar -xf clash-party-linux-${pkgver}*.deb
|
||||
bsdtar -xf data.tar.xz -C "${pkgdir}/"
|
||||
chmod +x ${pkgdir}/opt/mihomo-party/mihomo-party
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
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/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${pkgname}.desktop"
|
||||
sed -i '3s!/opt/clash-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${pkgname}.desktop"
|
||||
|
||||
chown -R root:root ${pkgdir}
|
||||
}
|
||||
|
||||
@ -9,4 +9,4 @@ if [[ -f "${XDG_CONFIG_HOME}/mihomo-party-flags.conf" ]]; then
|
||||
fi
|
||||
|
||||
# Launch
|
||||
exec /opt/mihomo-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
|
||||
exec /opt/clash-party/mihomo-party ${MIHOMO_PARTY_USER_FLAGS[@]} "$@"
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 19 KiB |
@ -8,5 +8,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
BIN
build/icon.icns
BIN
build/icon.ico
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 76 KiB |
BIN
build/icon.png
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 204 KiB |
@ -1,18 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
if type update-alternatives 2>/dev/null >&1; then
|
||||
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/mihomo-party' -a -e '/usr/bin/mihomo-party' -a "`readlink '/usr/bin/mihomo-party'`" != '/etc/alternatives/mihomo-party' ]; then
|
||||
rm -f '/usr/bin/mihomo-party'
|
||||
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/mihomo-party' 'mihomo-party' '/opt/mihomo-party/mihomo-party' 100 || ln -sf '/opt/mihomo-party/mihomo-party' '/usr/bin/mihomo-party'
|
||||
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/mihomo-party/mihomo-party' '/usr/bin/mihomo-party'
|
||||
ln -sf '/opt/clash-party/mihomo-party' '/usr/bin/clash-party'
|
||||
fi
|
||||
|
||||
chmod 4755 '/opt/mihomo-party/chrome-sandbox' || true
|
||||
chmod +sx /opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +sx /opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
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
|
||||
@ -21,3 +25,15 @@ 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
|
||||
|
||||
29
build/linux/postuninst
Normal file
@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
case "$1" in
|
||||
remove|purge|0)
|
||||
if type update-alternatives >/dev/null 2>&1; then
|
||||
update-alternatives --remove 'clash-party' '/opt/clash-party/mihomo-party' 2>/dev/null || true
|
||||
fi
|
||||
|
||||
[ -L '/usr/bin/clash-party' ] && rm -f '/usr/bin/clash-party'
|
||||
|
||||
if hash update-mime-database 2>/dev/null; then
|
||||
update-mime-database /usr/share/mime || true
|
||||
fi
|
||||
|
||||
if hash update-desktop-database 2>/dev/null; then
|
||||
update-desktop-database /usr/share/applications || true
|
||||
fi
|
||||
|
||||
# Update icon cache
|
||||
if hash gtk-update-icon-cache 2>/dev/null; then
|
||||
for dir in /usr/share/icons/hicolor /usr/share/icons/gnome; do
|
||||
[ -d "$dir" ] && gtk-update-icon-cache -f -t "$dir" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# others
|
||||
;;
|
||||
esac
|
||||
19
build/pkg-scripts/postinstall
Normal file → Executable file
@ -1,4 +1,4 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# 设置日志文件
|
||||
@ -19,7 +19,7 @@ fi
|
||||
if [[ $2 == *".app" ]]; then
|
||||
APP_PATH="$2"
|
||||
else
|
||||
APP_PATH="$2/Mihomo Party.app"
|
||||
APP_PATH="$2/Clash Party.app"
|
||||
fi
|
||||
|
||||
HELPER_PATH="/Library/PrivilegedHelperTools/party.mihomo.helper"
|
||||
@ -51,6 +51,14 @@ 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
|
||||
@ -117,6 +125,13 @@ macos_version=$(sw_vers -productVersion)
|
||||
macos_major=$(echo "$macos_version" | cut -d. -f1)
|
||||
log "macOS version: $macos_version"
|
||||
|
||||
# 启用服务(防止安全软件禁用)
|
||||
if ! launchctl enable system/party.mihomo.helper 2>/dev/null; then
|
||||
log "Warning: Failed to enable service, continuing installation..."
|
||||
else
|
||||
log "Service enabled successfully"
|
||||
fi
|
||||
|
||||
# 清理现有服务
|
||||
log "Cleaning up existing services..."
|
||||
launchctl bootout system "$LAUNCH_DAEMON" 2>/dev/null || true
|
||||
|
||||
4
build/pkg-scripts/preinstall
Normal file → Executable file
@ -1,4 +1,4 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# 检查 root 权限
|
||||
@ -20,6 +20,8 @@ fi
|
||||
rm -f "$HELPER_PATH"
|
||||
|
||||
# 清理可能存在的旧版本文件
|
||||
rm -rf "/Applications/Clash Party.app"
|
||||
rm -rf "/Applications/Clash\\ Party.app"
|
||||
rm -rf "/Applications/Mihomo Party.app"
|
||||
rm -rf "/Applications/Mihomo\\ Party.app"
|
||||
|
||||
|
||||
76
changelog.md
@ -1,72 +1,16 @@
|
||||
## 1.7.7
|
||||
### 新功能 (Feat)
|
||||
- Mihomo 内核升级 v1.19.12
|
||||
- 新增 Webdav 最大备数设置和清理逻辑
|
||||
# 1.9.4
|
||||
|
||||
### 修复 (Fix)
|
||||
- 修复 MacOS 下无法启动的问题(重置工作目录权限)
|
||||
- 尝试修复不同版本 MacOS 下安装软件时候的报错(Input/output error)
|
||||
- 部分遗漏的多国语言翻译
|
||||
## 新功能 (Feat)
|
||||
|
||||
## 1.7.6
|
||||
- 新增每个订阅独立的 User-Agent 配置
|
||||
- 使用 mshta 在 Electron 初始化前同步检测 PowerShell 版本
|
||||
|
||||
**此版本修复了 1.7.5 中的几个严重 bug,推荐所有人更新**
|
||||
## 修复 (Fix)
|
||||
|
||||
### 修复 (Fix)
|
||||
- 修复了内核1.19.8更新后gist同步失效的问题(#780)
|
||||
- 部分遗漏的多国语言翻译
|
||||
- MacOS 下启动Error: EACCES: permission denied
|
||||
- MacOS 系统代理 bypass 不生效
|
||||
- MacOS 系统代理开启时 500 报错
|
||||
- 修复 Win7 兼容性问题
|
||||
- 修复日志清理正则表达式以正确匹配带前缀的文件名
|
||||
|
||||
## 1.7.5
|
||||
## 其他 (Chore)
|
||||
|
||||
### 新功能 (Feat)
|
||||
- 增加组延迟测试时的动画
|
||||
- 订阅卡片可右键点击
|
||||
-
|
||||
|
||||
### 修复 (Fix)
|
||||
- 1.7.4引入的内核启动错误
|
||||
- 无法手动设置内核权限
|
||||
- 完善 系统代理socket 重建和检测机制
|
||||
|
||||
## 1.7.4
|
||||
|
||||
### 新功能 (Feat)
|
||||
- Mihomo 内核升级 v1.19.10
|
||||
- 改进 socket创建机制,防止 MacOS 下系统代理开启无法找到 socket 文件的问题
|
||||
- mihomo-party-helper增加更多日志,以方便调试
|
||||
- 改进 MacOS 下签名和公正流程
|
||||
- 增加 MacOS 下 plist 权限设置
|
||||
- 改进安装流程
|
||||
-
|
||||
|
||||
### 修复 (Fix)
|
||||
- 修复mihomo-party-helper本地提权漏洞
|
||||
- 修复 MacOS 下安装失败的问题
|
||||
- 移除节点页面的滚动位置记忆,解决页面溢出的问题
|
||||
- DNS hosts 设置在 useHosts 不为 true 时也会被错误应用的问题(#742)
|
||||
- 当用户在 Profile 设置中修改了更新间隔并保存后,新的间隔时间不会立即生效(#671)
|
||||
- 禁止选择器组件选择空值
|
||||
- 修复proxy-provider
|
||||
|
||||
## 1.7.3
|
||||
**注意:如安装后为英文,请在设置中反复选择几次不同语言以写入配置文件**
|
||||
|
||||
### 新功能 (Feat)
|
||||
- Mihomo 内核升级 v1.19.5
|
||||
- MacOS 下添加 Dock 图标动态展现方式 (#594)
|
||||
- 更改默认 UA 并添加版本
|
||||
- 添加固定间隔的配置文件更新按钮 (#670)
|
||||
- 重构Linux上的手动授权内核方式
|
||||
- 将sub-store迁移到工作目录下(#552)
|
||||
- 重置软件增加警告提示
|
||||
|
||||
### 修复 (Fix)
|
||||
- 修复代理节点页面因为重复刷新导致的溢出问题
|
||||
- 修复由于 Mihomo 核心错误导致启动时窗口丢失 (#601)
|
||||
- 修复macOS下的sub-store更新问题 (#552)
|
||||
- 修复多语言翻译
|
||||
- 修复 defaultBypass 几乎总是 Windows 默认绕过设置 (#602)
|
||||
- 修复重置防火墙时发生的错误,因为没有指定防火墙规则 (#650)
|
||||
- 重构 rule-item 额外字段处理逻辑并补充类型定义
|
||||
- 更新依赖
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
appId: party.mihomo.app
|
||||
productName: Mihomo Party
|
||||
productName: Clash Party
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
@ -19,7 +19,7 @@ extraResources:
|
||||
- from: './extra/'
|
||||
to: ''
|
||||
protocols:
|
||||
name: 'Mihomo Party URI Scheme'
|
||||
name: 'Clash Party URI Scheme'
|
||||
schemes:
|
||||
- 'clash'
|
||||
- 'mihomo'
|
||||
@ -27,9 +27,9 @@ win:
|
||||
target:
|
||||
- nsis
|
||||
- 7z
|
||||
artifactName: ${name}-windows-${version}-${arch}-portable.${ext}
|
||||
artifactName: clash-party-windows-${version}-${arch}-portable.${ext}
|
||||
nsis:
|
||||
artifactName: ${name}-windows-${version}-${arch}-setup.${ext}
|
||||
artifactName: clash-party-windows-${version}-${arch}-setup.${ext}
|
||||
uninstallDisplayName: ${productName}
|
||||
allowToChangeInstallationDirectory: true
|
||||
oneClick: false
|
||||
@ -39,32 +39,48 @@ 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: true
|
||||
artifactName: ${name}-macos-${version}-${arch}.${ext}
|
||||
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:
|
||||
Name: Mihomo Party
|
||||
MimeType: 'x-scheme-handler/clash;x-scheme-handler/mihomo'
|
||||
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: ${name}-linux-${version}-${arch}.${ext}
|
||||
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: []
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
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 = (
|
||||
@ -14,12 +15,28 @@ 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', 'utf-8-validate', 'bufferutil']
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
plugins: isLegacyBuild ? [] : [externalizeDepsPlugin()],
|
||||
build: isLegacyBuild
|
||||
? { rollupOptions: { external: legacyExternal, output: { format: 'cjs' } } }
|
||||
: undefined
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
plugins: isLegacyBuild ? [] : [externalizeDepsPlugin()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: isLegacyBuild ? legacyExternal : undefined,
|
||||
output: {
|
||||
format: 'cjs',
|
||||
entryFileNames: '[name].cjs'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
build: {
|
||||
@ -37,6 +54,7 @@ export default defineConfig({
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
monacoEditorPlugin({
|
||||
languageWorkers: ['editorWorkerService', 'typescript', 'css'],
|
||||
customDistPath: (_, out) => `${out}/monacoeditorwork`,
|
||||
|
||||
80
eslint.config.cjs
Normal file
@ -0,0 +1,80 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
]
|
||||
BIN
extra/sidecar/sysproxy.win32-x64-msvc.node
Normal file
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 37 KiB |
156
package.json
@ -1,98 +1,120 @@
|
||||
{
|
||||
"name": "mihomo-party",
|
||||
"version": "1.7.7",
|
||||
"description": "Mihomo Party",
|
||||
"version": "1.9.4",
|
||||
"description": "Clash Party",
|
||||
"type": "module",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "mihomo-party-org",
|
||||
"homepage": "https://mihomo.party",
|
||||
"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": "npm run typecheck:node && npm run typecheck:web",
|
||||
"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",
|
||||
"telegram": "node scripts/telegram.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",
|
||||
"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:linux": "electron-vite build && electron-builder --publish never --linux"
|
||||
"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/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@heroui/react": "^2.6.14",
|
||||
"@mihomo-party/sysproxy": "^2.0.7",
|
||||
"@mihomo-party/sysproxy-darwin-arm64": "^2.0.7",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@types/plist": "^3.0.5",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.7.7",
|
||||
"chokidar": "^4.0.1",
|
||||
"axios": "^1.14.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"croner": "^9.1.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^5.0.1",
|
||||
"i18next": "^24.2.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"react-i18next": "^15.4.0",
|
||||
"webdav": "^5.7.1",
|
||||
"ws": "^8.18.0",
|
||||
"yaml": "^2.6.0"
|
||||
"express": "^5.2.1",
|
||||
"file-icon": "^6.0.0",
|
||||
"file-icon-info": "^1.1.1",
|
||||
"flag-icons": "^7.5.0",
|
||||
"i18next": "^25.10.10",
|
||||
"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.20.0",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^2.0.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@types/adm-zip": "^0.5.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.13.1",
|
||||
"@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.10",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/adm-zip": "^0.5.8",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/pubsub-js": "^1.8.6",
|
||||
"@types/react": "^19.0.4",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cron-validator": "^1.3.1",
|
||||
"driver.js": "^1.3.5",
|
||||
"electron": "^34.0.2",
|
||||
"electron-builder": "25.1.8",
|
||||
"electron-vite": "^2.3.0",
|
||||
"@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.57.2",
|
||||
"@typescript-eslint/parser": "^8.57.2",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"cron-validator": "^1.4.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"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": "8.57.1",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"form-data": "^4.0.1",
|
||||
"framer-motion": "12.0.11",
|
||||
"lodash": "^4.17.21",
|
||||
"meta-json-schema": "^1.18.9",
|
||||
"monaco-yaml": "^5.2.3",
|
||||
"nanoid": "^5.0.8",
|
||||
"next-themes": "^0.4.3",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"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.21",
|
||||
"monaco-yaml": "^5.4.1",
|
||||
"nanoid": "^5.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.8",
|
||||
"prettier": "^3.8.1",
|
||||
"pubsub-js": "^1.9.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-monaco-editor": "^0.58.0",
|
||||
"react-router-dom": "^7.1.5",
|
||||
"react-virtuoso": "^4.12.0",
|
||||
"recharts": "^2.13.3",
|
||||
"swr": "^2.2.5",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tar": "^7.4.3",
|
||||
"tsx": "^4.19.2",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-i18next": "^16.6.6",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-monaco-editor": "^0.59.0",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"react-virtuoso": "^4.18.3",
|
||||
"swr": "^2.4.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tar": "^7.5.13",
|
||||
"tsx": "^4.21.0",
|
||||
"types-pac": "^1.0.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.7",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-monaco-editor": "^1.1.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c"
|
||||
"packageManager": "pnpm@10.27.0"
|
||||
}
|
||||
|
||||
11053
pnpm-lock.yaml
generated
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 1.6 KiB |
BIN
resources/icon_blue.ico
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
resources/icon_blue.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
resources/icon_green.ico
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
resources/icon_green.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
resources/icon_red.ico
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
resources/icon_red.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.2 KiB |
@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "=== Mihomo Party Cleanup Tool ==="
|
||||
echo "This script will remove all Mihomo Party related files and services."
|
||||
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
|
||||
@ -17,8 +17,8 @@ sudo launchctl unload /Library/LaunchDaemons/party.mihomo.helper.plist 2>/dev/nu
|
||||
echo "Removing files..."
|
||||
sudo rm -f /Library/LaunchDaemons/party.mihomo.helper.plist
|
||||
sudo rm -f /Library/PrivilegedHelperTools/party.mihomo.helper
|
||||
sudo rm -rf "/Applications/Mihomo Party.app"
|
||||
sudo rm -rf "/Applications/Mihomo\\ Party.app"
|
||||
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
|
||||
|
||||
66
scripts/copy-legacy-artifacts.mjs
Normal file
@ -0,0 +1,66 @@
|
||||
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 文件')
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import fs from 'fs'
|
||||
import AdmZip from 'adm-zip'
|
||||
import path from 'path'
|
||||
@ -45,6 +44,36 @@ async function getLatestAlphaVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ======= mihomo smart ======= */
|
||||
const MIHOMO_SMART_VERSION_URL =
|
||||
'https://github.com/vernesong/mihomo/releases/download/Prerelease-Alpha/version.txt'
|
||||
const MIHOMO_SMART_URL_PREFIX = `https://github.com/vernesong/mihomo/releases/download/Prerelease-Alpha`
|
||||
let MIHOMO_SMART_VERSION
|
||||
|
||||
const MIHOMO_SMART_MAP = {
|
||||
'win32-x64': 'mihomo-windows-amd64-v2-go120',
|
||||
'win32-ia32': 'mihomo-windows-386-go120',
|
||||
'win32-arm64': 'mihomo-windows-arm64',
|
||||
'darwin-x64': 'mihomo-darwin-amd64-v2-go120',
|
||||
'darwin-arm64': 'mihomo-darwin-arm64',
|
||||
'linux-x64': 'mihomo-linux-amd64-v2-go120',
|
||||
'linux-arm64': 'mihomo-linux-arm64'
|
||||
}
|
||||
|
||||
async function getLatestSmartVersion() {
|
||||
try {
|
||||
const response = await fetch(MIHOMO_SMART_VERSION_URL, {
|
||||
method: 'GET'
|
||||
})
|
||||
let v = await response.text()
|
||||
MIHOMO_SMART_VERSION = v.trim() // Trim to remove extra whitespaces
|
||||
console.log(`Latest smart version: ${MIHOMO_SMART_VERSION}`)
|
||||
} catch (error) {
|
||||
console.error('Error fetching latest smart version:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/* ======= mihomo release ======= */
|
||||
const MIHOMO_VERSION_URL =
|
||||
'https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt'
|
||||
@ -87,6 +116,10 @@ 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
|
||||
*/
|
||||
@ -123,6 +156,23 @@ function mihomo() {
|
||||
downloadURL
|
||||
}
|
||||
}
|
||||
|
||||
function mihomoSmart() {
|
||||
const name = MIHOMO_SMART_MAP[`${platform}-${arch}`]
|
||||
const isWin = platform === 'win32'
|
||||
const urlExt = isWin ? 'zip' : 'gz'
|
||||
const downloadURL = `${MIHOMO_SMART_URL_PREFIX}/${name}-${MIHOMO_SMART_VERSION}.${urlExt}`
|
||||
const exeFile = `${name}${isWin ? '.exe' : ''}`
|
||||
const zipFile = `${name}-${MIHOMO_SMART_VERSION}.${urlExt}`
|
||||
|
||||
return {
|
||||
name: 'mihomo-smart',
|
||||
targetFile: `mihomo-smart${isWin ? '.exe' : ''}`,
|
||||
exeFile,
|
||||
zipFile,
|
||||
downloadURL
|
||||
}
|
||||
}
|
||||
/**
|
||||
* download sidecar and rename
|
||||
*/
|
||||
@ -254,7 +304,7 @@ const resolveGeosite = () =>
|
||||
const resolveGeoIP = () =>
|
||||
resolveResource({
|
||||
file: 'geoip.dat',
|
||||
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip-lite.dat`
|
||||
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat`
|
||||
})
|
||||
const resolveASN = () =>
|
||||
resolveResource({
|
||||
@ -266,16 +316,72 @@ const resolveEnableLoopback = () =>
|
||||
file: 'enableLoopback.exe',
|
||||
downloadURL: `https://github.com/Kuingsmile/uwp-tool/releases/download/latest/enableLoopback.exe`
|
||||
})
|
||||
const resolveSysproxy = () =>
|
||||
resolveResource({
|
||||
file: 'sysproxy.exe',
|
||||
downloadURL: `https://github.com/mihomo-party-org/sysproxy/releases/download/${arch}/sysproxy.exe`
|
||||
})
|
||||
const resolveRunner = () =>
|
||||
resolveResource({
|
||||
file: 'mihomo-party-run.exe',
|
||||
downloadURL: `https://github.com/mihomo-party-org/mihomo-party-run/releases/download/${arch}/mihomo-party-run.exe`
|
||||
})
|
||||
/* ======= 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')
|
||||
@ -305,7 +411,7 @@ const resolve7zip = () =>
|
||||
})
|
||||
const resolveSubstore = () =>
|
||||
resolveResource({
|
||||
file: 'sub-store.bundle.js',
|
||||
file: 'sub-store.bundle.cjs',
|
||||
downloadURL:
|
||||
'https://github.com/sub-store-org/Sub-Store/releases/latest/download/sub-store.bundle.js'
|
||||
})
|
||||
@ -360,6 +466,11 @@ const tasks = [
|
||||
func: () => getLatestReleaseVersion().then(() => resolveSidecar(mihomo())),
|
||||
retry: 5
|
||||
},
|
||||
{
|
||||
name: 'mihomo-smart',
|
||||
func: () => getLatestSmartVersion().then(() => resolveSidecar(mihomoSmart())),
|
||||
retry: 5
|
||||
},
|
||||
{ name: 'mmdb', func: resolveMmdb, retry: 5 },
|
||||
{ name: 'metadb', func: resolveMetadb, retry: 5 },
|
||||
{ name: 'geosite', func: resolveGeosite, retry: 5 },
|
||||
@ -379,14 +490,7 @@ const tasks = [
|
||||
{
|
||||
name: 'sysproxy',
|
||||
func: resolveSysproxy,
|
||||
retry: 5,
|
||||
winOnly: true
|
||||
},
|
||||
{
|
||||
name: 'runner',
|
||||
func: resolveRunner,
|
||||
retry: 5,
|
||||
winOnly: true
|
||||
retry: 5
|
||||
},
|
||||
{
|
||||
name: 'monitor',
|
||||
@ -432,7 +536,14 @@ async function runTask() {
|
||||
break
|
||||
} catch (err) {
|
||||
console.error(`[ERROR]: task::${task.name} try ${i} ==`, err.message)
|
||||
if (i === task.retry - 1) throw err
|
||||
if (i === task.retry - 1) {
|
||||
if (task.optional) {
|
||||
console.log(`[WARN]: Optional task::${task.name} failed, skipping...`)
|
||||
break
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return runTask()
|
||||
|
||||
@ -1,46 +1,79 @@
|
||||
import axios from 'axios'
|
||||
import { readFileSync } from 'fs'
|
||||
import {
|
||||
getProcessedVersion,
|
||||
isDevBuild,
|
||||
getDownloadUrl,
|
||||
generateDownloadLinksMarkdown,
|
||||
getGitCommitHash
|
||||
} from './version-utils.mjs'
|
||||
|
||||
const chat_id = '@MihomoPartyChannel'
|
||||
const pkg = readFileSync('package.json', 'utf-8')
|
||||
const changelog = readFileSync('changelog.md', 'utf-8')
|
||||
const { version } = JSON.parse(pkg)
|
||||
const downloadUrl = `https://github.com/mihomo-party-org/mihomo-party/releases/download/v${version}`
|
||||
let content = `<b>🌟 <a href="https://github.com/mihomo-party-org/mihomo-party/releases/tag/v${version}">Mihomo Party v${version}</a> 正式发布</b>\n\n`
|
||||
for (const line of changelog.split('\n')) {
|
||||
if (line.length === 0) {
|
||||
content += '\n'
|
||||
} else if (line.startsWith('### ')) {
|
||||
content += `<b>${line.replace('### ', '')}</b>\n`
|
||||
} else {
|
||||
content += `${line}\n`
|
||||
}
|
||||
|
||||
// 获取处理后的版本号
|
||||
const version = getProcessedVersion()
|
||||
const releaseType = process.env.RELEASE_TYPE || process.argv[2] || 'release'
|
||||
const isDevRelease = releaseType === 'dev' || isDevBuild()
|
||||
|
||||
function convertMarkdownToTelegramHTML(content) {
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (line.trim().length === 0) {
|
||||
return ''
|
||||
} else if (line.startsWith('## ')) {
|
||||
return `<b>${line.replace('## ', '')}</b>`
|
||||
} else if (line.startsWith('### ')) {
|
||||
return `<b>${line.replace('### ', '')}</b>`
|
||||
} else if (line.startsWith('#### ')) {
|
||||
return `<b>${line.replace('#### ', '')}</b>`
|
||||
} else {
|
||||
let processedLine = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
|
||||
const encodedUrl = encodeURI(url)
|
||||
return `<a href="${encodedUrl}">${text}</a>`
|
||||
})
|
||||
processedLine = processedLine.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
|
||||
return processedLine
|
||||
}
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
content += '\n<b>下载地址:</b>\n<b>Windows10/11:</b>\n'
|
||||
content += `安装版:<a href="${downloadUrl}/mihomo-party-windows-${version}-x64-setup.exe">64位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-ia32-setup.exe">32位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-arm64-setup.exe">ARM64</a>\n`
|
||||
content += `便携版:<a href="${downloadUrl}/mihomo-party-windows-${version}-x64-portable.7z">64位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-ia32-portable.7z">32位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-arm64-portable.7z">ARM64</a>\n`
|
||||
content += '\n<b>Windows7/8:</b>\n'
|
||||
content += `安装版:<a href="${downloadUrl}/mihomo-party-win7-${version}-x64-setup.exe">64位</a> | <a href="${downloadUrl}/mihomo-party-win7-${version}-ia32-setup.exe">32位</a>\n`
|
||||
content += `便携版:<a href="${downloadUrl}/mihomo-party-win7-${version}-x64-portable.7z">64位</a> | <a href="${downloadUrl}/mihomo-party-win7-${version}-ia32-portable.7z">32位</a>\n`
|
||||
content += '\n<b>macOS 11+:</b>\n'
|
||||
content += `PKG:<a href="${downloadUrl}/mihomo-party-macos-${version}-x64.pkg
|
||||
">Intel</a> | <a href="${downloadUrl}/mihomo-party-macos-${version}-arm64.pkg">Apple Silicon</a>\n`
|
||||
content += '\n<b>macOS 10.15+:</b>\n'
|
||||
content += `PKG:<a href="${downloadUrl}/mihomo-party-catalina-${version}-x64.pkg
|
||||
">Intel</a> | <a href="${downloadUrl}/mihomo-party-catalina-${version}-arm64.pkg">Apple Silicon</a>\n`
|
||||
content += '\n<b>Linux:</b>\n'
|
||||
content += `DEB:<a href="${downloadUrl}/mihomo-party-linux-${version}-amd64.deb
|
||||
">64位</a> | <a href="${downloadUrl}/mihomo-party-linux-${version}-arm64.deb">ARM64</a>\n`
|
||||
content += `RPM:<a href="${downloadUrl}/mihomo-party-linux-${version}-x86_64.rpm">64位</a> | <a href="${downloadUrl}/mihomo-party-linux-${version}-aarch64.rpm">ARM64</a>`
|
||||
let content = ''
|
||||
|
||||
if (isDevRelease) {
|
||||
// 版本号中提取commit hash
|
||||
const shortCommitSha = getGitCommitHash(true)
|
||||
const commitSha = getGitCommitHash(false)
|
||||
|
||||
content = `<b>🚧 <a href="https://github.com/mihomo-party-org/clash-party/releases/tag/dev">Clash Party Dev Build</a> 开发版本发布</b>\n\n`
|
||||
content += `<b>基于版本:</b> ${version}\n`
|
||||
content += `<b>提交哈希:</b> <a href="https://github.com/mihomo-party-org/clash-party/commit/${commitSha}">${shortCommitSha}</a>\n\n`
|
||||
content += `<b>更新日志:</b>\n`
|
||||
content += convertMarkdownToTelegramHTML(changelog)
|
||||
content += '\n\n<b>⚠️ 注意:这是开发版本,可能存在不稳定性,仅供测试使用</b>\n'
|
||||
} else {
|
||||
// 正式版本通知
|
||||
content = `<b>🌟 <a href="https://github.com/mihomo-party-org/clash-party/releases/tag/v${version}">Clash Party v${version}</a> 正式发布</b>\n\n`
|
||||
content += convertMarkdownToTelegramHTML(changelog)
|
||||
}
|
||||
|
||||
// 构建下载链接
|
||||
const downloadUrl = getDownloadUrl(isDevRelease, version)
|
||||
|
||||
const downloadLinksMarkdown = generateDownloadLinksMarkdown(downloadUrl, version)
|
||||
content += convertMarkdownToTelegramHTML(downloadLinksMarkdown)
|
||||
|
||||
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/mihomo-party',
|
||||
url: 'https://github.com/mihomo-party-org/clash-party',
|
||||
prefer_large_media: true
|
||||
},
|
||||
parse_mode: 'HTML'
|
||||
})
|
||||
|
||||
console.log(`${isDevRelease ? '开发版本' : '正式版本'}Telegram 通知发送成功`)
|
||||
|
||||
31
scripts/update-version.mjs
Normal file
@ -0,0 +1,31 @@
|
||||
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 }
|
||||
@ -1,28 +1,29 @@
|
||||
import yaml from 'yaml'
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import {
|
||||
getProcessedVersion,
|
||||
isDevBuild,
|
||||
getDownloadUrl,
|
||||
generateDownloadLinksMarkdown
|
||||
} from './version-utils.mjs'
|
||||
|
||||
const pkg = readFileSync('package.json', 'utf-8')
|
||||
let changelog = readFileSync('changelog.md', 'utf-8')
|
||||
const { version } = JSON.parse(pkg)
|
||||
const downloadUrl = `https://github.com/mihomo-party-org/mihomo-party/releases/download/v${version}`
|
||||
|
||||
// 获取处理后的版本号
|
||||
const version = getProcessedVersion()
|
||||
const isDev = isDevBuild()
|
||||
const downloadUrl = getDownloadUrl(isDev, version)
|
||||
|
||||
const latest = {
|
||||
version,
|
||||
changelog
|
||||
}
|
||||
|
||||
changelog += '\n### 下载地址:\n\n#### Windows10/11:\n\n'
|
||||
changelog += `- 安装版:[64位](${downloadUrl}/mihomo-party-windows-${version}-x64-setup.exe) | [32位](${downloadUrl}/mihomo-party-windows-${version}-ia32-setup.exe) | [ARM64](${downloadUrl}/mihomo-party-windows-${version}-arm64-setup.exe)\n\n`
|
||||
changelog += `- 便携版:[64位](${downloadUrl}/mihomo-party-windows-${version}-x64-portable.7z) | [32位](${downloadUrl}/mihomo-party-windows-${version}-ia32-portable.7z) | [ARM64](${downloadUrl}/mihomo-party-windows-${version}-arm64-portable.7z)\n\n`
|
||||
changelog += '\n#### Windows7/8:\n\n'
|
||||
changelog += `- 安装版:[64位](${downloadUrl}/mihomo-party-win7-${version}-x64-setup.exe) | [32位](${downloadUrl}/mihomo-party-win7-${version}-ia32-setup.exe)\n\n`
|
||||
changelog += `- 便携版:[64位](${downloadUrl}/mihomo-party-win7-${version}-x64-portable.7z) | [32位](${downloadUrl}/mihomo-party-win7-${version}-ia32-portable.7z)\n\n`
|
||||
changelog += '\n#### macOS 11+:\n\n'
|
||||
changelog += `- PKG:[Intel](${downloadUrl}/mihomo-party-macos-${version}-x64.pkg) | [Apple Silicon](${downloadUrl}/mihomo-party-macos-${version}-arm64.pkg)\n\n`
|
||||
changelog += '\n#### macOS 10.15+:\n\n'
|
||||
changelog += `- PKG:[Intel](${downloadUrl}/mihomo-party-catalina-${version}-x64.pkg) | [Apple Silicon](${downloadUrl}/mihomo-party-catalina-${version}-arm64.pkg)\n\n`
|
||||
changelog += '\n#### Linux:\n\n'
|
||||
changelog += `- DEB:[64位](${downloadUrl}/mihomo-party-linux-${version}-amd64.deb) | [ARM64](${downloadUrl}/mihomo-party-linux-${version}-arm64.deb)\n\n`
|
||||
changelog += `- RPM:[64位](${downloadUrl}/mihomo-party-linux-${version}-x86_64.rpm) | [ARM64](${downloadUrl}/mihomo-party-linux-${version}-aarch64.rpm)`
|
||||
// 使用统一的下载链接生成函数
|
||||
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)
|
||||
|
||||
88
scripts/version-utils.mjs
Normal file
@ -0,0 +1,88 @@
|
||||
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
|
||||
}
|
||||
@ -1,24 +1,36 @@
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import { appConfigPath } from '../utils/dirs'
|
||||
import yaml from 'yaml'
|
||||
import { parse, stringify } from '../utils/yaml'
|
||||
import { deepMerge } from '../utils/merge'
|
||||
import { defaultConfig } from '../utils/template'
|
||||
|
||||
let appConfig: IAppConfig // config.yaml
|
||||
let appConfigWriteQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
export async function getAppConfig(force = false): Promise<IAppConfig> {
|
||||
if (force || !appConfig) {
|
||||
const data = await readFile(appConfigPath(), 'utf-8')
|
||||
appConfig = yaml.parse(data, { merge: true }) || defaultConfig
|
||||
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<IAppConfig>): Promise<void> {
|
||||
if (patch.nameserverPolicy) {
|
||||
appConfig.nameserverPolicy = patch.nameserverPolicy
|
||||
}
|
||||
appConfig = deepMerge(appConfig, patch)
|
||||
await writeFile(appConfigPath(), yaml.stringify(appConfig))
|
||||
appConfigWriteQueue = appConfigWriteQueue.then(async () => {
|
||||
if (patch.nameserverPolicy) {
|
||||
appConfig.nameserverPolicy = patch.nameserverPolicy
|
||||
}
|
||||
appConfig = deepMerge(appConfig, patch)
|
||||
await writeFile(appConfigPath(), stringify(appConfig))
|
||||
})
|
||||
await appConfigWriteQueue
|
||||
}
|
||||
|
||||
@ -1,17 +1,49 @@
|
||||
import { controledMihomoConfigPath } from '../utils/dirs'
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import yaml from 'yaml'
|
||||
import { existsSync } from 'fs'
|
||||
import { controledMihomoConfigPath } from '../utils/dirs'
|
||||
import { parse, stringify } from '../utils/yaml'
|
||||
import { generateProfile } from '../core/factory'
|
||||
import { getAppConfig } from './app'
|
||||
import { defaultControledMihomoConfig } from '../utils/template'
|
||||
import { deepMerge } from '../utils/merge'
|
||||
import { createLogger } from '../utils/logger'
|
||||
import { getAppConfig } from './app'
|
||||
|
||||
const controledMihomoLogger = createLogger('ControledMihomo')
|
||||
|
||||
let controledMihomoConfig: Partial<IMihomoConfig> // mihomo.yaml
|
||||
let controledMihomoWriteQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
export async function getControledMihomoConfig(force = false): Promise<Partial<IMihomoConfig>> {
|
||||
if (force || !controledMihomoConfig) {
|
||||
const data = await readFile(controledMihomoConfigPath(), 'utf-8')
|
||||
controledMihomoConfig = yaml.parse(data, { merge: true }) || defaultControledMihomoConfig
|
||||
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
|
||||
@ -19,38 +51,40 @@ export async function getControledMihomoConfig(force = false): Promise<Partial<I
|
||||
}
|
||||
|
||||
export async function patchControledMihomoConfig(patch: Partial<IMihomoConfig>): Promise<void> {
|
||||
const { useNameserverPolicy, controlDns = true, controlSniff = true } = await getAppConfig()
|
||||
if (!controlDns) {
|
||||
delete controledMihomoConfig.dns
|
||||
delete controledMihomoConfig.hosts
|
||||
} else {
|
||||
// 从不接管状态恢复
|
||||
if (controledMihomoConfig.dns?.ipv6 === undefined) {
|
||||
controledMihomoConfig.dns = defaultControledMihomoConfig.dns
|
||||
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 (!controlSniff) {
|
||||
delete controledMihomoConfig.sniffer
|
||||
} else {
|
||||
|
||||
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 (!controledMihomoConfig.sniffer) {
|
||||
if (controlDns) {
|
||||
// 确保 DNS 配置包含所有必要的默认字段,特别是新增的 fallback 等
|
||||
controledMihomoConfig.dns = deepMerge(
|
||||
defaultControledMihomoConfig.dns || {},
|
||||
controledMihomoConfig.dns || {}
|
||||
)
|
||||
}
|
||||
if (controlSniff && !controledMihomoConfig.sniffer) {
|
||||
controledMihomoConfig.sniffer = defaultControledMihomoConfig.sniffer
|
||||
}
|
||||
}
|
||||
if (patch.hosts) {
|
||||
controledMihomoConfig.hosts = patch.hosts
|
||||
}
|
||||
if (patch.dns?.['nameserver-policy']) {
|
||||
controledMihomoConfig.dns = controledMihomoConfig.dns || {}
|
||||
controledMihomoConfig.dns['nameserver-policy'] = patch.dns['nameserver-policy']
|
||||
}
|
||||
controledMihomoConfig = deepMerge(controledMihomoConfig, patch)
|
||||
if (!useNameserverPolicy) {
|
||||
delete controledMihomoConfig?.dns?.['nameserver-policy']
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
delete controledMihomoConfig?.tun?.device
|
||||
}
|
||||
await generateProfile()
|
||||
await writeFile(controledMihomoConfigPath(), yaml.stringify(controledMihomoConfig), 'utf-8')
|
||||
|
||||
await generateProfile()
|
||||
await writeFile(controledMihomoConfigPath(), stringify(controledMihomoConfig), 'utf-8')
|
||||
})
|
||||
await controledMihomoWriteQueue
|
||||
}
|
||||
|
||||
@ -14,7 +14,8 @@ export {
|
||||
getProfileStr,
|
||||
setProfileStr,
|
||||
changeCurrentProfile,
|
||||
updateProfileItem
|
||||
updateProfileItem,
|
||||
convertMrsRuleset
|
||||
} from './profile'
|
||||
export {
|
||||
getOverrideConfig,
|
||||
@ -27,3 +28,9 @@ export {
|
||||
setOverride,
|
||||
updateOverrideItem
|
||||
} from './override'
|
||||
export {
|
||||
createSmartOverride,
|
||||
removeSmartOverride,
|
||||
manageSmartOverride,
|
||||
isSmartOverrideExists
|
||||
} from './smartOverride'
|
||||
|
||||
@ -1,24 +1,29 @@
|
||||
import { overrideConfigPath, overridePath } from '../utils/dirs'
|
||||
import { getControledMihomoConfig } from './controledMihomo'
|
||||
import { readFile, writeFile, rm } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import axios from 'axios'
|
||||
import yaml from 'yaml'
|
||||
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<void> = Promise.resolve()
|
||||
|
||||
export async function getOverrideConfig(force = false): Promise<IOverrideConfig> {
|
||||
if (force || !overrideConfig) {
|
||||
const data = await readFile(overrideConfigPath(), 'utf-8')
|
||||
overrideConfig = yaml.parse(data, { merge: true }) || { items: [] }
|
||||
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<void> {
|
||||
overrideConfig = config
|
||||
await writeFile(overrideConfigPath(), yaml.stringify(overrideConfig), 'utf-8')
|
||||
overrideConfigWriteQueue = overrideConfigWriteQueue.then(async () => {
|
||||
overrideConfig = config
|
||||
await writeFile(overrideConfigPath(), stringify(overrideConfig), 'utf-8')
|
||||
})
|
||||
await overrideConfigWriteQueue
|
||||
}
|
||||
|
||||
export async function getOverrideItem(id: string | undefined): Promise<IOverrideItem | undefined> {
|
||||
@ -40,19 +45,22 @@ export async function addOverrideItem(item: Partial<IOverrideItem>): Promise<voi
|
||||
const config = await getOverrideConfig()
|
||||
const newItem = await createOverride(item)
|
||||
if (await getOverrideItem(item.id)) {
|
||||
updateOverrideItem(newItem)
|
||||
await updateOverrideItem(newItem)
|
||||
} else {
|
||||
config.items.push(newItem)
|
||||
await setOverrideConfig(config)
|
||||
}
|
||||
await setOverrideConfig(config)
|
||||
}
|
||||
|
||||
export async function removeOverrideItem(id: string): Promise<void> {
|
||||
const config = await getOverrideConfig()
|
||||
const item = await getOverrideItem(id)
|
||||
config.items = config.items?.filter((item) => item.id !== id)
|
||||
if (!item) return
|
||||
config.items = config.items?.filter((i) => i.id !== id)
|
||||
await setOverrideConfig(config)
|
||||
await rm(overridePath(id, item?.ext || 'js'))
|
||||
if (existsSync(overridePath(id, item.ext))) {
|
||||
await rm(overridePath(id, item.ext))
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOverride(item: Partial<IOverrideItem>): Promise<IOverrideItem> {
|
||||
@ -70,7 +78,7 @@ export async function createOverride(item: Partial<IOverrideItem>): Promise<IOve
|
||||
case 'remote': {
|
||||
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
|
||||
if (!item.url) throw new Error('Empty URL')
|
||||
const res = await axios.get(item.url, {
|
||||
const res = await chromeRequest.get(item.url, {
|
||||
proxy: {
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
@ -78,13 +86,13 @@ export async function createOverride(item: Partial<IOverrideItem>): Promise<IOve
|
||||
},
|
||||
responseType: 'text'
|
||||
})
|
||||
const data = res.data
|
||||
const data = res.data as string
|
||||
await setOverride(id, newItem.ext, data)
|
||||
break
|
||||
}
|
||||
case 'local': {
|
||||
const data = item.file || ''
|
||||
setOverride(id, newItem.ext, data)
|
||||
await setOverride(id, newItem.ext, data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,93 +1,160 @@
|
||||
import { getControledMihomoConfig } from './controledMihomo'
|
||||
import { mihomoProfileWorkDir, mihomoWorkDir, profileConfigPath, profilePath } from '../utils/dirs'
|
||||
import { addProfileUpdater } from '../core/profileUpdater'
|
||||
import { readFile, rm, writeFile } from 'fs/promises'
|
||||
import { restartCore } from '../core/manager'
|
||||
import { getAppConfig } from './app'
|
||||
import { existsSync } from 'fs'
|
||||
import axios, { AxiosResponse } from 'axios'
|
||||
import yaml from 'yaml'
|
||||
import { defaultProfile } from '../utils/template'
|
||||
import { subStorePort } from '../resolve/server'
|
||||
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'
|
||||
|
||||
let profileConfig: IProfileConfig // profile.yaml
|
||||
const profileLogger = createLogger('Profile')
|
||||
|
||||
let profileConfig: IProfileConfig
|
||||
let profileConfigWriteQueue: Promise<void> = Promise.resolve()
|
||||
let changeProfileQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
export async function getProfileConfig(force = false): Promise<IProfileConfig> {
|
||||
if (force || !profileConfig) {
|
||||
const data = await readFile(profileConfigPath(), 'utf-8')
|
||||
profileConfig = yaml.parse(data, { merge: true }) || { items: [] }
|
||||
profileConfig = parse(data) || { items: [] }
|
||||
}
|
||||
if (typeof profileConfig !== 'object') profileConfig = { items: [] }
|
||||
return profileConfig
|
||||
if (!Array.isArray(profileConfig.items)) profileConfig.items = []
|
||||
return JSON.parse(JSON.stringify(profileConfig))
|
||||
}
|
||||
|
||||
export async function setProfileConfig(config: IProfileConfig): Promise<void> {
|
||||
profileConfig = config
|
||||
await writeFile(profileConfigPath(), yaml.stringify(config), 'utf-8')
|
||||
profileConfigWriteQueue = profileConfigWriteQueue.then(async () => {
|
||||
profileConfig = config
|
||||
await writeFile(profileConfigPath(), stringify(config), 'utf-8')
|
||||
})
|
||||
await profileConfigWriteQueue
|
||||
}
|
||||
|
||||
export async function updateProfileConfig(
|
||||
updater: (config: IProfileConfig) => IProfileConfig | Promise<IProfileConfig>
|
||||
): Promise<IProfileConfig> {
|
||||
let result: IProfileConfig | undefined
|
||||
profileConfigWriteQueue = profileConfigWriteQueue.then(async () => {
|
||||
const data = await readFile(profileConfigPath(), 'utf-8')
|
||||
profileConfig = parse(data) || { items: [] }
|
||||
if (typeof profileConfig !== 'object') profileConfig = { items: [] }
|
||||
if (!Array.isArray(profileConfig.items)) profileConfig.items = []
|
||||
profileConfig = await updater(JSON.parse(JSON.stringify(profileConfig)))
|
||||
result = profileConfig
|
||||
await writeFile(profileConfigPath(), stringify(profileConfig), 'utf-8')
|
||||
})
|
||||
await profileConfigWriteQueue
|
||||
return JSON.parse(JSON.stringify(result ?? profileConfig))
|
||||
}
|
||||
|
||||
export async function getProfileItem(id: string | undefined): Promise<IProfileItem | undefined> {
|
||||
const { items } = await getProfileConfig()
|
||||
if (!id || id === 'default') return { id: 'default', type: 'local', name: '空白订阅' }
|
||||
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<void> {
|
||||
const config = await getProfileConfig()
|
||||
const current = config.current
|
||||
config.current = id
|
||||
await setProfileConfig(config)
|
||||
try {
|
||||
await restartCore()
|
||||
} catch (e) {
|
||||
config.current = current
|
||||
throw e
|
||||
} finally {
|
||||
await setProfileConfig(config)
|
||||
// 使用队列确保 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<void> {
|
||||
const config = await getProfileConfig()
|
||||
const index = config.items.findIndex((i) => i.id === item.id)
|
||||
if (index === -1) {
|
||||
throw new Error('Profile not found')
|
||||
}
|
||||
config.items[index] = item
|
||||
await setProfileConfig(config)
|
||||
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<IProfileItem>): Promise<void> {
|
||||
const newItem = await createProfile(item)
|
||||
const config = await getProfileConfig()
|
||||
if (await getProfileItem(newItem.id)) {
|
||||
await updateProfileItem(newItem)
|
||||
} else {
|
||||
config.items.push(newItem)
|
||||
}
|
||||
await setProfileConfig(config)
|
||||
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 (!config.current) {
|
||||
// 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<void> {
|
||||
const config = await getProfileConfig()
|
||||
config.items = config.items?.filter((item) => item.id !== id)
|
||||
await removeProfileUpdater(id)
|
||||
|
||||
let shouldRestart = false
|
||||
if (config.current === id) {
|
||||
shouldRestart = true
|
||||
if (config.items.length > 0) {
|
||||
config.current = config.items[0].id
|
||||
} else {
|
||||
config.current = undefined
|
||||
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
|
||||
}
|
||||
}
|
||||
await setProfileConfig(config)
|
||||
return config
|
||||
})
|
||||
|
||||
if (existsSync(profilePath(id))) {
|
||||
await rm(profilePath(id))
|
||||
}
|
||||
@ -101,85 +168,147 @@ export async function removeProfileItem(id: string): Promise<void> {
|
||||
|
||||
export async function getCurrentProfileItem(): Promise<IProfileItem> {
|
||||
const { current } = await getProfileConfig()
|
||||
return (await getProfileItem(current)) || { id: 'default', type: 'local', name: '空白订阅' }
|
||||
return (
|
||||
(await getProfileItem(current)) || {
|
||||
id: 'default',
|
||||
type: 'local',
|
||||
name: i18next.t('profiles.emptyProfile')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
interface FetchOptions {
|
||||
url: string
|
||||
useProxy: boolean
|
||||
mixedPort: number
|
||||
userAgent: string
|
||||
authToken?: string
|
||||
timeout: number
|
||||
substore: boolean
|
||||
}
|
||||
|
||||
interface FetchResult {
|
||||
data: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
async function fetchAndValidateSubscription(options: FetchOptions): Promise<FetchResult> {
|
||||
const { url, useProxy, mixedPort, userAgent, authToken, timeout, substore } = options
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': userAgent,
|
||||
'Accept-Encoding': 'identity'
|
||||
}
|
||||
if (authToken) headers['Authorization'] = authToken
|
||||
|
||||
let res: chromeRequest.Response<string>
|
||||
if (substore) {
|
||||
const urlObj = new URL(`http://127.0.0.1:${subStorePort}${url}`)
|
||||
urlObj.searchParams.set('target', 'ClashMeta')
|
||||
urlObj.searchParams.set('noCache', 'true')
|
||||
if (useProxy) {
|
||||
urlObj.searchParams.set('proxy', `http://127.0.0.1:${mixedPort}`)
|
||||
}
|
||||
res = await chromeRequest.get(urlObj.toString(), { headers, responseType: 'text', timeout })
|
||||
} else {
|
||||
res = await chromeRequest.get(url, {
|
||||
headers,
|
||||
responseType: 'text',
|
||||
timeout,
|
||||
proxy: useProxy ? { protocol: 'http', host: '127.0.0.1', port: mixedPort } : false
|
||||
})
|
||||
}
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`Subscription failed: Request status code ${res.status}`)
|
||||
}
|
||||
|
||||
const parsed = parse(res.data) as Record<string, unknown> | null
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new Error('Subscription failed: Profile is not a valid YAML')
|
||||
}
|
||||
if (!parsed['proxies'] && !parsed['proxy-providers']) {
|
||||
throw new Error('Subscription failed: Profile missing proxies or providers')
|
||||
}
|
||||
|
||||
return { data: res.data, headers: res.headers }
|
||||
}
|
||||
|
||||
export async function createProfile(item: Partial<IProfileItem>): Promise<IProfileItem> {
|
||||
const id = item.id || new Date().getTime().toString(16)
|
||||
const newItem = {
|
||||
const newItem: IProfileItem = {
|
||||
id,
|
||||
name: item.name || (item.type === 'remote' ? 'Remote File' : 'Local File'),
|
||||
type: item.type,
|
||||
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,
|
||||
updated: new Date().getTime()
|
||||
} as IProfileItem
|
||||
switch (newItem.type) {
|
||||
case 'remote': {
|
||||
const { userAgent } = await getAppConfig()
|
||||
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
|
||||
if (!item.url) throw new Error('Empty URL')
|
||||
let res: AxiosResponse
|
||||
if (newItem.substore) {
|
||||
const urlObj = new URL(`http://127.0.0.1:${subStorePort}${item.url}`)
|
||||
urlObj.searchParams.set('target', 'ClashMeta')
|
||||
urlObj.searchParams.set('noCache', 'true')
|
||||
if (newItem.useProxy) {
|
||||
urlObj.searchParams.set('proxy', `http://127.0.0.1:${mixedPort}`)
|
||||
} else {
|
||||
urlObj.searchParams.delete('proxy')
|
||||
}
|
||||
res = await axios.get(urlObj.toString(), {
|
||||
headers: {
|
||||
'User-Agent': userAgent || `mihomo.party/v${app.getVersion()} (clash.meta)`
|
||||
},
|
||||
responseType: 'text'
|
||||
})
|
||||
} else {
|
||||
res = await axios.get(item.url, {
|
||||
proxy: newItem.useProxy
|
||||
? {
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: mixedPort
|
||||
}
|
||||
: false,
|
||||
headers: {
|
||||
'User-Agent': userAgent || `mihomo.party/v${app.getVersion()} (clash.meta)`
|
||||
},
|
||||
responseType: 'text'
|
||||
})
|
||||
}
|
||||
autoUpdate: item.autoUpdate ?? false,
|
||||
authToken: item.authToken,
|
||||
userAgent: item.userAgent,
|
||||
updated: new Date().getTime(),
|
||||
updateTimeout: item.updateTimeout || 5
|
||||
}
|
||||
|
||||
const data = res.data
|
||||
const headers = res.headers
|
||||
if (headers['content-disposition'] && newItem.name === 'Remote File') {
|
||||
newItem.name = parseFilename(headers['content-disposition'])
|
||||
// Local
|
||||
if (newItem.type === 'local') {
|
||||
await setProfileStr(id, item.file || '')
|
||||
return newItem
|
||||
}
|
||||
|
||||
// Remote
|
||||
if (!item.url) throw new Error('Empty URL')
|
||||
|
||||
const { userAgent, subscriptionTimeout = 30000 } = await getAppConfig()
|
||||
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
|
||||
const userItemTimeoutMs = (newItem.updateTimeout || 5) * 1000
|
||||
|
||||
const baseOptions: Omit<FetchOptions, 'useProxy' | 'timeout'> = {
|
||||
url: item.url,
|
||||
mixedPort,
|
||||
userAgent: item.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
|
||||
}
|
||||
if (headers['profile-web-page-url']) {
|
||||
newItem.home = headers['profile-web-page-url']
|
||||
}
|
||||
if (headers['profile-update-interval']) {
|
||||
if (!item.allowFixedInterval) {
|
||||
newItem.interval = parseInt(headers['profile-update-interval']) * 60
|
||||
}
|
||||
}
|
||||
if (headers['subscription-userinfo']) {
|
||||
newItem.extra = parseSubinfo(headers['subscription-userinfo'])
|
||||
}
|
||||
await setProfileStr(id, data)
|
||||
break
|
||||
}
|
||||
case 'local': {
|
||||
const data = item.file || ''
|
||||
await setProfileStr(id, data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const { data, headers } = result
|
||||
|
||||
if (headers['content-disposition'] && newItem.name === 'Remote File') {
|
||||
newItem.name = parseFilename(headers['content-disposition'])
|
||||
}
|
||||
if (headers['profile-web-page-url']) {
|
||||
newItem.home = headers['profile-web-page-url']
|
||||
}
|
||||
if (headers['profile-update-interval'] && !item.allowFixedInterval) {
|
||||
newItem.interval = parseInt(headers['profile-update-interval']) * 60
|
||||
}
|
||||
if (headers['subscription-userinfo']) {
|
||||
newItem.extra = parseSubinfo(headers['subscription-userinfo'])
|
||||
}
|
||||
|
||||
await setProfileStr(id, data)
|
||||
return newItem
|
||||
}
|
||||
|
||||
@ -187,37 +316,79 @@ export async function getProfileStr(id: string | undefined): Promise<string> {
|
||||
if (existsSync(profilePath(id || 'default'))) {
|
||||
return await readFile(profilePath(id || 'default'), 'utf-8')
|
||||
} else {
|
||||
return yaml.stringify(defaultProfile)
|
||||
return stringify(defaultProfile)
|
||||
}
|
||||
}
|
||||
|
||||
export async function setProfileStr(id: string, content: string): Promise<void> {
|
||||
const { current } = await getProfileConfig()
|
||||
// 读取最新的配置
|
||||
const { current } = await getProfileConfig(true)
|
||||
await writeFile(profilePath(id), content, 'utf-8')
|
||||
if (current === id) await restartCore()
|
||||
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<IMihomoConfig> {
|
||||
const profile = await getProfileStr(id)
|
||||
let result = yaml.parse(profile, { merge: true }) || {}
|
||||
if (typeof result !== 'object') result = {}
|
||||
return result
|
||||
|
||||
// 检测是否为 HTML 内容(订阅返回错误页面)
|
||||
const trimmed = profile.trim()
|
||||
if (
|
||||
trimmed.startsWith('<!DOCTYPE') ||
|
||||
trimmed.startsWith('<html') ||
|
||||
trimmed.startsWith('<HTML') ||
|
||||
/<style[^>]*>/i.test(trimmed.slice(0, 500))
|
||||
) {
|
||||
throw new Error(
|
||||
`Profile "${id}" contains HTML instead of YAML. The subscription may have returned an error page. Please re-import or update the subscription.`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
let result = parse(profile)
|
||||
if (typeof result !== 'object') result = {}
|
||||
return result as IMihomoConfig
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
throw new Error(`Failed to parse profile "${id}": ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
// attachment;filename=xxx.yaml; filename*=UTF-8''%xx%xx%xx
|
||||
function parseFilename(str: string): string {
|
||||
if (str.match(/filename\*=.*''/)) {
|
||||
const filename = decodeURIComponent(str.split(/filename\*=.*''/)[1])
|
||||
return filename
|
||||
} else {
|
||||
const filename = str.split('filename=')[1]
|
||||
return filename
|
||||
const parts = str.split(/filename\*=.*''/)
|
||||
if (parts[1]) {
|
||||
return 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('; ')
|
||||
const parts = str.split(/\s*;\s*/)
|
||||
const obj = {} as ISubscriptionUserInfo
|
||||
parts.forEach((part) => {
|
||||
const [key, value] = part.split('=')
|
||||
@ -256,3 +427,45 @@ export async function setFileStr(path: string, content: string): Promise<void> {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function convertMrsRuleset(filePath: string, behavior: string): Promise<string> {
|
||||
const { exec } = await import('child_process')
|
||||
const { promisify } = await import('util')
|
||||
const execAsync = promisify(exec)
|
||||
const { mihomoCorePath } = await import('../utils/dirs')
|
||||
const { getAppConfig } = await import('./app')
|
||||
const { tmpdir } = await import('os')
|
||||
const { randomBytes } = await import('crypto')
|
||||
const { unlink } = await import('fs/promises')
|
||||
|
||||
const { core = 'mihomo' } = await getAppConfig()
|
||||
const corePath = mihomoCorePath(core)
|
||||
const { diffWorkDir = false } = await getAppConfig()
|
||||
const { current } = await getProfileConfig()
|
||||
let fullPath: string
|
||||
if (isAbsolutePath(filePath)) {
|
||||
fullPath = filePath
|
||||
} else {
|
||||
fullPath = join(diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), filePath)
|
||||
}
|
||||
|
||||
const tempFileName = `mrs-convert-${randomBytes(8).toString('hex')}.txt`
|
||||
const tempFilePath = join(tmpdir(), tempFileName)
|
||||
|
||||
try {
|
||||
// 使用 mihomo convert-ruleset 命令转换 MRS 文件为 text 格式
|
||||
// 命令格式: mihomo convert-ruleset <behavior> <format> <source>
|
||||
await execAsync(`"${corePath}" convert-ruleset ${behavior} mrs "${fullPath}" "${tempFilePath}"`)
|
||||
const content = await readFile(tempFilePath, 'utf-8')
|
||||
await unlink(tempFilePath)
|
||||
|
||||
return content
|
||||
} catch (error) {
|
||||
try {
|
||||
await unlink(tempFilePath)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
423
src/main/config/smartOverride.ts
Normal file
@ -0,0 +1,423 @@
|
||||
import { overrideLogger } from '../utils/logger'
|
||||
import { getAppConfig } from './app'
|
||||
import { addOverrideItem, removeOverrideItem, getOverrideItem } from './override'
|
||||
|
||||
const SMART_OVERRIDE_ID = 'smart-core-override'
|
||||
|
||||
/**
|
||||
* Smart 内核的覆写配置模板
|
||||
*/
|
||||
function generateSmartOverrideTemplate(
|
||||
useLightGBM: boolean,
|
||||
collectData: boolean,
|
||||
strategy: string,
|
||||
collectorSize: number
|
||||
): string {
|
||||
return `
|
||||
// 配置会在启用 Smart 内核时自动应用
|
||||
|
||||
function main(config) {
|
||||
try {
|
||||
// 确保配置对象存在
|
||||
if (!config || typeof config !== 'object') {
|
||||
console.log('[Smart Override] Invalid config object')
|
||||
return config
|
||||
}
|
||||
|
||||
// 设置 Smart 内核的 profile 配置
|
||||
if (!config.profile) {
|
||||
config.profile = {}
|
||||
}
|
||||
config.profile['smart-collector-size'] = ${collectorSize}
|
||||
|
||||
// 确保代理组配置存在
|
||||
if (!config['proxy-groups']) {
|
||||
config['proxy-groups'] = []
|
||||
}
|
||||
|
||||
// 确保代理组是数组
|
||||
if (!Array.isArray(config['proxy-groups'])) {
|
||||
console.log('[Smart Override] proxy-groups is not an array, converting...')
|
||||
config['proxy-groups'] = []
|
||||
}
|
||||
|
||||
// 首先检查是否存在 url-test 或 load-balance 代理组
|
||||
let hasUrlTestOrLoadBalance = false
|
||||
for (let i = 0; i < config['proxy-groups'].length; i++) {
|
||||
const group = config['proxy-groups'][i]
|
||||
if (group && group.type) {
|
||||
const groupType = group.type.toLowerCase()
|
||||
if (groupType === 'url-test' || groupType === 'load-balance') {
|
||||
hasUrlTestOrLoadBalance = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果存在 url-test 或 load-balance 代理组,只进行类型转换
|
||||
if (hasUrlTestOrLoadBalance) {
|
||||
console.log('[Smart Override] Found url-test or load-balance groups, converting to smart type')
|
||||
|
||||
// 记录需要更新引用的代理组名称映射
|
||||
const nameMapping = new Map()
|
||||
|
||||
for (let i = 0; i < config['proxy-groups'].length; i++) {
|
||||
const group = config['proxy-groups'][i]
|
||||
if (group && group.type) {
|
||||
const groupType = group.type.toLowerCase()
|
||||
if (groupType === 'url-test' || groupType === 'load-balance') {
|
||||
console.log('[Smart Override] Converting group:', group.name, 'from', group.type, 'to smart')
|
||||
|
||||
// 记录原名称和新名称的映射关系
|
||||
const originalName = group.name
|
||||
|
||||
// 保留原有配置,只修改 type 和添加 Smart 特有配置
|
||||
group.type = 'smart'
|
||||
|
||||
// 为代理组名称添加 (Smart Group) 后缀
|
||||
if (group.name && !group.name.includes('(Smart Group)')) {
|
||||
group.name = group.name + '(Smart Group)'
|
||||
nameMapping.set(originalName, group.name)
|
||||
}
|
||||
|
||||
// 添加 Smart 特有配置
|
||||
if (!group['policy-priority']) {
|
||||
group['policy-priority'] = '' // policy-priority: <1 means lower priority, >1 means higher priority, the default is 1, pattern support regex and string
|
||||
}
|
||||
group.uselightgbm = ${useLightGBM}
|
||||
group.collectdata = ${collectData}
|
||||
group.strategy = '${strategy}'
|
||||
|
||||
// 移除 url-test 和 load-balance 特有的配置
|
||||
if (group.url) delete group.url
|
||||
if (group.interval) delete group.interval
|
||||
if (group.tolerance) delete group.tolerance
|
||||
if (group.lazy) delete group.lazy
|
||||
if (group.expected_status) delete group['expected-status']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新配置文件中其他位置对代理组名称的引用
|
||||
if (nameMapping.size > 0) {
|
||||
console.log('[Smart Override] Updating references to renamed groups:', Array.from(nameMapping.entries()))
|
||||
|
||||
// 更新代理组中的 proxies 字段引用
|
||||
if (config['proxy-groups'] && Array.isArray(config['proxy-groups'])) {
|
||||
config['proxy-groups'].forEach(group => {
|
||||
if (group && group.proxies && Array.isArray(group.proxies)) {
|
||||
group.proxies = group.proxies.map(proxyName => {
|
||||
if (nameMapping.has(proxyName)) {
|
||||
console.log('[Smart Override] Updated proxy reference:', proxyName, '→', nameMapping.get(proxyName))
|
||||
return nameMapping.get(proxyName)
|
||||
}
|
||||
return proxyName
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 更新规则中的代理组引用
|
||||
// 规则参数列表,这些不是策略组名称
|
||||
const ruleParamsSet = new Set(['no-resolve', 'force-remote-dns', 'prefer-ipv6'])
|
||||
|
||||
if (config.rules && Array.isArray(config.rules)) {
|
||||
config.rules = config.rules.map(rule => {
|
||||
if (typeof rule === 'string') {
|
||||
// 按逗号分割规则,精确匹配策略组名称位置
|
||||
const parts = rule.split(',').map(part => part.trim())
|
||||
|
||||
if (parts.length >= 2) {
|
||||
// 找到策略组名称的位置
|
||||
let targetIndex = -1
|
||||
|
||||
// MATCH 规则:MATCH,策略组
|
||||
if (parts[0] === 'MATCH' && parts.length === 2) {
|
||||
targetIndex = 1
|
||||
} else if (parts.length >= 3) {
|
||||
// 其他规则:TYPE,MATCHER,策略组[,参数...]
|
||||
// 策略组通常在第 3 个位置(索引 2),但需要跳过参数
|
||||
for (let i = 2; i < parts.length; i++) {
|
||||
if (!ruleParamsSet.has(parts[i])) {
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只替换策略组名称位置
|
||||
if (targetIndex !== -1 && nameMapping.has(parts[targetIndex])) {
|
||||
const oldName = parts[targetIndex]
|
||||
parts[targetIndex] = nameMapping.get(oldName)
|
||||
console.log('[Smart Override] Updated rule reference:', oldName, '→', nameMapping.get(oldName))
|
||||
return parts.join(',')
|
||||
}
|
||||
}
|
||||
return rule
|
||||
} else if (typeof rule === 'object' && rule !== null) {
|
||||
// 处理对象格式的规则
|
||||
['target', 'proxy'].forEach(field => {
|
||||
if (rule[field] && nameMapping.has(rule[field])) {
|
||||
console.log('[Smart Override] Updated rule object reference:', rule[field], '→', nameMapping.get(rule[field]))
|
||||
rule[field] = nameMapping.get(rule[field])
|
||||
}
|
||||
})
|
||||
}
|
||||
return rule
|
||||
})
|
||||
}
|
||||
|
||||
// 更新其他可能的配置字段引用
|
||||
['mode', 'proxy-mode'].forEach(field => {
|
||||
if (config[field] && nameMapping.has(config[field])) {
|
||||
console.log('[Smart Override] Updated config field', field + ':', config[field], '→', nameMapping.get(config[field]))
|
||||
config[field] = nameMapping.get(config[field])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
console.log('[Smart Override] Conversion completed, skipping other operations')
|
||||
return config
|
||||
}
|
||||
|
||||
// 如果没有 url-test 或 load-balance 代理组,执行原有逻辑
|
||||
console.log('[Smart Override] No url-test or load-balance groups found, executing original logic')
|
||||
|
||||
// 查找现有的 Smart 代理组并更新
|
||||
let smartGroupExists = false
|
||||
for (let i = 0; i < config['proxy-groups'].length; i++) {
|
||||
const group = config['proxy-groups'][i]
|
||||
if (group && group.type === 'smart') {
|
||||
smartGroupExists = true
|
||||
console.log('[Smart Override] Found existing smart group:', group.name)
|
||||
|
||||
if (!group['policy-priority']) {
|
||||
group['policy-priority'] = '' // policy-priority: <1 means lower priority, >1 means higher priority, the default is 1, pattern support regex and string
|
||||
}
|
||||
group.uselightgbm = ${useLightGBM}
|
||||
group.collectdata = ${collectData}
|
||||
group.strategy = '${strategy}'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有 Smart 组且有可用代理,创建示例组
|
||||
if (!smartGroupExists && config.proxies && Array.isArray(config.proxies) && config.proxies.length > 0) {
|
||||
console.log('[Smart Override] Creating new smart group with', config.proxies.length, 'proxies')
|
||||
|
||||
// 获取所有代理的名称
|
||||
const proxyNames = config.proxies
|
||||
.filter(proxy => proxy && typeof proxy === 'object' && proxy.name)
|
||||
.map(proxy => proxy.name)
|
||||
|
||||
if (proxyNames.length > 0) {
|
||||
const smartGroup = {
|
||||
name: 'Smart Group',
|
||||
type: 'smart',
|
||||
'policy-priority': '', // policy-priority: <1 means lower priority, >1 means higher priority, the default is 1, pattern support regex and string
|
||||
uselightgbm: ${useLightGBM},
|
||||
collectdata: ${collectData},
|
||||
strategy: '${strategy}',
|
||||
proxies: proxyNames
|
||||
}
|
||||
config['proxy-groups'].unshift(smartGroup)
|
||||
console.log('[Smart Override] Created smart group at first position with proxies:', proxyNames)
|
||||
} else {
|
||||
console.log('[Smart Override] No valid proxies found, skipping smart group creation')
|
||||
}
|
||||
} else if (!smartGroupExists) {
|
||||
console.log('[Smart Override] No proxies available, skipping smart group creation')
|
||||
}
|
||||
|
||||
// 处理规则替换
|
||||
if (config.rules && Array.isArray(config.rules)) {
|
||||
console.log('[Smart Override] Processing rules, original count:', config.rules.length)
|
||||
|
||||
// 收集所有代理组名称
|
||||
const proxyGroupNames = new Set()
|
||||
if (config['proxy-groups'] && Array.isArray(config['proxy-groups'])) {
|
||||
config['proxy-groups'].forEach(group => {
|
||||
if (group && group.name) {
|
||||
proxyGroupNames.add(group.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 添加常见的内置目标
|
||||
const builtinTargets = new Set([
|
||||
'DIRECT',
|
||||
'REJECT',
|
||||
'REJECT-DROP',
|
||||
'PASS',
|
||||
'COMPATIBLE'
|
||||
])
|
||||
|
||||
// 添加常见的规则参数,不应该替换
|
||||
const ruleParams = new Set(['no-resolve', 'force-remote-dns', 'prefer-ipv6'])
|
||||
|
||||
console.log('[Smart Override] Found', proxyGroupNames.size, 'proxy groups:', Array.from(proxyGroupNames))
|
||||
|
||||
let replacedCount = 0
|
||||
config.rules = config.rules.map(rule => {
|
||||
if (typeof rule === 'string') {
|
||||
// 检查是否是复杂规则格式(包含括号的嵌套规则)
|
||||
if (rule.includes('((') || rule.includes('))')) {
|
||||
console.log('[Smart Override] Skipping complex nested rule:', rule)
|
||||
return rule
|
||||
}
|
||||
|
||||
// 处理字符串格式的规则
|
||||
const parts = rule.split(',').map(part => part.trim())
|
||||
if (parts.length >= 2) {
|
||||
// 找到代理组名称的位置
|
||||
let targetIndex = -1
|
||||
let targetValue = ''
|
||||
|
||||
// 处理 MATCH 规则
|
||||
if (parts[0] === 'MATCH' && parts.length === 2) {
|
||||
targetIndex = 1
|
||||
targetValue = parts[1]
|
||||
} else if (parts.length >= 3) {
|
||||
// 处理其他规则
|
||||
for (let i = 2; i < parts.length; i++) {
|
||||
const part = parts[i]
|
||||
if (!ruleParams.has(part)) {
|
||||
targetIndex = i
|
||||
targetValue = part
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndex !== -1 && targetValue) {
|
||||
// 检查是否应该替换
|
||||
const shouldReplace = !builtinTargets.has(targetValue) &&
|
||||
(proxyGroupNames.has(targetValue) ||
|
||||
!ruleParams.has(targetValue))
|
||||
|
||||
if (shouldReplace) {
|
||||
parts[targetIndex] = 'Smart Group'
|
||||
replacedCount++
|
||||
console.log('[Smart Override] Replaced rule target:', targetValue, '→ Smart Group')
|
||||
return parts.join(',')
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (typeof rule === 'object' && rule !== null) {
|
||||
// 处理对象格式
|
||||
let targetField = ''
|
||||
let targetValue = ''
|
||||
|
||||
if (rule.target) {
|
||||
targetField = 'target'
|
||||
targetValue = rule.target
|
||||
} else if (rule.proxy) {
|
||||
targetField = 'proxy'
|
||||
targetValue = rule.proxy
|
||||
}
|
||||
|
||||
if (targetField && targetValue) {
|
||||
const shouldReplace = !builtinTargets.has(targetValue) &&
|
||||
(proxyGroupNames.has(targetValue) ||
|
||||
!ruleParams.has(targetValue))
|
||||
|
||||
if (shouldReplace) {
|
||||
rule[targetField] = 'Smart Group'
|
||||
replacedCount++
|
||||
console.log('[Smart Override] Replaced rule target:', targetValue, '→ Smart Group')
|
||||
}
|
||||
}
|
||||
}
|
||||
return rule
|
||||
})
|
||||
|
||||
console.log('[Smart Override] Rules processed, replaced', replacedCount, 'non-DIRECT rules with Smart Group')
|
||||
} else {
|
||||
console.log('[Smart Override] No rules found or rules is not an array')
|
||||
}
|
||||
|
||||
console.log('[Smart Override] Configuration processed successfully')
|
||||
return config
|
||||
} catch (error) {
|
||||
console.error('[Smart Override] Error processing config:', error)
|
||||
// 发生错误时返回原始配置,避免破坏整个配置
|
||||
return config
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新 Smart 内核覆写配置
|
||||
*/
|
||||
export async function createSmartOverride(): Promise<void> {
|
||||
try {
|
||||
// 获取应用配置
|
||||
const {
|
||||
smartCoreUseLightGBM = false,
|
||||
smartCoreCollectData = false,
|
||||
smartCoreStrategy = 'sticky-sessions',
|
||||
smartCollectorSize = 100
|
||||
} = await getAppConfig()
|
||||
|
||||
// 生成覆写模板
|
||||
const template = generateSmartOverrideTemplate(
|
||||
smartCoreUseLightGBM,
|
||||
smartCoreCollectData,
|
||||
smartCoreStrategy,
|
||||
smartCollectorSize
|
||||
)
|
||||
|
||||
await addOverrideItem({
|
||||
id: SMART_OVERRIDE_ID,
|
||||
name: 'Smart Core Override',
|
||||
type: 'local',
|
||||
ext: 'js',
|
||||
global: true,
|
||||
file: template
|
||||
})
|
||||
} catch (error) {
|
||||
await overrideLogger.error('Failed to create Smart override', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Smart 内核覆写配置
|
||||
*/
|
||||
export async function removeSmartOverride(): Promise<void> {
|
||||
try {
|
||||
const existingOverride = await getOverrideItem(SMART_OVERRIDE_ID)
|
||||
if (existingOverride) {
|
||||
await removeOverrideItem(SMART_OVERRIDE_ID)
|
||||
}
|
||||
} catch (error) {
|
||||
await overrideLogger.error('Failed to remove Smart override', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据应用配置管理 Smart 覆写
|
||||
*/
|
||||
export async function manageSmartOverride(): Promise<void> {
|
||||
const { enableSmartCore = true, enableSmartOverride = true, core } = await getAppConfig()
|
||||
|
||||
if (enableSmartCore && enableSmartOverride && core === 'mihomo-smart') {
|
||||
await createSmartOverride()
|
||||
} else {
|
||||
await removeSmartOverride()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Smart 覆写是否存在
|
||||
*/
|
||||
export async function isSmartOverrideExists(): Promise<boolean> {
|
||||
try {
|
||||
const override = await getOverrideItem(SMART_OVERRIDE_ID)
|
||||
return !!override
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
82
src/main/core/dns.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { net } from 'electron'
|
||||
import axios from 'axios'
|
||||
import { getAppConfig, patchAppConfig } from '../config'
|
||||
|
||||
const execPromise = promisify(exec)
|
||||
const helperSocketPath = '/tmp/mihomo-party-helper.sock'
|
||||
|
||||
let setPublicDNSTimer: NodeJS.Timeout | null = null
|
||||
let recoverDNSTimer: NodeJS.Timeout | null = null
|
||||
|
||||
export async function getDefaultDevice(): Promise<string> {
|
||||
const { stdout: deviceOut } = await execPromise(`route -n get default`)
|
||||
let device = deviceOut.split('\n').find((s) => s.includes('interface:'))
|
||||
device = device?.trim().split(' ').slice(1).join(' ')
|
||||
if (!device) throw new Error('Get device failed')
|
||||
return device
|
||||
}
|
||||
|
||||
async function getDefaultService(): Promise<string> {
|
||||
const device = await getDefaultDevice()
|
||||
const { stdout: order } = await execPromise(`networksetup -listnetworkserviceorder`)
|
||||
const block = order.split('\n\n').find((s) => s.includes(`Device: ${device}`))
|
||||
if (!block) throw new Error('Get networkservice failed')
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.match(/^\(\d+\).*/)) {
|
||||
return line.trim().split(' ').slice(1).join(' ')
|
||||
}
|
||||
}
|
||||
throw new Error('Get service failed')
|
||||
}
|
||||
|
||||
async function getOriginDNS(): Promise<void> {
|
||||
const service = await getDefaultService()
|
||||
const { stdout: dns } = await execPromise(`networksetup -getdnsservers "${service}"`)
|
||||
if (dns.startsWith("There aren't any DNS Servers set on")) {
|
||||
await patchAppConfig({ originDNS: 'Empty' })
|
||||
} else {
|
||||
await patchAppConfig({ originDNS: dns.trim().replace(/\n/g, ' ') })
|
||||
}
|
||||
}
|
||||
|
||||
async function setDNS(dns: string): Promise<void> {
|
||||
const service = await getDefaultService()
|
||||
try {
|
||||
await axios.post('http://localhost/dns', { service, dns }, { socketPath: helperSocketPath })
|
||||
} catch {
|
||||
// fallback to osascript if helper not available
|
||||
const shell = `networksetup -setdnsservers "${service}" ${dns}`
|
||||
const command = `do shell script "${shell}" with administrator privileges`
|
||||
await execPromise(`osascript -e '${command}'`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function setPublicDNS(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return
|
||||
if (net.isOnline()) {
|
||||
const { originDNS } = await getAppConfig()
|
||||
if (!originDNS) {
|
||||
await getOriginDNS()
|
||||
await setDNS('223.5.5.5')
|
||||
}
|
||||
} else {
|
||||
if (setPublicDNSTimer) clearTimeout(setPublicDNSTimer)
|
||||
setPublicDNSTimer = setTimeout(() => setPublicDNS(), 5000)
|
||||
}
|
||||
}
|
||||
|
||||
export async function recoverDNS(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return
|
||||
if (net.isOnline()) {
|
||||
const { originDNS } = await getAppConfig()
|
||||
if (originDNS) {
|
||||
await setDNS(originDNS)
|
||||
await patchAppConfig({ originDNS: undefined })
|
||||
}
|
||||
} else {
|
||||
if (recoverDNSTimer) clearTimeout(recoverDNSTimer)
|
||||
recoverDNSTimer = setTimeout(() => recoverDNS(), 5000)
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,7 @@
|
||||
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,
|
||||
@ -12,23 +16,128 @@ import {
|
||||
mihomoProfileWorkDir,
|
||||
mihomoWorkConfigPath,
|
||||
mihomoWorkDir,
|
||||
overridePath
|
||||
overridePath,
|
||||
rulePath
|
||||
} from '../utils/dirs'
|
||||
import yaml from 'yaml'
|
||||
import { copyFile, mkdir, writeFile } from 'fs/promises'
|
||||
import { parse, stringify } from '../utils/yaml'
|
||||
import { deepMerge } from '../utils/merge'
|
||||
import vm from 'vm'
|
||||
import { existsSync, writeFileSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { createLogger } from '../utils/logger'
|
||||
|
||||
let runtimeConfigStr: string
|
||||
let runtimeConfig: IMihomoConfig
|
||||
const factoryLogger = createLogger('Factory')
|
||||
|
||||
export async function generateProfile(): Promise<void> {
|
||||
const { current } = await getProfileConfig()
|
||||
const { diffWorkDir = false } = await getAppConfig()
|
||||
let runtimeConfigStr: string = ''
|
||||
let runtimeConfig: IMihomoConfig = {} as IMihomoConfig
|
||||
|
||||
// 辅助函数:处理带偏移量的规则
|
||||
function processRulesWithOffset(ruleStrings: string[], currentRules: string[], isAppend = false) {
|
||||
const normalRules: string[] = []
|
||||
const rules = [...currentRules]
|
||||
|
||||
ruleStrings.forEach((ruleStr) => {
|
||||
const parts = ruleStr.split(',')
|
||||
const firstPartIsNumber =
|
||||
!isNaN(Number(parts[0])) && parts[0].trim() !== '' && parts.length >= 3
|
||||
|
||||
if (firstPartIsNumber) {
|
||||
const offset = parseInt(parts[0])
|
||||
const rule = parts.slice(1).join(',')
|
||||
|
||||
if (isAppend) {
|
||||
// 后置规则的插入位置计算
|
||||
const insertPosition = Math.max(0, rules.length - Math.min(offset, rules.length))
|
||||
rules.splice(insertPosition, 0, rule)
|
||||
} else {
|
||||
// 前置规则的插入位置计算
|
||||
const insertPosition = Math.min(offset, rules.length)
|
||||
rules.splice(insertPosition, 0, rule)
|
||||
}
|
||||
} else {
|
||||
normalRules.push(ruleStr)
|
||||
}
|
||||
})
|
||||
|
||||
return { normalRules, insertRules: rules }
|
||||
}
|
||||
|
||||
export async function generateProfile(): Promise<string | undefined> {
|
||||
// 读取最新的配置
|
||||
const { current } = await getProfileConfig(true)
|
||||
const {
|
||||
diffWorkDir = false,
|
||||
controlDns = true,
|
||||
controlSniff = true,
|
||||
useNameserverPolicy
|
||||
} = await getAppConfig()
|
||||
const currentProfile = await overrideProfile(current, await getProfile(current))
|
||||
const controledMihomoConfig = await getControledMihomoConfig()
|
||||
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`
|
||||
@ -36,7 +145,7 @@ export async function generateProfile(): Promise<void> {
|
||||
profile['log-level'] = 'info'
|
||||
}
|
||||
runtimeConfig = profile
|
||||
runtimeConfigStr = yaml.stringify(profile)
|
||||
runtimeConfigStr = stringify(profile)
|
||||
if (diffWorkDir) {
|
||||
await prepareProfileWorkDir(current)
|
||||
}
|
||||
@ -44,16 +153,30 @@ export async function generateProfile(): Promise<void> {
|
||||
diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work'),
|
||||
runtimeConfigStr
|
||||
)
|
||||
return current
|
||||
}
|
||||
|
||||
async function prepareProfileWorkDir(current: string | undefined): Promise<void> {
|
||||
if (!existsSync(mihomoProfileWorkDir(current))) {
|
||||
await mkdir(mihomoProfileWorkDir(current), { recursive: true })
|
||||
}
|
||||
|
||||
const isSourceNewer = async (sourcePath: string, targetPath: string): Promise<boolean> => {
|
||||
try {
|
||||
const [sourceStats, targetStats] = await Promise.all([stat(sourcePath), stat(targetPath)])
|
||||
return sourceStats.mtime > targetStats.mtime
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const copy = async (file: string): Promise<void> => {
|
||||
const targetPath = path.join(mihomoProfileWorkDir(current), file)
|
||||
const sourcePath = path.join(mihomoWorkDir(), file)
|
||||
if (!existsSync(targetPath) && existsSync(sourcePath)) {
|
||||
if (!existsSync(sourcePath)) return
|
||||
// 复制条件:目标不存在 或 源文件更新
|
||||
const shouldCopy = !existsSync(targetPath) || (await isSourceNewer(sourcePath, targetPath))
|
||||
if (shouldCopy) {
|
||||
await copyFile(sourcePath, targetPath)
|
||||
}
|
||||
}
|
||||
@ -81,7 +204,7 @@ async function overrideProfile(
|
||||
profile = runOverrideScript(profile, content, item)
|
||||
break
|
||||
case 'yaml': {
|
||||
let patch = yaml.parse(content, { merge: true }) || {}
|
||||
let patch = parse(content) || {}
|
||||
if (typeof patch !== 'object') patch = {}
|
||||
profile = deepMerge(profile, patch)
|
||||
break
|
||||
|
||||
@ -1,7 +1,21 @@
|
||||
import { ChildProcess, exec, execFile, spawn } from 'child_process'
|
||||
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,
|
||||
logPath,
|
||||
coreLogPath,
|
||||
mihomoCoreDir,
|
||||
mihomoCorePath,
|
||||
mihomoProfileWorkDir,
|
||||
@ -9,15 +23,11 @@ import {
|
||||
mihomoWorkConfigPath,
|
||||
mihomoWorkDir
|
||||
} from '../utils/dirs'
|
||||
import { generateProfile } from './factory'
|
||||
import {
|
||||
getAppConfig,
|
||||
getControledMihomoConfig,
|
||||
getProfileConfig,
|
||||
patchAppConfig,
|
||||
patchControledMihomoConfig
|
||||
} from '../config'
|
||||
import { app, dialog, ipcMain, net } from 'electron'
|
||||
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,
|
||||
@ -27,219 +37,421 @@ import {
|
||||
stopMihomoTraffic,
|
||||
stopMihomoLogs,
|
||||
stopMihomoMemory,
|
||||
patchMihomoConfig
|
||||
patchMihomoConfig,
|
||||
getAxios
|
||||
} from './mihomoApi'
|
||||
import chokidar from 'chokidar'
|
||||
import { readFile, rm, writeFile } from 'fs/promises'
|
||||
import { promisify } from 'util'
|
||||
import { mainWindow } from '..'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { createWriteStream, existsSync } from 'fs'
|
||||
import { uploadRuntimeConfig } from '../resolve/gistApi'
|
||||
import { startMonitor } from '../resolve/trafficMonitor'
|
||||
import i18next from '../../shared/i18n'
|
||||
import { generateProfile } from './factory'
|
||||
import { getSessionAdminStatus } from './permissions'
|
||||
import {
|
||||
cleanupSocketFile,
|
||||
cleanupWindowsNamedPipes,
|
||||
validateWindowsPipeAccess,
|
||||
waitForCoreReady
|
||||
} from './process'
|
||||
import { setPublicDNS, recoverDNS } from './dns'
|
||||
|
||||
chokidar.watch(path.join(mihomoCoreDir(), 'meta-update'), {}).on('unlinkDir', async () => {
|
||||
try {
|
||||
await stopCore(true)
|
||||
await startCore()
|
||||
} catch (e) {
|
||||
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`)
|
||||
}
|
||||
})
|
||||
// 重新导出权限相关函数
|
||||
export {
|
||||
initAdminStatus,
|
||||
getSessionAdminStatus,
|
||||
checkAdminPrivileges,
|
||||
checkMihomoCorePermissions,
|
||||
checkHighPrivilegeCore,
|
||||
grantTunPermissions,
|
||||
restartAsAdmin,
|
||||
requestTunPermissions,
|
||||
showTunPermissionDialog,
|
||||
showErrorDialog,
|
||||
checkTunPermissions,
|
||||
manualGrantCorePermition
|
||||
} from './permissions'
|
||||
|
||||
export const mihomoIpcPath =
|
||||
process.platform === 'win32' ? '\\\\.\\pipe\\MihomoParty\\mihomo' : '/tmp/mihomo-party.sock'
|
||||
export { getDefaultDevice } from './dns'
|
||||
|
||||
const execFilePromise = promisify(execFile)
|
||||
const ctlParam = process.platform === 'win32' ? '-ext-ctl-pipe' : '-ext-ctl-unix'
|
||||
|
||||
let setPublicDNSTimer: NodeJS.Timeout | null = null
|
||||
let recoverDNSTimer: NodeJS.Timeout | null = null
|
||||
// 核心进程状态
|
||||
let child: ChildProcess
|
||||
let retry = 10
|
||||
let isRestarting = false
|
||||
|
||||
// 文件监听器
|
||||
let coreWatcher: FSWatcher | null = null
|
||||
|
||||
// 初始化核心文件监听
|
||||
export function initCoreWatcher(): void {
|
||||
if (coreWatcher) return
|
||||
|
||||
coreWatcher = chokidar.watch(path.join(mihomoCoreDir(), 'meta-update'), {})
|
||||
coreWatcher.on('unlinkDir', async () => {
|
||||
// 等待核心自我更新完成,避免与核心自动重启产生竞态
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
try {
|
||||
await stopCore(true)
|
||||
await startCore()
|
||||
} catch (e) {
|
||||
safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 清理核心文件监听
|
||||
export function cleanupCoreWatcher(): void {
|
||||
if (coreWatcher) {
|
||||
coreWatcher.close()
|
||||
coreWatcher = null
|
||||
}
|
||||
}
|
||||
|
||||
// 动态生成 IPC 路径
|
||||
export const getMihomoIpcPath = (): string => {
|
||||
if (process.platform === 'win32') {
|
||||
const isAdmin = getSessionAdminStatus()
|
||||
const sessionId = process.env.SESSIONNAME || process.env.USERNAME || 'default'
|
||||
const processId = process.pid
|
||||
|
||||
return isAdmin
|
||||
? `\\\\.\\pipe\\MihomoParty\\mihomo-admin-${sessionId}-${processId}`
|
||||
: `\\\\.\\pipe\\MihomoParty\\mihomo-user-${sessionId}-${processId}`
|
||||
}
|
||||
|
||||
const uid = process.getuid?.() || 'unknown'
|
||||
const processId = process.pid
|
||||
return `/tmp/mihomo-party-${uid}-${processId}.sock`
|
||||
}
|
||||
|
||||
// 核心配置接口
|
||||
interface CoreConfig {
|
||||
corePath: string
|
||||
workDir: string
|
||||
ipcPath: string
|
||||
logLevel: LogLevel
|
||||
tunEnabled: boolean
|
||||
autoSetDNS: boolean
|
||||
cpuPriority: string
|
||||
detached: boolean
|
||||
}
|
||||
|
||||
// 准备核心配置
|
||||
async function prepareCore(detached: boolean, skipStop = false): Promise<CoreConfig> {
|
||||
const [appConfig, mihomoConfig] = await Promise.all([getAppConfig(), getControledMihomoConfig()])
|
||||
|
||||
export async function startCore(detached = false): Promise<Promise<void>[]> {
|
||||
const {
|
||||
core = 'mihomo',
|
||||
autoSetDNS = true,
|
||||
diffWorkDir = false,
|
||||
mihomoCpuPriority = 'PRIORITY_NORMAL',
|
||||
disableLoopbackDetector = false,
|
||||
disableEmbedCA = false,
|
||||
disableSystemCA = false,
|
||||
skipSafePathCheck = false
|
||||
} = await getAppConfig()
|
||||
const { 'log-level': logLevel } = await getControledMihomoConfig()
|
||||
if (existsSync(path.join(dataDir(), 'core.pid'))) {
|
||||
const pid = parseInt(await readFile(path.join(dataDir(), 'core.pid'), 'utf-8'))
|
||||
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(path.join(dataDir(), 'core.pid'))
|
||||
await rm(pidPath)
|
||||
}
|
||||
}
|
||||
const { current } = await getProfileConfig()
|
||||
const { tun } = await getControledMihomoConfig()
|
||||
const corePath = mihomoCorePath(core)
|
||||
await generateProfile()
|
||||
await checkProfile()
|
||||
await stopCore()
|
||||
|
||||
// 管理 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) {
|
||||
await writeFile(logPath(), `[Manager]: set dns failed, ${error}`, {
|
||||
flag: 'a'
|
||||
})
|
||||
managerLogger.error('set dns failed', error)
|
||||
}
|
||||
}
|
||||
const stdout = createWriteStream(logPath(), { flags: 'a' })
|
||||
const stderr = createWriteStream(logPath(), { flags: 'a' })
|
||||
const env = {
|
||||
DISABLE_LOOPBACK_DETECTOR: String(disableLoopbackDetector),
|
||||
DISABLE_EMBED_CA: String(disableEmbedCA),
|
||||
DISABLE_SYSTEM_CA: String(disableSystemCA),
|
||||
SKIP_SAFE_PATH_CHECK: String(skipSafePathCheck)
|
||||
|
||||
// 获取动态 IPC 路径
|
||||
const ipcPath = getMihomoIpcPath()
|
||||
managerLogger.info(`Using IPC path: ${ipcPath}`)
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
await validateWindowsPipeAccess(ipcPath)
|
||||
}
|
||||
child = spawn(
|
||||
corePath,
|
||||
['-d', diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), ctlParam, mihomoIpcPath],
|
||||
{
|
||||
detached: detached,
|
||||
stdio: detached ? 'ignore' : undefined,
|
||||
env: env
|
||||
|
||||
return {
|
||||
corePath: mihomoCorePath(core),
|
||||
workDir: diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(),
|
||||
ipcPath,
|
||||
logLevel,
|
||||
tunEnabled: tun?.enable ?? false,
|
||||
autoSetDNS,
|
||||
cpuPriority: mihomoCpuPriority,
|
||||
detached
|
||||
}
|
||||
}
|
||||
|
||||
// 启动核心进程
|
||||
function spawnCoreProcess(config: CoreConfig): ChildProcess {
|
||||
const { corePath, workDir, ipcPath, cpuPriority, detached } = config
|
||||
|
||||
const stdout = createWriteStream(coreLogPath(), { flags: 'a' })
|
||||
const stderr = createWriteStream(coreLogPath(), { flags: 'a' })
|
||||
|
||||
const proc = spawn(corePath, ['-d', workDir, ctlParam, ipcPath], {
|
||||
detached,
|
||||
stdio: detached ? 'ignore' : undefined
|
||||
})
|
||||
|
||||
if (process.platform === 'win32' && proc.pid) {
|
||||
os.setPriority(
|
||||
proc.pid,
|
||||
os.constants.priority[cpuPriority as keyof typeof os.constants.priority]
|
||||
)
|
||||
}
|
||||
|
||||
if (!detached) {
|
||||
proc.stdout?.pipe(stdout)
|
||||
proc.stderr?.pipe(stderr)
|
||||
}
|
||||
|
||||
return proc
|
||||
}
|
||||
|
||||
// 设置核心进程事件监听
|
||||
function setupCoreListeners(
|
||||
proc: ChildProcess,
|
||||
logLevel: LogLevel,
|
||||
resolve: (value: Promise<void>[]) => void,
|
||||
reject: (reason: unknown) => void
|
||||
): void {
|
||||
proc.on('close', async (code, signal) => {
|
||||
managerLogger.info(`Core closed, code: ${code}, signal: ${signal}`)
|
||||
|
||||
if (isRestarting) {
|
||||
managerLogger.info('Core closed during restart, skipping auto-restart')
|
||||
return
|
||||
}
|
||||
)
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
os.setPriority(child.pid, os.constants.priority[mihomoCpuPriority])
|
||||
}
|
||||
if (detached) {
|
||||
child.unref()
|
||||
return new Promise((resolve) => {
|
||||
resolve([new Promise(() => {})])
|
||||
})
|
||||
}
|
||||
child.on('close', async (code, signal) => {
|
||||
await writeFile(logPath(), `[Manager]: Core closed, code: ${code}, signal: ${signal}\n`, {
|
||||
flag: 'a'
|
||||
})
|
||||
|
||||
if (retry) {
|
||||
await writeFile(logPath(), `[Manager]: Try Restart Core\n`, { flag: 'a' })
|
||||
managerLogger.info('Try Restart Core')
|
||||
retry--
|
||||
await restartCore()
|
||||
} else {
|
||||
await stopCore()
|
||||
}
|
||||
})
|
||||
child.stdout?.pipe(stdout)
|
||||
child.stderr?.pipe(stderr)
|
||||
return new Promise((resolve, reject) => {
|
||||
child.stdout?.on('data', async (data) => {
|
||||
const str = data.toString()
|
||||
if (str.includes('configure tun interface: operation not permitted')) {
|
||||
patchControledMihomoConfig({ tun: { enable: false } })
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
reject(i18next.t('tun.error.tunPermissionDenied'))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if ((process.platform !== 'win32' && str.includes('External controller unix listen error')) ||
|
||||
(process.platform === 'win32' && str.includes('External controller pipe listen error'))
|
||||
) {
|
||||
reject(i18next.t('mihomo.error.externalControllerListenError'))
|
||||
}
|
||||
reject(i18next.t('mihomo.error.externalControllerListenError'))
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
(process.platform !== 'win32' && str.includes('RESTful API unix listening at')) ||
|
||||
(process.platform === 'win32' && str.includes('RESTful API pipe listening at'))
|
||||
) {
|
||||
resolve([
|
||||
new Promise((resolve) => {
|
||||
child.stdout?.on('data', async (data) => {
|
||||
if (data.toString().toLowerCase().includes('start initial compatible provider default')) {
|
||||
try {
|
||||
mainWindow?.webContents.send('groupsUpdated')
|
||||
mainWindow?.webContents.send('rulesUpdated')
|
||||
await uploadRuntimeConfig()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await patchMihomoConfig({ 'log-level': logLevel })
|
||||
resolve()
|
||||
// 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 startMihomoTraffic()
|
||||
await startMihomoConnections()
|
||||
await startMihomoLogs()
|
||||
await startMihomoMemory()
|
||||
retry = 10
|
||||
}
|
||||
})
|
||||
})
|
||||
])
|
||||
|
||||
await waitForCoreReady()
|
||||
await getAxios(true)
|
||||
await startMihomoTraffic()
|
||||
await startMihomoConnections()
|
||||
await startMihomoLogs()
|
||||
await startMihomoMemory()
|
||||
retry = 10
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 启动核心
|
||||
export async function startCore(detached = false, skipStop = false): Promise<Promise<void>[]> {
|
||||
const config = await prepareCore(detached, skipStop)
|
||||
child = spawnCoreProcess(config)
|
||||
|
||||
if (detached) {
|
||||
managerLogger.info(
|
||||
`Core process detached successfully on ${process.platform}, PID: ${child.pid}`
|
||||
)
|
||||
child.unref()
|
||||
return [new Promise(() => {})]
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
setupCoreListeners(child, config.logLevel, resolve, reject)
|
||||
})
|
||||
}
|
||||
|
||||
// 停止核心
|
||||
export async function stopCore(force = false): Promise<void> {
|
||||
try {
|
||||
if (!force) {
|
||||
await recoverDNS()
|
||||
}
|
||||
} catch (error) {
|
||||
await writeFile(logPath(), `[Manager]: recover dns failed, ${error}`, {
|
||||
flag: 'a'
|
||||
})
|
||||
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<void> {
|
||||
if (isRestarting) {
|
||||
managerLogger.info('Core restart already in progress, skipping duplicate request')
|
||||
return
|
||||
}
|
||||
|
||||
isRestarting = true
|
||||
let retryCount = 0
|
||||
const maxRetries = 3
|
||||
|
||||
try {
|
||||
await startCore()
|
||||
} catch (e) {
|
||||
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`)
|
||||
// 先显式停止核心,确保状态干净
|
||||
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<void> {
|
||||
try {
|
||||
await startCore(true)
|
||||
if (child && child.pid) {
|
||||
if (child?.pid) {
|
||||
await writeFile(path.join(dataDir(), 'core.pid'), child.pid.toString())
|
||||
}
|
||||
} catch (e) {
|
||||
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`)
|
||||
safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 退出但保持核心运行
|
||||
export async function quitWithoutCore(): Promise<void> {
|
||||
await keepCoreAlive()
|
||||
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(): Promise<void> {
|
||||
const {
|
||||
core = 'mihomo',
|
||||
diffWorkDir = false,
|
||||
skipSafePathCheck = false
|
||||
} = await getAppConfig()
|
||||
const { current } = await getProfileConfig()
|
||||
// 检查配置文件
|
||||
async function checkProfile(
|
||||
current: string | undefined,
|
||||
core: string = 'mihomo',
|
||||
diffWorkDir: boolean = false
|
||||
): Promise<void> {
|
||||
const corePath = mihomoCorePath(core)
|
||||
const execFilePromise = promisify(execFile)
|
||||
const env = {
|
||||
SKIP_SAFE_PATH_CHECK: String(skipSafePathCheck)
|
||||
}
|
||||
|
||||
try {
|
||||
await execFilePromise(corePath, [
|
||||
'-t',
|
||||
@ -247,104 +459,42 @@ async function checkProfile(): Promise<void> {
|
||||
diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work'),
|
||||
'-d',
|
||||
mihomoTestDir()
|
||||
], { env })
|
||||
])
|
||||
} catch (error) {
|
||||
managerLogger.error('Profile check failed', error)
|
||||
|
||||
if (error instanceof Error && 'stdout' in error) {
|
||||
const { stdout } = error as { stdout: string }
|
||||
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'))
|
||||
.map((line) => line.split('level=error')[1])
|
||||
throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}:\n${errorLines.join('\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 error
|
||||
throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function manualGrantCorePermition(): Promise<void> {
|
||||
const { core = 'mihomo' } = await getAppConfig()
|
||||
const corePath = mihomoCorePath(core)
|
||||
const execPromise = promisify(exec)
|
||||
const execFilePromise = promisify(execFile)
|
||||
if (process.platform === 'darwin') {
|
||||
const shell = `chown root:admin ${corePath.replace(' ', '\\\\ ')}\nchmod +sx ${corePath.replace(' ', '\\\\ ')}`
|
||||
const command = `do shell script "${shell}" with administrator privileges`
|
||||
await execPromise(`osascript -e '${command}'`)
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
await execFilePromise('pkexec', [
|
||||
'bash',
|
||||
'-c',
|
||||
`chown root:root "${corePath}" && chmod +sx "${corePath}"`
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDefaultDevice(): Promise<string> {
|
||||
const execPromise = promisify(exec)
|
||||
const { stdout: deviceOut } = await execPromise(`route -n get default`)
|
||||
let device = deviceOut.split('\n').find((s) => s.includes('interface:'))
|
||||
device = device?.trim().split(' ').slice(1).join(' ')
|
||||
if (!device) throw new Error('Get device failed')
|
||||
return device
|
||||
}
|
||||
|
||||
async function getDefaultService(): Promise<string> {
|
||||
const execPromise = promisify(exec)
|
||||
const device = await getDefaultDevice()
|
||||
const { stdout: order } = await execPromise(`networksetup -listnetworkserviceorder`)
|
||||
const block = order.split('\n\n').find((s) => s.includes(`Device: ${device}`))
|
||||
if (!block) throw new Error('Get networkservice failed')
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.match(/^\(\d+\).*/)) {
|
||||
return line.trim().split(' ').slice(1).join(' ')
|
||||
}
|
||||
}
|
||||
throw new Error('Get service failed')
|
||||
}
|
||||
|
||||
async function getOriginDNS(): Promise<void> {
|
||||
const execPromise = promisify(exec)
|
||||
const service = await getDefaultService()
|
||||
const { stdout: dns } = await execPromise(`networksetup -getdnsservers "${service}"`)
|
||||
if (dns.startsWith("There aren't any DNS Servers set on")) {
|
||||
await patchAppConfig({ originDNS: 'Empty' })
|
||||
} else {
|
||||
await patchAppConfig({ originDNS: dns.trim().replace(/\n/g, ' ') })
|
||||
}
|
||||
}
|
||||
|
||||
async function setDNS(dns: string): Promise<void> {
|
||||
const service = await getDefaultService()
|
||||
const execPromise = promisify(exec)
|
||||
await execPromise(`networksetup -setdnsservers "${service}" ${dns}`)
|
||||
}
|
||||
|
||||
async function setPublicDNS(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return
|
||||
if (net.isOnline()) {
|
||||
const { originDNS } = await getAppConfig()
|
||||
if (!originDNS) {
|
||||
await getOriginDNS()
|
||||
await setDNS('223.5.5.5')
|
||||
}
|
||||
} else {
|
||||
if (setPublicDNSTimer) clearTimeout(setPublicDNSTimer)
|
||||
setPublicDNSTimer = setTimeout(() => setPublicDNS(), 5000)
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverDNS(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return
|
||||
if (net.isOnline()) {
|
||||
const { originDNS } = await getAppConfig()
|
||||
if (originDNS) {
|
||||
await setDNS(originDNS)
|
||||
await patchAppConfig({ originDNS: undefined })
|
||||
}
|
||||
} else {
|
||||
if (recoverDNSTimer) clearTimeout(recoverDNSTimer)
|
||||
recoverDNSTimer = setTimeout(() => recoverDNS(), 5000)
|
||||
}
|
||||
// 权限检查入口(从 permissions.ts 调用)
|
||||
export async function checkAdminRestartForTun(): Promise<void> {
|
||||
const { checkAdminRestartForTun: check } = await import('./permissions')
|
||||
await check(restartCore)
|
||||
}
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import { getAppConfig, getControledMihomoConfig } from '../config'
|
||||
import { mainWindow } from '..'
|
||||
import WebSocket from 'ws'
|
||||
import { getAppConfig, getControledMihomoConfig } from '../config'
|
||||
import { mainWindow } from '../window'
|
||||
import { tray } from '../resolve/tray'
|
||||
import { calcTraffic } from '../utils/calc'
|
||||
import { getRuntimeConfig } from './factory'
|
||||
import { floatingWindow } from '../resolve/floatingWindow'
|
||||
import { mihomoIpcPath } from './manager'
|
||||
import { createLogger } from '../utils/logger'
|
||||
import { getRuntimeConfig } from './factory'
|
||||
import { getMihomoIpcPath } from './manager'
|
||||
|
||||
let axiosIns: AxiosInstance = null!
|
||||
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
|
||||
@ -18,12 +22,21 @@ let logsRetry = 10
|
||||
let mihomoConnectionsWs: WebSocket | null = null
|
||||
let connectionsRetry = 10
|
||||
|
||||
const MAX_RETRY = 10
|
||||
|
||||
export const getAxios = async (force: boolean = false): Promise<AxiosInstance> => {
|
||||
if (axiosIns && !force) return axiosIns
|
||||
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: mihomoIpcPath,
|
||||
socketPath: dynamicIpcPath,
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
@ -32,6 +45,12 @@ export const getAxios = async (force: boolean = false): Promise<AxiosInstance> =
|
||||
return response.data
|
||||
},
|
||||
(error) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
mihomoApiLogger.debug(`Pipe not ready: ${error.config?.socketPath}`)
|
||||
} else {
|
||||
mihomoApiLogger.error(`Axios error with path ${dynamicIpcPath}: ${error.message}`)
|
||||
}
|
||||
|
||||
if (error.response && error.response.data) {
|
||||
return Promise.reject(error.response.data)
|
||||
}
|
||||
@ -66,6 +85,11 @@ export const mihomoRules = async (): Promise<IMihomoRulesInfo> => {
|
||||
return await instance.get('/rules')
|
||||
}
|
||||
|
||||
export const mihomoRulesDisable = async (rules: Record<string, boolean>): Promise<void> => {
|
||||
const instance = await getAxios()
|
||||
return await instance.patch('/rules/disable', rules)
|
||||
}
|
||||
|
||||
export const mihomoProxies = async (): Promise<IMihomoProxies> => {
|
||||
const instance = await getAxios()
|
||||
const proxies = (await instance.get('/proxies')) as IMihomoProxies
|
||||
@ -86,14 +110,14 @@ export const mihomoGroups = async (): Promise<IMihomoMixedGroup[]> => {
|
||||
if (proxies.proxies[name] && 'all' in proxies.proxies[name] && !proxies.proxies[name].hidden) {
|
||||
const newGroup = proxies.proxies[name]
|
||||
newGroup.testUrl = url
|
||||
const newAll = newGroup.all.map((name) => proxies.proxies[name])
|
||||
const newAll = (newGroup.all || []).map((name) => proxies.proxies[name])
|
||||
groups.push({ ...newGroup, all: newAll })
|
||||
}
|
||||
})
|
||||
if (!groups.find((group) => group.name === 'GLOBAL')) {
|
||||
const newGlobal = proxies.proxies['GLOBAL'] as IMihomoGroup
|
||||
if (!newGlobal.hidden) {
|
||||
const newAll = newGlobal.all.map((name) => proxies.proxies[name])
|
||||
const newAll = (newGlobal.all || []).map((name) => proxies.proxies[name])
|
||||
groups.push({ ...newGlobal, all: newAll })
|
||||
}
|
||||
}
|
||||
@ -145,7 +169,7 @@ export const mihomoProxyDelay = async (proxy: string, url?: string): Promise<IMi
|
||||
const instance = await getAxios()
|
||||
return await instance.get(`/proxies/${encodeURIComponent(proxy)}/delay`, {
|
||||
params: {
|
||||
url: url || delayTestUrl || 'http://www.gstatic.com/generate_204',
|
||||
url: url || delayTestUrl || 'https://www.gstatic.com/generate_204',
|
||||
timeout: delayTestTimeout || 5000
|
||||
}
|
||||
})
|
||||
@ -157,7 +181,7 @@ export const mihomoGroupDelay = async (group: string, url?: string): Promise<IMi
|
||||
const instance = await getAxios()
|
||||
return await instance.get(`/group/${encodeURIComponent(group)}/delay`, {
|
||||
params: {
|
||||
url: url || delayTestUrl || 'http://www.gstatic.com/generate_204',
|
||||
url: url || delayTestUrl || 'https://www.gstatic.com/generate_204',
|
||||
timeout: delayTestTimeout || 5000
|
||||
}
|
||||
})
|
||||
@ -168,11 +192,63 @@ export const mihomoUpgrade = async (): Promise<void> => {
|
||||
return await instance.post('/upgrade')
|
||||
}
|
||||
|
||||
export const mihomoUpgradeUI = async (): Promise<void> => {
|
||||
const instance = await getAxios()
|
||||
return await instance.post('/upgrade/ui')
|
||||
}
|
||||
|
||||
export const mihomoUpgradeConfig = async (): Promise<void> => {
|
||||
mihomoApiLogger.info('mihomoUpgradeConfig called')
|
||||
|
||||
try {
|
||||
const instance = await getAxios()
|
||||
mihomoApiLogger.info('axios instance obtained')
|
||||
const { diffWorkDir = false } = await getAppConfig()
|
||||
const { current } = await import('../config').then((mod) => mod.getProfileConfig(true))
|
||||
const { mihomoWorkConfigPath } = await import('../utils/dirs')
|
||||
const configPath = diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work')
|
||||
mihomoApiLogger.info(`config path: ${configPath}`)
|
||||
const { existsSync } = await import('fs')
|
||||
if (!existsSync(configPath)) {
|
||||
mihomoApiLogger.info('config file does not exist, generating...')
|
||||
const { generateProfile } = await import('./factory')
|
||||
await generateProfile()
|
||||
}
|
||||
const response = await instance.put('/configs?force=true', {
|
||||
path: configPath
|
||||
})
|
||||
mihomoApiLogger.info(`config upgrade request completed ${response?.status || 'no status'}`)
|
||||
} catch (error) {
|
||||
mihomoApiLogger.error('Failed to upgrade config', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Smart 内核 API
|
||||
export const mihomoSmartGroupWeights = async (
|
||||
groupName: string
|
||||
): Promise<Record<string, number>> => {
|
||||
const instance = await getAxios()
|
||||
return await instance.get(`/group/${encodeURIComponent(groupName)}/weights`)
|
||||
}
|
||||
|
||||
export const mihomoSmartFlushCache = async (configName?: string): Promise<void> => {
|
||||
const instance = await getAxios()
|
||||
if (configName) {
|
||||
return await instance.post(`/cache/smart/flush/${encodeURIComponent(configName)}`)
|
||||
} else {
|
||||
return await instance.post('/cache/smart/flush')
|
||||
}
|
||||
}
|
||||
|
||||
export const startMihomoTraffic = async (): Promise<void> => {
|
||||
trafficRetry = MAX_RETRY
|
||||
await mihomoTraffic()
|
||||
}
|
||||
|
||||
export const stopMihomoTraffic = (): void => {
|
||||
trafficRetry = 0
|
||||
|
||||
if (mihomoTrafficWs) {
|
||||
mihomoTrafficWs.removeAllListeners()
|
||||
if (mihomoTrafficWs.readyState === WebSocket.OPEN) {
|
||||
@ -183,12 +259,16 @@ export const stopMihomoTraffic = (): void => {
|
||||
}
|
||||
|
||||
const mihomoTraffic = async (): Promise<void> => {
|
||||
mihomoTrafficWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/traffic`)
|
||||
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<void> => {
|
||||
const data = e.data as string
|
||||
const json = JSON.parse(data) as IMihomoTrafficInfo
|
||||
trafficRetry = 10
|
||||
trafficRetry = MAX_RETRY
|
||||
try {
|
||||
mainWindow?.webContents.send('mihomoTraffic', json)
|
||||
if (process.platform !== 'linux') {
|
||||
@ -208,11 +288,12 @@ const mihomoTraffic = async (): Promise<void> => {
|
||||
mihomoTrafficWs.onclose = (): void => {
|
||||
if (trafficRetry) {
|
||||
trafficRetry--
|
||||
mihomoTraffic()
|
||||
setTimeout(mihomoTraffic, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
mihomoTrafficWs.onerror = (): void => {
|
||||
mihomoTrafficWs.onerror = (error): void => {
|
||||
mihomoApiLogger.error('Traffic WebSocket error', error)
|
||||
if (mihomoTrafficWs) {
|
||||
mihomoTrafficWs.close()
|
||||
mihomoTrafficWs = null
|
||||
@ -221,10 +302,13 @@ const mihomoTraffic = async (): Promise<void> => {
|
||||
}
|
||||
|
||||
export const startMihomoMemory = async (): Promise<void> => {
|
||||
memoryRetry = MAX_RETRY
|
||||
await mihomoMemory()
|
||||
}
|
||||
|
||||
export const stopMihomoMemory = (): void => {
|
||||
memoryRetry = 0
|
||||
|
||||
if (mihomoMemoryWs) {
|
||||
mihomoMemoryWs.removeAllListeners()
|
||||
if (mihomoMemoryWs.readyState === WebSocket.OPEN) {
|
||||
@ -235,11 +319,13 @@ export const stopMihomoMemory = (): void => {
|
||||
}
|
||||
|
||||
const mihomoMemory = async (): Promise<void> => {
|
||||
mihomoMemoryWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/memory`)
|
||||
const dynamicIpcPath = getMihomoIpcPath()
|
||||
const wsUrl = `ws+unix:${dynamicIpcPath}:/memory`
|
||||
mihomoMemoryWs = new WebSocket(wsUrl)
|
||||
|
||||
mihomoMemoryWs.onmessage = (e): void => {
|
||||
const data = e.data as string
|
||||
memoryRetry = 10
|
||||
memoryRetry = MAX_RETRY
|
||||
try {
|
||||
mainWindow?.webContents.send('mihomoMemory', JSON.parse(data) as IMihomoMemoryInfo)
|
||||
} catch {
|
||||
@ -250,7 +336,7 @@ const mihomoMemory = async (): Promise<void> => {
|
||||
mihomoMemoryWs.onclose = (): void => {
|
||||
if (memoryRetry) {
|
||||
memoryRetry--
|
||||
mihomoMemory()
|
||||
setTimeout(mihomoMemory, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
@ -263,10 +349,13 @@ const mihomoMemory = async (): Promise<void> => {
|
||||
}
|
||||
|
||||
export const startMihomoLogs = async (): Promise<void> => {
|
||||
logsRetry = MAX_RETRY
|
||||
await mihomoLogs()
|
||||
}
|
||||
|
||||
export const stopMihomoLogs = (): void => {
|
||||
logsRetry = 0
|
||||
|
||||
if (mihomoLogsWs) {
|
||||
mihomoLogsWs.removeAllListeners()
|
||||
if (mihomoLogsWs.readyState === WebSocket.OPEN) {
|
||||
@ -278,12 +367,14 @@ export const stopMihomoLogs = (): void => {
|
||||
|
||||
const mihomoLogs = async (): Promise<void> => {
|
||||
const { 'log-level': logLevel = 'info' } = await getControledMihomoConfig()
|
||||
const dynamicIpcPath = getMihomoIpcPath()
|
||||
const wsUrl = `ws+unix:${dynamicIpcPath}:/logs?level=${logLevel}`
|
||||
|
||||
mihomoLogsWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/logs?level=${logLevel}`)
|
||||
mihomoLogsWs = new WebSocket(wsUrl)
|
||||
|
||||
mihomoLogsWs.onmessage = (e): void => {
|
||||
const data = e.data as string
|
||||
logsRetry = 10
|
||||
logsRetry = MAX_RETRY
|
||||
try {
|
||||
mainWindow?.webContents.send('mihomoLogs', JSON.parse(data) as IMihomoLogInfo)
|
||||
} catch {
|
||||
@ -294,7 +385,7 @@ const mihomoLogs = async (): Promise<void> => {
|
||||
mihomoLogsWs.onclose = (): void => {
|
||||
if (logsRetry) {
|
||||
logsRetry--
|
||||
mihomoLogs()
|
||||
setTimeout(mihomoLogs, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
@ -307,10 +398,13 @@ const mihomoLogs = async (): Promise<void> => {
|
||||
}
|
||||
|
||||
export const startMihomoConnections = async (): Promise<void> => {
|
||||
connectionsRetry = MAX_RETRY
|
||||
await mihomoConnections()
|
||||
}
|
||||
|
||||
export const stopMihomoConnections = (): void => {
|
||||
connectionsRetry = 0
|
||||
|
||||
if (mihomoConnectionsWs) {
|
||||
mihomoConnectionsWs.removeAllListeners()
|
||||
if (mihomoConnectionsWs.readyState === WebSocket.OPEN) {
|
||||
@ -321,11 +415,13 @@ export const stopMihomoConnections = (): void => {
|
||||
}
|
||||
|
||||
const mihomoConnections = async (): Promise<void> => {
|
||||
mihomoConnectionsWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/connections`)
|
||||
const dynamicIpcPath = getMihomoIpcPath()
|
||||
const wsUrl = `ws+unix:${dynamicIpcPath}:/connections`
|
||||
mihomoConnectionsWs = new WebSocket(wsUrl)
|
||||
|
||||
mihomoConnectionsWs.onmessage = (e): void => {
|
||||
const data = e.data as string
|
||||
connectionsRetry = 10
|
||||
connectionsRetry = MAX_RETRY
|
||||
try {
|
||||
mainWindow?.webContents.send('mihomoConnections', JSON.parse(data) as IMihomoConnectionsInfo)
|
||||
} catch {
|
||||
@ -336,7 +432,7 @@ const mihomoConnections = async (): Promise<void> => {
|
||||
mihomoConnectionsWs.onclose = (): void => {
|
||||
if (connectionsRetry) {
|
||||
connectionsRetry--
|
||||
mihomoConnections()
|
||||
setTimeout(mihomoConnections, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
@ -347,3 +443,33 @@ const mihomoConnections = async (): Promise<void> => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function SysProxyStatus(): Promise<boolean> {
|
||||
const appConfig = await getAppConfig()
|
||||
return appConfig.sysProxy.enable
|
||||
}
|
||||
|
||||
export const TunStatus = async (): Promise<boolean> => {
|
||||
const config = await getControledMihomoConfig()
|
||||
return config?.tun?.enable === true
|
||||
}
|
||||
|
||||
export function calculateTrayIconStatus(
|
||||
sysProxyEnabled: boolean,
|
||||
tunEnabled: boolean
|
||||
): 'white' | 'blue' | 'green' | 'red' {
|
||||
if (sysProxyEnabled && tunEnabled) {
|
||||
return 'red' // 系统代理 + TUN 同时启用(警告状态)
|
||||
} else if (sysProxyEnabled) {
|
||||
return 'blue' // 仅系统代理启用
|
||||
} else if (tunEnabled) {
|
||||
return 'green' // 仅 TUN 启用
|
||||
} else {
|
||||
return 'white' // 全关
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrayIconStatus(): Promise<'white' | 'blue' | 'green' | 'red'> {
|
||||
const [sysProxyEnabled, tunEnabled] = await Promise.all([SysProxyStatus(), TunStatus()])
|
||||
return calculateTrayIconStatus(sysProxyEnabled, tunEnabled)
|
||||
}
|
||||
|
||||
412
src/main/core/permissions.ts
Normal file
@ -0,0 +1,412 @@
|
||||
import { exec, execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { stat } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { app, dialog, ipcMain } from 'electron'
|
||||
import { getAppConfig, patchControledMihomoConfig } from '../config'
|
||||
import { mihomoCorePath, mihomoCoreDir } from '../utils/dirs'
|
||||
import { managerLogger } from '../utils/logger'
|
||||
import i18next from '../../shared/i18n'
|
||||
|
||||
const execPromise = promisify(exec)
|
||||
const execFilePromise = promisify(execFile)
|
||||
|
||||
// 内核名称白名单
|
||||
const ALLOWED_CORES = ['mihomo', 'mihomo-alpha', 'mihomo-smart'] as const
|
||||
type AllowedCore = (typeof ALLOWED_CORES)[number]
|
||||
|
||||
export function isValidCoreName(core: string): core is AllowedCore {
|
||||
return ALLOWED_CORES.includes(core as AllowedCore)
|
||||
}
|
||||
|
||||
export function validateCorePath(corePath: string): void {
|
||||
if (corePath.includes('..')) {
|
||||
throw new Error('Invalid core path: directory traversal detected')
|
||||
}
|
||||
|
||||
const dangerousChars = /[;&|`$(){}[\]<>'"\\]/
|
||||
if (dangerousChars.test(path.basename(corePath))) {
|
||||
throw new Error('Invalid core path: contains dangerous characters')
|
||||
}
|
||||
|
||||
const normalizedPath = path.normalize(path.resolve(corePath))
|
||||
const expectedDir = path.normalize(path.resolve(mihomoCoreDir()))
|
||||
|
||||
if (!normalizedPath.startsWith(expectedDir + path.sep) && normalizedPath !== expectedDir) {
|
||||
throw new Error('Invalid core path: not in expected directory')
|
||||
}
|
||||
}
|
||||
|
||||
function shellEscape(arg: string): string {
|
||||
return "'" + arg.replace(/'/g, "'\\''") + "'"
|
||||
}
|
||||
|
||||
// 会话管理员状态缓存
|
||||
let sessionAdminStatus: boolean | null = null
|
||||
|
||||
export async function initAdminStatus(): Promise<void> {
|
||||
if (process.platform === 'win32' && sessionAdminStatus === null) {
|
||||
sessionAdminStatus = await checkAdminPrivileges().catch(() => false)
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionAdminStatus(): boolean {
|
||||
if (process.platform !== 'win32') {
|
||||
return true
|
||||
}
|
||||
return sessionAdminStatus ?? false
|
||||
}
|
||||
|
||||
export async function checkAdminPrivileges(): Promise<boolean> {
|
||||
if (process.platform !== 'win32') {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
await execPromise('chcp 65001 >nul 2>&1 && fltmc', { encoding: 'utf8' })
|
||||
managerLogger.info('Admin privileges confirmed via fltmc')
|
||||
return true
|
||||
} catch (fltmcError: unknown) {
|
||||
const errorCode = (fltmcError as { code?: number })?.code || 0
|
||||
managerLogger.debug(`fltmc failed with code ${errorCode}, trying net session as fallback`)
|
||||
|
||||
try {
|
||||
await execPromise('chcp 65001 >nul 2>&1 && net session', { encoding: 'utf8' })
|
||||
managerLogger.info('Admin privileges confirmed via net session')
|
||||
return true
|
||||
} catch (netSessionError: unknown) {
|
||||
const netErrorCode = (netSessionError as { code?: number })?.code || 0
|
||||
managerLogger.debug(
|
||||
`Both fltmc and net session failed, no admin privileges. Error codes: fltmc=${errorCode}, net=${netErrorCode}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkMihomoCorePermissions(): Promise<boolean> {
|
||||
const { core = 'mihomo' } = await getAppConfig()
|
||||
const corePath = mihomoCorePath(core)
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
return await checkAdminPrivileges()
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin' || process.platform === 'linux') {
|
||||
const stats = await stat(corePath)
|
||||
return (stats.mode & 0o4000) !== 0 && stats.uid === 0
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function checkHighPrivilegeCore(): Promise<boolean> {
|
||||
try {
|
||||
const { core = 'mihomo' } = await getAppConfig()
|
||||
const corePath = mihomoCorePath(core)
|
||||
|
||||
managerLogger.info(`Checking high privilege core: ${corePath}`)
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (!existsSync(corePath)) {
|
||||
managerLogger.info('Core file does not exist')
|
||||
return false
|
||||
}
|
||||
|
||||
const hasHighPrivilegeProcess = await checkHighPrivilegeMihomoProcess()
|
||||
if (hasHighPrivilegeProcess) {
|
||||
managerLogger.info('Found high privilege mihomo process running')
|
||||
return true
|
||||
}
|
||||
|
||||
const isAdmin = await checkAdminPrivileges()
|
||||
managerLogger.info(`Current process admin privileges: ${isAdmin}`)
|
||||
return isAdmin
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin' || process.platform === 'linux') {
|
||||
managerLogger.info('Non-Windows platform, skipping high privilege core check')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
managerLogger.error('Failed to check high privilege core', error)
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function checkHighPrivilegeMihomoProcess(): Promise<boolean> {
|
||||
const mihomoExecutables =
|
||||
process.platform === 'win32'
|
||||
? ['mihomo.exe', 'mihomo-alpha.exe', 'mihomo-smart.exe']
|
||||
: ['mihomo', 'mihomo-alpha', 'mihomo-smart']
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
for (const executable of mihomoExecutables) {
|
||||
try {
|
||||
const { stdout } = await execPromise(
|
||||
`chcp 65001 >nul 2>&1 && tasklist /FI "IMAGENAME eq ${executable}" /FO CSV`,
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
const lines = stdout.split('\n').filter((line) => line.includes(executable))
|
||||
|
||||
if (lines.length > 0) {
|
||||
managerLogger.info(`Found ${lines.length} ${executable} processes running`)
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split(',')
|
||||
if (parts.length >= 2) {
|
||||
const pid = parts[1].replace(/"/g, '').trim()
|
||||
try {
|
||||
const { stdout: processInfo } = await execPromise(
|
||||
`powershell -NoProfile -Command "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Process -Id ${pid} | Select-Object Name,Id,Path,CommandLine | ConvertTo-Json"`,
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
const processJson = JSON.parse(processInfo)
|
||||
managerLogger.info(`Process ${pid} info: ${processInfo.substring(0, 200)}`)
|
||||
|
||||
if (processJson.Name.includes('mihomo') && processJson.Path === null) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
managerLogger.info(`Cannot get info for process ${pid}, might be high privilege`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
managerLogger.error(`Failed to check ${executable} processes`, error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let foundProcesses = false
|
||||
|
||||
for (const executable of mihomoExecutables) {
|
||||
try {
|
||||
const { stdout } = await execPromise(`ps aux | grep ${executable} | grep -v grep`)
|
||||
const lines = stdout
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() && line.includes(executable))
|
||||
|
||||
if (lines.length > 0) {
|
||||
foundProcesses = true
|
||||
managerLogger.info(`Found ${lines.length} ${executable} processes running`)
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/)
|
||||
if (parts.length >= 1) {
|
||||
const user = parts[0]
|
||||
managerLogger.info(`${executable} process running as user: ${user}`)
|
||||
|
||||
if (user === 'root') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundProcesses) {
|
||||
managerLogger.info('No mihomo processes found running')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
managerLogger.error('Failed to check high privilege mihomo process', error)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function grantTunPermissions(): Promise<void> {
|
||||
const { core = 'mihomo' } = await getAppConfig()
|
||||
|
||||
if (!isValidCoreName(core)) {
|
||||
throw new Error(`Invalid core name: ${core}. Allowed values: ${ALLOWED_CORES.join(', ')}`)
|
||||
}
|
||||
|
||||
const corePath = mihomoCorePath(core)
|
||||
validateCorePath(corePath)
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const escapedPath = shellEscape(corePath)
|
||||
const script = `do shell script "chown root:admin ${escapedPath} && chmod +sx ${escapedPath}" with administrator privileges`
|
||||
await execFilePromise('osascript', ['-e', script])
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
await execFilePromise('pkexec', ['chown', 'root:root', corePath])
|
||||
await execFilePromise('pkexec', ['chmod', '+sx', corePath])
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
throw new Error('Windows platform requires running as administrator')
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartAsAdmin(forTun: boolean = true): Promise<void> {
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('This function is only available on Windows')
|
||||
}
|
||||
|
||||
// 先停止 Core,避免新旧进程冲突
|
||||
try {
|
||||
const { stopCore } = await import('./manager')
|
||||
managerLogger.info('Stopping core before admin restart...')
|
||||
await stopCore(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
} catch (error) {
|
||||
managerLogger.warn('Failed to stop core before restart:', error)
|
||||
}
|
||||
|
||||
const exePath = process.execPath
|
||||
const args = process.argv.slice(1).filter((arg) => arg !== '--admin-restart-for-tun')
|
||||
const restartArgs = forTun ? [...args, '--admin-restart-for-tun'] : args
|
||||
|
||||
const escapedExePath = exePath.replace(/'/g, "''")
|
||||
const argsString = restartArgs.map((arg) => arg.replace(/'/g, "''")).join("', '")
|
||||
|
||||
// 使用 Start-Sleep 延迟启动,确保旧进程完全退出后再启动新进程
|
||||
const command =
|
||||
restartArgs.length > 0
|
||||
? `powershell -NoProfile -Command "Start-Sleep -Milliseconds 1000; Start-Process -FilePath '${escapedExePath}' -ArgumentList '${argsString}' -Verb RunAs"`
|
||||
: `powershell -NoProfile -Command "Start-Sleep -Milliseconds 1000; Start-Process -FilePath '${escapedExePath}' -Verb RunAs"`
|
||||
|
||||
managerLogger.info('Restarting as administrator with command', command)
|
||||
|
||||
// 先启动 PowerShell(它会等待 1 秒),然后立即退出当前进程
|
||||
exec(command, { windowsHide: true }, (error) => {
|
||||
if (error) {
|
||||
managerLogger.error('Failed to start PowerShell for admin restart', error)
|
||||
}
|
||||
})
|
||||
managerLogger.info('PowerShell command started, quitting app immediately')
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
export async function requestTunPermissions(): Promise<void> {
|
||||
if (process.platform === 'win32') {
|
||||
await restartAsAdmin()
|
||||
} else {
|
||||
const hasPermissions = await checkMihomoCorePermissions()
|
||||
if (!hasPermissions) {
|
||||
await grantTunPermissions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function showTunPermissionDialog(): Promise<boolean> {
|
||||
managerLogger.info('Preparing TUN permission dialog...')
|
||||
|
||||
const title = i18next.t('tun.permissions.title') || '需要管理员权限'
|
||||
const message =
|
||||
i18next.t('tun.permissions.message') ||
|
||||
'启用 TUN 模式需要管理员权限,是否现在重启应用获取权限?'
|
||||
const confirmText = i18next.t('common.confirm') || '确认'
|
||||
const cancelText = i18next.t('common.cancel') || '取消'
|
||||
|
||||
const choice = dialog.showMessageBoxSync({
|
||||
type: 'warning',
|
||||
title,
|
||||
message,
|
||||
buttons: [confirmText, cancelText],
|
||||
defaultId: 0,
|
||||
cancelId: 1
|
||||
})
|
||||
|
||||
managerLogger.info(`TUN permission dialog choice: ${choice}`)
|
||||
return choice === 0
|
||||
}
|
||||
|
||||
export async function showErrorDialog(title: string, message: string): Promise<void> {
|
||||
const okText = i18next.t('common.confirm') || '确认'
|
||||
|
||||
dialog.showMessageBoxSync({
|
||||
type: 'error',
|
||||
title,
|
||||
message,
|
||||
buttons: [okText],
|
||||
defaultId: 0
|
||||
})
|
||||
}
|
||||
|
||||
export async function validateTunPermissionsOnStartup(
|
||||
_restartCore: () => Promise<void>
|
||||
): Promise<void> {
|
||||
const { getControledMihomoConfig } = await import('../config')
|
||||
const { tun } = await getControledMihomoConfig()
|
||||
|
||||
if (!tun?.enable) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasPermissions = await checkMihomoCorePermissions()
|
||||
|
||||
if (!hasPermissions) {
|
||||
// 启动时没有权限,静默禁用 TUN,不弹窗打扰用户
|
||||
managerLogger.warn(
|
||||
'TUN is enabled but insufficient permissions detected, auto-disabling TUN...'
|
||||
)
|
||||
await patchControledMihomoConfig({ tun: { enable: false } })
|
||||
|
||||
const { mainWindow } = await import('../index')
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
|
||||
managerLogger.info('TUN auto-disabled due to insufficient permissions on startup')
|
||||
} else {
|
||||
managerLogger.info('TUN permissions validated successfully')
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAdminRestartForTun(restartCore: () => Promise<void>): Promise<void> {
|
||||
if (process.argv.includes('--admin-restart-for-tun')) {
|
||||
managerLogger.info('Detected admin restart for TUN mode, auto-enabling TUN...')
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const hasAdminPrivileges = await checkAdminPrivileges()
|
||||
if (hasAdminPrivileges) {
|
||||
await patchControledMihomoConfig({ tun: { enable: true }, dns: { enable: true } })
|
||||
|
||||
const { checkAutoRun, enableAutoRun } = await import('../sys/autoRun')
|
||||
const autoRunEnabled = await checkAutoRun()
|
||||
if (autoRunEnabled) {
|
||||
await enableAutoRun()
|
||||
}
|
||||
|
||||
await restartCore()
|
||||
|
||||
managerLogger.info('TUN mode auto-enabled after admin restart')
|
||||
|
||||
const { mainWindow } = await import('../index')
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
} else {
|
||||
managerLogger.warn('Admin restart detected but no admin privileges found')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
managerLogger.error('Failed to auto-enable TUN after admin restart', error)
|
||||
}
|
||||
} else {
|
||||
await validateTunPermissionsOnStartup(restartCore)
|
||||
}
|
||||
}
|
||||
|
||||
export function checkTunPermissions(): Promise<boolean> {
|
||||
return checkMihomoCorePermissions()
|
||||
}
|
||||
|
||||
export function manualGrantCorePermition(): Promise<void> {
|
||||
return grantTunPermissions()
|
||||
}
|
||||
139
src/main/core/process.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { rm } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { managerLogger } from '../utils/logger'
|
||||
import { getAxios } from './mihomoApi'
|
||||
|
||||
const execPromise = promisify(exec)
|
||||
|
||||
// 常量
|
||||
const CORE_READY_MAX_RETRIES = 30
|
||||
const CORE_READY_RETRY_INTERVAL_MS = 100
|
||||
|
||||
export async function cleanupSocketFile(): Promise<void> {
|
||||
if (process.platform === 'win32') {
|
||||
await cleanupWindowsNamedPipes()
|
||||
} else {
|
||||
await cleanupUnixSockets()
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupWindowsNamedPipes(): Promise<void> {
|
||||
try {
|
||||
try {
|
||||
const { stdout } = await execPromise(
|
||||
`powershell -NoProfile -Command "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Process | Where-Object {$_.ProcessName -like '*mihomo*'} | Select-Object Id,ProcessName | ConvertTo-Json"`,
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
|
||||
if (stdout.trim()) {
|
||||
managerLogger.info(`Found potential pipe-blocking processes: ${stdout}`)
|
||||
|
||||
try {
|
||||
const processes = JSON.parse(stdout)
|
||||
const processArray = Array.isArray(processes) ? processes : [processes]
|
||||
|
||||
for (const proc of processArray) {
|
||||
const pid = proc.Id
|
||||
if (pid && pid !== process.pid) {
|
||||
await terminateProcess(pid)
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
managerLogger.warn('Failed to parse process list JSON:', parseError)
|
||||
await fallbackTextParsing(stdout)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
managerLogger.warn('Failed to check mihomo processes:', error)
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
} catch (error) {
|
||||
managerLogger.error('Windows named pipe cleanup failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function terminateProcess(pid: number): Promise<void> {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
process.kill(pid, 'SIGTERM')
|
||||
managerLogger.info(`Terminated process ${pid} to free pipe`)
|
||||
} catch (error: unknown) {
|
||||
if ((error as { code?: string })?.code !== 'ESRCH') {
|
||||
managerLogger.warn(`Failed to terminate process ${pid}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fallbackTextParsing(stdout: string): Promise<void> {
|
||||
const lines = stdout.split('\n').filter((line) => line.includes('mihomo'))
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\d+)/)
|
||||
if (match) {
|
||||
const pid = parseInt(match[1])
|
||||
if (pid !== process.pid) {
|
||||
await terminateProcess(pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupUnixSockets(): Promise<void> {
|
||||
try {
|
||||
const socketPaths = [
|
||||
'/tmp/mihomo-party.sock',
|
||||
'/tmp/mihomo-party-admin.sock',
|
||||
`/tmp/mihomo-party-${process.getuid?.() || 'user'}.sock`
|
||||
]
|
||||
|
||||
for (const socketPath of socketPaths) {
|
||||
try {
|
||||
if (existsSync(socketPath)) {
|
||||
await rm(socketPath)
|
||||
managerLogger.info(`Cleaned up socket file: ${socketPath}`)
|
||||
}
|
||||
} catch (error) {
|
||||
managerLogger.warn(`Failed to cleanup socket file ${socketPath}:`, error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
managerLogger.error('Unix socket cleanup failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateWindowsPipeAccess(pipePath: string): Promise<void> {
|
||||
try {
|
||||
managerLogger.info(`Validating pipe access for: ${pipePath}`)
|
||||
managerLogger.info(`Pipe validation completed for: ${pipePath}`)
|
||||
} catch (error) {
|
||||
managerLogger.error('Windows pipe validation failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForCoreReady(): Promise<void> {
|
||||
for (let i = 0; i < CORE_READY_MAX_RETRIES; i++) {
|
||||
try {
|
||||
const axios = await getAxios(true)
|
||||
await axios.get('/')
|
||||
managerLogger.info(
|
||||
`Core ready after ${i + 1} attempts (${(i + 1) * CORE_READY_RETRY_INTERVAL_MS}ms)`
|
||||
)
|
||||
return
|
||||
} catch {
|
||||
if (i === 0) {
|
||||
managerLogger.info('Waiting for core to be ready...')
|
||||
}
|
||||
|
||||
if (i === CORE_READY_MAX_RETRIES - 1) {
|
||||
managerLogger.warn(
|
||||
`Core not ready after ${CORE_READY_MAX_RETRIES} attempts, proceeding anyway`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, CORE_READY_RETRY_INTERVAL_MS))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,62 +1,141 @@
|
||||
import { addProfileItem, getCurrentProfileItem, getProfileConfig } from '../config'
|
||||
import { Cron } from 'croner'
|
||||
import { addProfileItem, getCurrentProfileItem, getProfileConfig, getProfileItem } from '../config'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
const intervalPool: Record<string, NodeJS.Timeout> = {}
|
||||
const intervalPool: Record<string, Cron | NodeJS.Timeout> = {}
|
||||
const delayedUpdatePool: Record<string, NodeJS.Timeout> = {}
|
||||
|
||||
async function updateProfile(id: string): Promise<void> {
|
||||
const item = await getProfileItem(id)
|
||||
if (item && item.type === 'remote') {
|
||||
await addProfileItem(item)
|
||||
}
|
||||
}
|
||||
|
||||
export async function initProfileUpdater(): Promise<void> {
|
||||
const { items, current } = await getProfileConfig()
|
||||
const { items = [], current } = await getProfileConfig()
|
||||
const currentItem = await getCurrentProfileItem()
|
||||
|
||||
for (const item of items.filter((i) => i.id !== current)) {
|
||||
if (item.type === 'remote' && item.interval) {
|
||||
intervalPool[item.id] = setTimeout(
|
||||
async () => {
|
||||
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 addProfileItem(item)
|
||||
await updateProfile(itemId)
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
await logger.warn(`[ProfileUpdater] Failed to update profile ${itemId}:`, e)
|
||||
}
|
||||
},
|
||||
item.interval * 60 * 1000
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await addProfileItem(item)
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
await logger.warn(`[ProfileUpdater] Failed to init profile ${item.name}:`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentItem?.type === 'remote' && currentItem.interval) {
|
||||
intervalPool[currentItem.id] = setTimeout(
|
||||
async () => {
|
||||
|
||||
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 addProfileItem(currentItem)
|
||||
await updateProfile(currentId)
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
await logger.warn(`[ProfileUpdater] Failed to update current profile:`, e)
|
||||
}
|
||||
},
|
||||
currentItem.interval * 60 * 1000 + 10000 // +10s
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await addProfileItem(currentItem)
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
await logger.warn(`[ProfileUpdater] Failed to init current profile:`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function addProfileUpdater(item: IProfileItem): Promise<void> {
|
||||
if (item.type === 'remote' && item.interval) {
|
||||
if (item.type === 'remote' && item.autoUpdate && item.interval) {
|
||||
if (intervalPool[item.id]) {
|
||||
clearTimeout(intervalPool[item.id])
|
||||
if (intervalPool[item.id] instanceof Cron) {
|
||||
;(intervalPool[item.id] as Cron).stop()
|
||||
} else {
|
||||
clearInterval(intervalPool[item.id] as NodeJS.Timeout)
|
||||
}
|
||||
}
|
||||
intervalPool[item.id] = setTimeout(
|
||||
async () => {
|
||||
|
||||
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 addProfileItem(item)
|
||||
await updateProfile(itemId)
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
await logger.warn(`[ProfileUpdater] Failed to update profile ${itemId}:`, e)
|
||||
}
|
||||
},
|
||||
item.interval * 60 * 1000
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeProfileUpdater(id: string): Promise<void> {
|
||||
if (intervalPool[id]) {
|
||||
if (intervalPool[id] instanceof Cron) {
|
||||
;(intervalPool[id] as Cron).stop()
|
||||
} else {
|
||||
clearInterval(intervalPool[id] as NodeJS.Timeout)
|
||||
}
|
||||
delete intervalPool[id]
|
||||
}
|
||||
if (delayedUpdatePool[id]) {
|
||||
clearTimeout(delayedUpdatePool[id])
|
||||
delete delayedUpdatePool[id]
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,21 @@
|
||||
import axios from 'axios'
|
||||
import * as chromeRequest from '../utils/chromeRequest'
|
||||
import { subStorePort } from '../resolve/server'
|
||||
import { getAppConfig } from '../config'
|
||||
|
||||
export async function subStoreSubs(): Promise<ISubStoreSub[]> {
|
||||
const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig()
|
||||
const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}`
|
||||
const res = await axios.get(`${baseUrl}/api/subs`, { responseType: 'json' })
|
||||
return res.data.data as ISubStoreSub[]
|
||||
const res = await chromeRequest.get<{ data: ISubStoreSub[] }>(`${baseUrl}/api/subs`, {
|
||||
responseType: 'json'
|
||||
})
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function subStoreCollections(): Promise<ISubStoreSub[]> {
|
||||
const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig()
|
||||
const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}`
|
||||
const res = await axios.get(`${baseUrl}/api/collections`, { responseType: 'json' })
|
||||
return res.data.data as ISubStoreSub[]
|
||||
const res = await chromeRequest.get<{ data: ISubStoreSub[] }>(`${baseUrl}/api/collections`, {
|
||||
responseType: 'json'
|
||||
})
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
32
src/main/deeplink.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Notification } from 'electron'
|
||||
import i18next from 'i18next'
|
||||
import { addProfileItem } from './config'
|
||||
import { mainWindow } from './window'
|
||||
import { safeShowErrorBox } from './utils/init'
|
||||
|
||||
export async function handleDeepLink(url: string): Promise<void> {
|
||||
if (!url.startsWith('clash://') && !url.startsWith('mihomo://')) return
|
||||
|
||||
const urlObj = new URL(url)
|
||||
switch (urlObj.host) {
|
||||
case 'install-config': {
|
||||
try {
|
||||
const profileUrl = urlObj.searchParams.get('url')
|
||||
const profileName = urlObj.searchParams.get('name')
|
||||
if (!profileUrl) {
|
||||
throw new Error(i18next.t('profiles.error.urlParamMissing'))
|
||||
}
|
||||
await addProfileItem({
|
||||
type: 'remote',
|
||||
name: profileName ?? undefined,
|
||||
url: profileUrl
|
||||
})
|
||||
mainWindow?.webContents.send('profileConfigUpdated')
|
||||
new Notification({ title: i18next.t('profiles.notification.importSuccess') }).show()
|
||||
} catch (e) {
|
||||
safeShowErrorBox('profiles.error.importFailed', `${url}\n${e}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,135 +1,144 @@
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import { registerIpcMainHandlers } from './utils/ipc'
|
||||
import windowStateKeeper from 'electron-window-state'
|
||||
import { app, shell, BrowserWindow, Menu, dialog, Notification, powerMonitor } from 'electron'
|
||||
import { addProfileItem, getAppConfig, patchAppConfig } from './config'
|
||||
import { quitWithoutCore, startCore, stopCore } from './core/manager'
|
||||
import { triggerSysProxy } from './sys/sysproxy'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { createTray, hideDockIcon, showDockIcon } from './resolve/tray'
|
||||
import { init } from './utils/init'
|
||||
import { join } from 'path'
|
||||
import { initShortcut } from './resolve/shortcut'
|
||||
import { execSync, spawn, exec } from 'child_process'
|
||||
import { createElevateTask } from './sys/misc'
|
||||
import { promisify } from 'util'
|
||||
import { stat } from 'fs/promises'
|
||||
import { initProfileUpdater } from './core/profileUpdater'
|
||||
import { existsSync, writeFileSync } from 'fs'
|
||||
import { exePath, taskDir } from './utils/dirs'
|
||||
import path from 'path'
|
||||
import { startMonitor } from './resolve/trafficMonitor'
|
||||
import { showFloatingWindow } from './resolve/floatingWindow'
|
||||
import iconv from 'iconv-lite'
|
||||
import { initI18n } from '../shared/i18n'
|
||||
import i18next from 'i18next'
|
||||
|
||||
async function fixUserDataPermissions(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return
|
||||
|
||||
const userDataPath = app.getPath('userData')
|
||||
if (!existsSync(userDataPath)) return
|
||||
import { execSync } from 'child_process'
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { app, dialog } from 'electron'
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
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}"`)
|
||||
}
|
||||
const stdout = execSync('powershell -NoProfile -Command "$PSVersionTable.PSVersion.Major"', {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000
|
||||
})
|
||||
const major = parseInt(stdout.trim(), 10)
|
||||
if (!isNaN(major) && major < 5) {
|
||||
const isZh = Intl.DateTimeFormat().resolvedOptions().locale?.startsWith('zh')
|
||||
const title = isZh ? '需要更新 PowerShell' : 'PowerShell Update Required'
|
||||
const message = isZh
|
||||
? `检测到您的 PowerShell 版本为 ${major}.x,部分功能需要 PowerShell 5.1 才能正常运行。\\n\\n请访问 Microsoft 官网下载并安装 Windows Management Framework 5.1。`
|
||||
: `Detected PowerShell version ${major}.x. Some features require PowerShell 5.1.\\n\\nPlease install Windows Management Framework 5.1 from the Microsoft website.`
|
||||
execSync(
|
||||
`mshta "javascript:var sh=new ActiveXObject('WScript.Shell');sh.Popup('${message}',0,'${title}',48);close()"`,
|
||||
{ timeout: 60000 }
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
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'
|
||||
|
||||
let quitTimeout: NodeJS.Timeout | null = null
|
||||
export let mainWindow: BrowserWindow | null = null
|
||||
const mainLogger = createLogger('Main')
|
||||
|
||||
if (process.platform === 'win32' && !is.dev && !process.argv.includes('noadmin')) {
|
||||
try {
|
||||
createElevateTask()
|
||||
} catch (createError) {
|
||||
try {
|
||||
if (process.argv.slice(1).length > 0) {
|
||||
writeFileSync(path.join(taskDir(), 'param.txt'), process.argv.slice(1).join(' '))
|
||||
} else {
|
||||
writeFileSync(path.join(taskDir(), 'param.txt'), 'empty')
|
||||
}
|
||||
if (!existsSync(path.join(taskDir(), 'mihomo-party-run.exe'))) {
|
||||
throw new Error('mihomo-party-run.exe not found')
|
||||
} else {
|
||||
execSync('%SystemRoot%\\System32\\schtasks.exe /run /tn mihomo-party-run')
|
||||
}
|
||||
} catch (e) {
|
||||
let createErrorStr = `${createError}`
|
||||
let eStr = `${e}`
|
||||
try {
|
||||
createErrorStr = iconv.decode((createError as { stderr: Buffer }).stderr, 'gbk')
|
||||
eStr = iconv.decode((e as { stderr: Buffer }).stderr, 'gbk')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
dialog.showErrorBox(
|
||||
i18next.t('common.error.adminRequired'),
|
||||
`${i18next.t('common.error.adminRequired')}\n${createErrorStr}\n${eStr}`
|
||||
)
|
||||
} finally {
|
||||
app.exit()
|
||||
}
|
||||
}
|
||||
export { mainWindow, showMainWindow, triggerMainWindow, closeMainWindow }
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
}
|
||||
|
||||
async function initApp(): Promise<void> {
|
||||
await fixUserDataPermissions()
|
||||
}
|
||||
|
||||
initApp()
|
||||
.then(() => {
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
initApp().catch((e) => {
|
||||
safeShowErrorBox('common.error.initFailed', `${e}`)
|
||||
app.quit()
|
||||
})
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
setupPlatformSpecifics()
|
||||
|
||||
async function checkHighPrivilegeCoreEarly(): Promise<void> {
|
||||
if (process.platform !== 'win32') return
|
||||
|
||||
try {
|
||||
await initBasic()
|
||||
const isCurrentAppAdmin = await checkAdminPrivileges()
|
||||
if (isCurrentAppAdmin) return
|
||||
|
||||
const hasHighPrivilegeCore = await checkHighPrivilegeCore()
|
||||
if (!hasHighPrivilegeCore) return
|
||||
|
||||
try {
|
||||
const appConfig = await getAppConfig()
|
||||
const language = appConfig.language || (app.getLocale().startsWith('zh') ? 'zh-CN' : 'en-US')
|
||||
await initI18n({ lng: language })
|
||||
} catch {
|
||||
await initI18n({ lng: 'zh-CN' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore permission fix errors
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
export function customRelaunch(): void {
|
||||
const script = `while kill -0 ${process.pid} 2>/dev/null; do
|
||||
sleep 0.1
|
||||
done
|
||||
${process.argv.join(' ')} & disown
|
||||
exit
|
||||
`
|
||||
spawn('sh', ['-c', `"${script}"`], {
|
||||
shell: true,
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
} catch (e) {
|
||||
mainLogger.error('Failed to check high privilege core', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
app.relaunch = customRelaunch
|
||||
async function initHardwareAcceleration(): Promise<void> {
|
||||
try {
|
||||
await initBasic()
|
||||
const { disableHardwareAcceleration = false } = await getAppConfig()
|
||||
if (disableHardwareAcceleration) {
|
||||
app.disableHardwareAcceleration()
|
||||
}
|
||||
} catch (e) {
|
||||
mainLogger.warn('Failed to read hardware acceleration config', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && !exePath().startsWith('C')) {
|
||||
// https://github.com/electron/electron/issues/43278
|
||||
// https://github.com/electron/electron/issues/36698
|
||||
app.commandLine.appendSwitch('in-process-gpu')
|
||||
}
|
||||
|
||||
const initPromise = init()
|
||||
initHardwareAcceleration()
|
||||
setupAppLifecycle()
|
||||
|
||||
app.on('second-instance', async (_event, commandline) => {
|
||||
showMainWindow()
|
||||
@ -144,248 +153,96 @@ app.on('open-url', async (_event, url) => {
|
||||
await handleDeepLink(url)
|
||||
})
|
||||
|
||||
app.on('before-quit', async (e) => {
|
||||
e.preventDefault()
|
||||
triggerSysProxy(false)
|
||||
await stopCore()
|
||||
app.exit()
|
||||
})
|
||||
|
||||
powerMonitor.on('shutdown', async () => {
|
||||
triggerSysProxy(false)
|
||||
await stopCore()
|
||||
app.exit()
|
||||
})
|
||||
|
||||
// 获取系统语言
|
||||
function getSystemLanguage(): 'zh-CN' | 'en-US' {
|
||||
const locale = app.getLocale()
|
||||
return locale.startsWith('zh') ? 'zh-CN' : 'en-US'
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(async () => {
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId('party.mihomo.app')
|
||||
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 })
|
||||
await initPromise
|
||||
return appConfig
|
||||
} catch (e) {
|
||||
dialog.showErrorBox(i18next.t('common.error.initFailed'), `${e}`)
|
||||
safeShowErrorBox('common.error.initFailed', `${e}`)
|
||||
app.quit()
|
||||
throw e
|
||||
}
|
||||
try {
|
||||
const [startPromise] = await startCore()
|
||||
startPromise.then(async () => {
|
||||
await initProfileUpdater()
|
||||
})
|
||||
} catch (e) {
|
||||
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`)
|
||||
}
|
||||
try {
|
||||
await startMonitor()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})()
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
electronApp.setAppUserModelId('party.mihomo.app')
|
||||
|
||||
const appConfig = await initPromise
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
const { showFloatingWindow: showFloating = false, disableTray = false } = await getAppConfig()
|
||||
|
||||
registerIpcMainHandlers()
|
||||
await createWindow()
|
||||
|
||||
const createWindowPromise = createWindow()
|
||||
|
||||
let coreStarted = false
|
||||
const coreStartPromise = (async (): Promise<void> => {
|
||||
try {
|
||||
initCoreWatcher()
|
||||
const startPromises = await startCore()
|
||||
if (startPromises.length > 0) {
|
||||
startPromises[0].then(async () => {
|
||||
await initProfileUpdater()
|
||||
await initWebdavBackupScheduler()
|
||||
await checkAdminRestartForTun()
|
||||
})
|
||||
}
|
||||
coreStarted = true
|
||||
} catch (e) {
|
||||
safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`)
|
||||
}
|
||||
})()
|
||||
|
||||
const monitorPromise = (async (): Promise<void> => {
|
||||
try {
|
||||
await startMonitor()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})()
|
||||
|
||||
await createWindowPromise
|
||||
|
||||
const { showFloatingWindow: showFloating = false, disableTray = false } = appConfig
|
||||
const uiTasks: Promise<void>[] = [initShortcut()]
|
||||
|
||||
if (showFloating) {
|
||||
showFloatingWindow()
|
||||
uiTasks.push(
|
||||
(async () => {
|
||||
try {
|
||||
await showFloatingWindow()
|
||||
} catch (error) {
|
||||
await logger.error('Failed to create floating window on startup', error)
|
||||
}
|
||||
})()
|
||||
)
|
||||
}
|
||||
|
||||
if (!disableTray) {
|
||||
await createTray()
|
||||
uiTasks.push(createTray())
|
||||
}
|
||||
await initShortcut()
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
|
||||
await Promise.all(uiTasks)
|
||||
await Promise.all([coreStartPromise, monitorPromise])
|
||||
|
||||
if (coreStarted) {
|
||||
mainWindow?.webContents.send('core-started')
|
||||
}
|
||||
|
||||
app.on('activate', () => {
|
||||
showMainWindow()
|
||||
})
|
||||
})
|
||||
|
||||
async function handleDeepLink(url: string): Promise<void> {
|
||||
if (!url.startsWith('clash://') && !url.startsWith('mihomo://')) return
|
||||
|
||||
const urlObj = new URL(url)
|
||||
switch (urlObj.host) {
|
||||
case 'install-config': {
|
||||
try {
|
||||
const profileUrl = urlObj.searchParams.get('url')
|
||||
const profileName = urlObj.searchParams.get('name')
|
||||
if (!profileUrl) {
|
||||
throw new Error(i18next.t('profiles.error.urlParamMissing'))
|
||||
}
|
||||
await addProfileItem({
|
||||
type: 'remote',
|
||||
name: profileName ?? undefined,
|
||||
url: profileUrl
|
||||
})
|
||||
mainWindow?.webContents.send('profileConfigUpdated')
|
||||
new Notification({ title: i18next.t('profiles.notification.importSuccess') }).show()
|
||||
break
|
||||
} catch (e) {
|
||||
dialog.showErrorBox(i18next.t('profiles.error.importFailed'), `${url}\n${e}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWindow(): Promise<void> {
|
||||
const { useWindowFrame = false } = await getAppConfig()
|
||||
const mainWindowState = windowStateKeeper({
|
||||
defaultWidth: 800,
|
||||
defaultHeight: 600,
|
||||
file: 'window-state.json'
|
||||
})
|
||||
// https://github.com/electron/electron/issues/16521#issuecomment-582955104
|
||||
Menu.setApplicationMenu(null)
|
||||
mainWindow = new BrowserWindow({
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
width: mainWindowState.width,
|
||||
height: mainWindowState.height,
|
||||
x: mainWindowState.x,
|
||||
y: mainWindowState.y,
|
||||
show: false,
|
||||
frame: useWindowFrame,
|
||||
fullscreenable: false,
|
||||
titleBarStyle: useWindowFrame ? 'default' : 'hidden',
|
||||
titleBarOverlay: useWindowFrame
|
||||
? false
|
||||
: {
|
||||
height: 49
|
||||
},
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === 'linux' ? { icon: icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
spellcheck: false,
|
||||
sandbox: false,
|
||||
devTools: true
|
||||
}
|
||||
})
|
||||
mainWindowState.manage(mainWindow)
|
||||
mainWindow.on('ready-to-show', async () => {
|
||||
const {
|
||||
silentStart = false,
|
||||
autoQuitWithoutCore = false,
|
||||
autoQuitWithoutCoreDelay = 60
|
||||
} = await getAppConfig()
|
||||
if (autoQuitWithoutCore && !mainWindow?.isVisible()) {
|
||||
if (quitTimeout) {
|
||||
clearTimeout(quitTimeout)
|
||||
}
|
||||
quitTimeout = setTimeout(async () => {
|
||||
await quitWithoutCore()
|
||||
}, autoQuitWithoutCoreDelay * 1000)
|
||||
}
|
||||
if (!silentStart) {
|
||||
if (quitTimeout) {
|
||||
clearTimeout(quitTimeout)
|
||||
}
|
||||
mainWindow?.show()
|
||||
mainWindow?.focusOnWebView()
|
||||
}
|
||||
})
|
||||
mainWindow.webContents.on('did-fail-load', () => {
|
||||
mainWindow?.webContents.reload()
|
||||
})
|
||||
|
||||
mainWindow.on('show', () => {
|
||||
showDockIcon()
|
||||
})
|
||||
|
||||
mainWindow.on('close', async (event) => {
|
||||
event.preventDefault()
|
||||
mainWindow?.hide()
|
||||
const {
|
||||
autoQuitWithoutCore = false,
|
||||
autoQuitWithoutCoreDelay = 60,
|
||||
useDockIcon = true
|
||||
} = await getAppConfig()
|
||||
if (!useDockIcon) {
|
||||
hideDockIcon()
|
||||
}
|
||||
if (autoQuitWithoutCore) {
|
||||
if (quitTimeout) {
|
||||
clearTimeout(quitTimeout)
|
||||
}
|
||||
quitTimeout = setTimeout(async () => {
|
||||
await quitWithoutCore()
|
||||
}, autoQuitWithoutCoreDelay * 1000)
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('resized', () => {
|
||||
if (mainWindow) mainWindowState.saveState(mainWindow)
|
||||
})
|
||||
|
||||
mainWindow.on('move', () => {
|
||||
if (mainWindow) mainWindowState.saveState(mainWindow)
|
||||
})
|
||||
|
||||
mainWindow.on('session-end', async () => {
|
||||
triggerSysProxy(false)
|
||||
await stopCore()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// 在开发模式下自动打开 DevTools
|
||||
if (is.dev) {
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerMainWindow(): void {
|
||||
if (mainWindow?.isVisible()) {
|
||||
closeMainWindow()
|
||||
} else {
|
||||
showMainWindow()
|
||||
}
|
||||
}
|
||||
|
||||
export function showMainWindow(): void {
|
||||
if (mainWindow) {
|
||||
if (quitTimeout) {
|
||||
clearTimeout(quitTimeout)
|
||||
}
|
||||
mainWindow.show()
|
||||
mainWindow.focusOnWebView()
|
||||
}
|
||||
}
|
||||
|
||||
export function closeMainWindow(): void {
|
||||
if (mainWindow) {
|
||||
mainWindow.close()
|
||||
}
|
||||
}
|
||||
|
||||
76
src/main/lifecycle.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { spawn, exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { stat } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { app, powerMonitor } from 'electron'
|
||||
import { stopCore, cleanupCoreWatcher } from './core/manager'
|
||||
import { triggerSysProxy } from './sys/sysproxy'
|
||||
import { exePath } from './utils/dirs'
|
||||
|
||||
export function customRelaunch(): void {
|
||||
const script = `while kill -0 ${process.pid} 2>/dev/null; do
|
||||
sleep 0.1
|
||||
done
|
||||
${process.argv.join(' ')} & disown
|
||||
exit
|
||||
`
|
||||
spawn('sh', ['-c', script], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
}
|
||||
|
||||
export async function fixUserDataPermissions(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return
|
||||
|
||||
const userDataPath = app.getPath('userData')
|
||||
if (!existsSync(userDataPath)) return
|
||||
|
||||
try {
|
||||
const stats = await stat(userDataPath)
|
||||
const currentUid = process.getuid?.() || 0
|
||||
|
||||
if (stats.uid === 0 && currentUid !== 0) {
|
||||
const execPromise = promisify(exec)
|
||||
const username = process.env.USER || process.env.LOGNAME
|
||||
if (username) {
|
||||
await execPromise(`chown -R "${username}:staff" "${userDataPath}"`)
|
||||
await execPromise(`chmod -R u+rwX "${userDataPath}"`)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function setupPlatformSpecifics(): void {
|
||||
if (process.platform === 'linux') {
|
||||
app.relaunch = customRelaunch
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && !exePath().startsWith('C')) {
|
||||
app.commandLine.appendSwitch('in-process-gpu')
|
||||
}
|
||||
}
|
||||
|
||||
export function setupAppLifecycle(): void {
|
||||
app.on('before-quit', async (e) => {
|
||||
e.preventDefault()
|
||||
cleanupCoreWatcher()
|
||||
await triggerSysProxy(false)
|
||||
await stopCore()
|
||||
app.exit()
|
||||
})
|
||||
|
||||
powerMonitor.on('shutdown', async () => {
|
||||
cleanupCoreWatcher()
|
||||
triggerSysProxy(false)
|
||||
await stopCore()
|
||||
app.exit()
|
||||
})
|
||||
}
|
||||
|
||||
export function getSystemLanguage(): 'zh-CN' | 'en-US' {
|
||||
const locale = app.getLocale()
|
||||
return locale.startsWith('zh') ? 'zh-CN' : 'en-US'
|
||||
}
|
||||
@ -1,18 +1,23 @@
|
||||
import axios from 'axios'
|
||||
import yaml from 'yaml'
|
||||
import { app, shell } from 'electron'
|
||||
import { getControledMihomoConfig } from '../config'
|
||||
import { dataDir, exeDir, exePath, isPortable, resourcesFilesDir } from '../utils/dirs'
|
||||
import { copyFile, rm, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import os from 'os'
|
||||
import { exec, execSync, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { 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<IAppVersion | undefined> {
|
||||
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
|
||||
const res = await axios.get(
|
||||
const res = await chromeRequest.get(
|
||||
'https://github.com/mihomo-party-org/mihomo-party/releases/latest/download/latest.yml',
|
||||
{
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
@ -24,31 +29,49 @@ export async function checkUpdate(): Promise<IAppVersion | undefined> {
|
||||
responseType: 'text'
|
||||
}
|
||||
)
|
||||
const latest = yaml.parse(res.data, { merge: true }) as IAppVersion
|
||||
const latest = parse(res.data as string) as IAppVersion
|
||||
const currentVersion = app.getVersion()
|
||||
if (latest.version !== currentVersion) {
|
||||
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<void> {
|
||||
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
|
||||
const baseUrl = `https://github.com/mihomo-party-org/mihomo-party/releases/download/v${version}/`
|
||||
const fileMap = {
|
||||
'win32-x64': `mihomo-party-windows-${version}-x64-setup.exe`,
|
||||
'win32-ia32': `mihomo-party-windows-${version}-ia32-setup.exe`,
|
||||
'win32-arm64': `mihomo-party-windows-${version}-arm64-setup.exe`,
|
||||
'darwin-x64': `mihomo-party-macos-${version}-x64.pkg`,
|
||||
'darwin-arm64': `mihomo-party-macos-${version}-arm64.pkg`
|
||||
'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('不支持自动更新,请手动下载更新')
|
||||
throw new Error(i18next.t('common.error.autoUpdateNotSupported'))
|
||||
}
|
||||
if (process.platform === 'win32' && parseInt(os.release()) < 10) {
|
||||
file = file.replace('windows', 'win7')
|
||||
@ -63,8 +86,18 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
|
||||
}
|
||||
try {
|
||||
if (!existsSync(path.join(dataDir(), file))) {
|
||||
const res = await axios.get(`${baseUrl}${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',
|
||||
@ -72,15 +105,66 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream'
|
||||
},
|
||||
onProgress: (loaded, total) => {
|
||||
mainWindow?.webContents.send('updateDownloadProgress', {
|
||||
status: 'downloading',
|
||||
percent: Math.round((loaded / total) * 100)
|
||||
})
|
||||
}
|
||||
})
|
||||
await writeFile(path.join(dataDir(), file), res.data)
|
||||
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')) {
|
||||
spawn(path.join(dataDir(), file), ['/S', '--force-run'], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
}).unref()
|
||||
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'))
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { getAppConfig } from '../config'
|
||||
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,
|
||||
@ -9,36 +14,86 @@ import {
|
||||
overrideDir,
|
||||
profileConfigPath,
|
||||
profilesDir,
|
||||
rulesDir,
|
||||
subStoreDir,
|
||||
themesDir
|
||||
} from '../utils/dirs'
|
||||
import { getAppConfig } from '../config'
|
||||
|
||||
export async function webdavBackup(): Promise<boolean> {
|
||||
let backupCronJob: Cron | null = null
|
||||
|
||||
interface WebDAVContext {
|
||||
client: ReturnType<Awaited<typeof import('webdav/dist/node/index.js')>['createClient']>
|
||||
webdavDir: string
|
||||
webdavMaxBackups: number
|
||||
}
|
||||
|
||||
async function getWebDAVClient(): Promise<WebDAVContext> {
|
||||
const { createClient } = await import('webdav/dist/node/index.js')
|
||||
const {
|
||||
webdavUrl = '',
|
||||
webdavUsername = '',
|
||||
webdavPassword = '',
|
||||
webdavDir = 'mihomo-party',
|
||||
webdavMaxBackups = 0
|
||||
webdavDir = 'clash-party',
|
||||
webdavMaxBackups = 0,
|
||||
webdavIgnoreCert = false
|
||||
} = await getAppConfig()
|
||||
|
||||
const clientOptions: Parameters<typeof createClient>[1] = {
|
||||
username: webdavUsername,
|
||||
password: webdavPassword
|
||||
}
|
||||
|
||||
if (webdavIgnoreCert) {
|
||||
clientOptions.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
})
|
||||
}
|
||||
|
||||
const client = createClient(webdavUrl, clientOptions)
|
||||
|
||||
return { client, webdavDir, webdavMaxBackups }
|
||||
}
|
||||
|
||||
function createBackupZip(): AdmZip {
|
||||
const zip = new AdmZip()
|
||||
|
||||
zip.addLocalFile(appConfigPath())
|
||||
zip.addLocalFile(controledMihomoConfigPath())
|
||||
zip.addLocalFile(profileConfigPath())
|
||||
zip.addLocalFile(overrideConfigPath())
|
||||
zip.addLocalFolder(themesDir(), 'themes')
|
||||
zip.addLocalFolder(profilesDir(), 'profiles')
|
||||
zip.addLocalFolder(overrideDir(), 'override')
|
||||
zip.addLocalFolder(subStoreDir(), 'substore')
|
||||
const files = [
|
||||
appConfigPath(),
|
||||
controledMihomoConfigPath(),
|
||||
profileConfigPath(),
|
||||
overrideConfigPath()
|
||||
]
|
||||
|
||||
const folders = [
|
||||
{ path: themesDir(), name: 'themes' },
|
||||
{ path: profilesDir(), name: 'profiles' },
|
||||
{ path: overrideDir(), name: 'override' },
|
||||
{ path: rulesDir(), name: 'rules' },
|
||||
{ path: subStoreDir(), name: 'substore' }
|
||||
]
|
||||
|
||||
for (const file of files) {
|
||||
if (existsSync(file)) {
|
||||
zip.addLocalFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
for (const { path, name } of folders) {
|
||||
if (existsSync(path)) {
|
||||
zip.addLocalFolder(path, name)
|
||||
}
|
||||
}
|
||||
|
||||
return zip
|
||||
}
|
||||
|
||||
export async function webdavBackup(): Promise<boolean> {
|
||||
const { client, webdavDir, webdavMaxBackups } = await getWebDAVClient()
|
||||
const zip = createBackupZip()
|
||||
const date = new Date()
|
||||
const zipFileName = `${process.platform}_${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip`
|
||||
|
||||
const client = createClient(webdavUrl, {
|
||||
username: webdavUsername,
|
||||
password: webdavPassword
|
||||
})
|
||||
try {
|
||||
await client.createDirectory(webdavDir)
|
||||
} catch {
|
||||
@ -75,7 +130,7 @@ export async function webdavBackup(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to clean up old backup files:', error)
|
||||
await systemLogger.error('Failed to clean up old backup files', error)
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,36 +138,14 @@ export async function webdavBackup(): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function webdavRestore(filename: string): Promise<void> {
|
||||
const { createClient } = await import('webdav/dist/node/index.js')
|
||||
const {
|
||||
webdavUrl = '',
|
||||
webdavUsername = '',
|
||||
webdavPassword = '',
|
||||
webdavDir = 'mihomo-party'
|
||||
} = await getAppConfig()
|
||||
|
||||
const client = createClient(webdavUrl, {
|
||||
username: webdavUsername,
|
||||
password: webdavPassword
|
||||
})
|
||||
const { 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<string[]> {
|
||||
const { createClient } = await import('webdav/dist/node/index.js')
|
||||
const {
|
||||
webdavUrl = '',
|
||||
webdavUsername = '',
|
||||
webdavPassword = '',
|
||||
webdavDir = 'mihomo-party'
|
||||
} = await getAppConfig()
|
||||
|
||||
const client = createClient(webdavUrl, {
|
||||
username: webdavUsername,
|
||||
password: webdavPassword
|
||||
})
|
||||
const { client, webdavDir } = await getWebDAVClient()
|
||||
const files = await client.getDirectoryContents(webdavDir, { glob: '*.zip' })
|
||||
if (Array.isArray(files)) {
|
||||
return files.map((file) => file.basename)
|
||||
@ -122,17 +155,110 @@ export async function listWebdavBackups(): Promise<string[]> {
|
||||
}
|
||||
|
||||
export async function webdavDelete(filename: string): Promise<void> {
|
||||
const { createClient } = await import('webdav/dist/node/index.js')
|
||||
const {
|
||||
webdavUrl = '',
|
||||
webdavUsername = '',
|
||||
webdavPassword = '',
|
||||
webdavDir = 'mihomo-party'
|
||||
} = await getAppConfig()
|
||||
|
||||
const client = createClient(webdavUrl, {
|
||||
username: webdavUsername,
|
||||
password: webdavPassword
|
||||
})
|
||||
const { client, webdavDir } = await getWebDAVClient()
|
||||
await client.deleteFile(`${webdavDir}/${filename}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 WebDAV 定时备份任务
|
||||
*/
|
||||
export async function initWebdavBackupScheduler(): Promise<void> {
|
||||
try {
|
||||
// 先停止现有的定时任务
|
||||
if (backupCronJob) {
|
||||
backupCronJob.stop()
|
||||
backupCronJob = null
|
||||
}
|
||||
|
||||
const { webdavBackupCron } = await getAppConfig()
|
||||
|
||||
// 如果配置了 Cron 表达式,则启动定时任务
|
||||
if (webdavBackupCron) {
|
||||
backupCronJob = new Cron(webdavBackupCron, async () => {
|
||||
try {
|
||||
await webdavBackup()
|
||||
await systemLogger.info('WebDAV backup completed successfully via cron job')
|
||||
} catch (error) {
|
||||
await systemLogger.error('Failed to execute WebDAV backup via cron job', error)
|
||||
}
|
||||
})
|
||||
|
||||
await systemLogger.info(`WebDAV backup scheduler initialized with cron: ${webdavBackupCron}`)
|
||||
await systemLogger.info(`WebDAV backup scheduler nextRun: ${backupCronJob.nextRun()}`)
|
||||
} else {
|
||||
await systemLogger.info('WebDAV backup scheduler disabled (no cron expression configured)')
|
||||
}
|
||||
} catch (error) {
|
||||
await systemLogger.error('Failed to initialize WebDAV backup scheduler', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止 WebDAV 定时备份任务
|
||||
*/
|
||||
export async function stopWebdavBackupScheduler(): Promise<void> {
|
||||
if (backupCronJob) {
|
||||
backupCronJob.stop()
|
||||
backupCronJob = null
|
||||
await systemLogger.info('WebDAV backup scheduler stopped')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新初始化 WebDAV 定时备份任务
|
||||
* 先停止现有任务,然后重新启动
|
||||
*/
|
||||
export async function reinitScheduler(): Promise<void> {
|
||||
await systemLogger.info('Reinitializing WebDAV backup scheduler...')
|
||||
await stopWebdavBackupScheduler()
|
||||
await initWebdavBackupScheduler()
|
||||
await systemLogger.info('WebDAV backup scheduler reinitialized successfully')
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出本地备份
|
||||
*/
|
||||
export async function exportLocalBackup(): Promise<boolean> {
|
||||
const zip = createBackupZip()
|
||||
|
||||
const date = new Date()
|
||||
const zipFileName = `clash-party-backup-${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip`
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: i18next.t('localBackup.export.title'),
|
||||
defaultPath: zipFileName,
|
||||
filters: [
|
||||
{ name: 'ZIP Files', extensions: ['zip'] },
|
||||
{ name: 'All Files', extensions: ['*'] }
|
||||
]
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePath) {
|
||||
zip.writeZip(result.filePath)
|
||||
await systemLogger.info(`Local backup exported to: ${result.filePath}`)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入本地备份
|
||||
*/
|
||||
export async function importLocalBackup(): Promise<boolean> {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: i18next.t('localBackup.import.title'),
|
||||
filters: [
|
||||
{ name: 'ZIP Files', extensions: ['zip'] },
|
||||
{ name: 'All Files', extensions: ['*'] }
|
||||
],
|
||||
properties: ['openFile']
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const filePath = result.filePaths[0]
|
||||
const zip = new AdmZip(filePath)
|
||||
zip.extractAllTo(dataDir(), true)
|
||||
await systemLogger.info(`Local backup imported from: ${filePath}`)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@ -1,66 +1,121 @@
|
||||
import { join } from 'path'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import windowStateKeeper from 'electron-window-state'
|
||||
import { join } from 'path'
|
||||
import { getAppConfig, patchAppConfig } from '../config'
|
||||
import { floatingWindowLogger } from '../utils/logger'
|
||||
import { applyTheme } from './theme'
|
||||
import { buildContextMenu, showTrayIcon } from './tray'
|
||||
|
||||
export let floatingWindow: BrowserWindow | null = null
|
||||
|
||||
function logError(message: string, error?: unknown): void {
|
||||
floatingWindowLogger.log(`FloatingWindow Error: ${message}`, error).catch(() => {})
|
||||
}
|
||||
|
||||
async function createFloatingWindow(): Promise<void> {
|
||||
const floatingWindowState = windowStateKeeper({
|
||||
file: 'floating-window-state.json'
|
||||
})
|
||||
const { customTheme = 'default.css' } = await getAppConfig()
|
||||
floatingWindow = new BrowserWindow({
|
||||
width: 120,
|
||||
height: 42,
|
||||
x: floatingWindowState.x,
|
||||
y: floatingWindowState.y,
|
||||
show: false,
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
resizable: false,
|
||||
transparent: true,
|
||||
skipTaskbar: true,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
closable: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
spellcheck: false,
|
||||
sandbox: false
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
floatingWindowState.manage(floatingWindow)
|
||||
floatingWindow.on('ready-to-show', () => {
|
||||
applyTheme(customTheme)
|
||||
floatingWindow?.show()
|
||||
floatingWindow?.setAlwaysOnTop(true, 'screen-saver')
|
||||
})
|
||||
floatingWindow.on('moved', () => {
|
||||
if (floatingWindow) floatingWindowState.saveState(floatingWindow)
|
||||
})
|
||||
ipcMain.on('updateFloatingWindow', () => {
|
||||
if (floatingWindow) {
|
||||
floatingWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
floatingWindow?.webContents.send('appConfigUpdated')
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
windowOptions.hasShadow = !safeMode
|
||||
if (windowOptions.webPreferences) {
|
||||
windowOptions.webPreferences.offscreen = false
|
||||
}
|
||||
}
|
||||
})
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
floatingWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/floating.html`)
|
||||
} else {
|
||||
floatingWindow.loadFile(join(__dirname, '../renderer/floating.html'))
|
||||
|
||||
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<void> {
|
||||
if (floatingWindow) {
|
||||
floatingWindow.show()
|
||||
} else {
|
||||
createFloatingWindow()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,7 +131,7 @@ export async function triggerFloatingWindow(): Promise<void> {
|
||||
|
||||
export async function closeFloatingWindow(): Promise<void> {
|
||||
if (floatingWindow) {
|
||||
floatingWindow.close()
|
||||
ipcMain.removeAllListeners('updateFloatingWindow')
|
||||
floatingWindow.destroy()
|
||||
floatingWindow = null
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import axios from 'axios'
|
||||
import * as chromeRequest from '../utils/chromeRequest'
|
||||
import { getAppConfig, getControledMihomoConfig } from '../config'
|
||||
import { getRuntimeConfigStr } from '../core/factory'
|
||||
|
||||
@ -10,7 +10,7 @@ interface GistInfo {
|
||||
|
||||
async function listGists(token: string): Promise<GistInfo[]> {
|
||||
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
|
||||
const res = await axios.get('https://api.github.com/gists', {
|
||||
const res = await chromeRequest.get('https://api.github.com/gists', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
@ -23,17 +23,17 @@ async function listGists(token: string): Promise<GistInfo[]> {
|
||||
},
|
||||
responseType: 'json'
|
||||
})
|
||||
return res.data as GistInfo[]
|
||||
return Array.isArray(res.data) ? res.data : []
|
||||
}
|
||||
|
||||
async function createGist(token: string, content: string): Promise<void> {
|
||||
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
|
||||
return await axios.post(
|
||||
await chromeRequest.post(
|
||||
'https://api.github.com/gists',
|
||||
{
|
||||
description: 'Auto Synced Mihomo Party Runtime Config',
|
||||
description: 'Auto Synced Clash Party Runtime Config',
|
||||
public: false,
|
||||
files: { 'mihomo-party.yaml': { content } }
|
||||
files: { 'clash-party.yaml': { content } }
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
@ -52,11 +52,11 @@ async function createGist(token: string, content: string): Promise<void> {
|
||||
|
||||
async function updateGist(token: string, id: string, content: string): Promise<void> {
|
||||
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
|
||||
return await axios.patch(
|
||||
await chromeRequest.patch(
|
||||
`https://api.github.com/gists/${id}`,
|
||||
{
|
||||
description: 'Auto Synced Mihomo Party Runtime Config',
|
||||
files: { 'mihomo-party.yaml': { content } }
|
||||
description: 'Auto Synced Clash Party Runtime Config',
|
||||
files: { 'clash-party.yaml': { content } }
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
@ -77,15 +77,13 @@ export async function getGistUrl(): Promise<string> {
|
||||
const { githubToken } = await getAppConfig()
|
||||
if (!githubToken) return ''
|
||||
const gists = await listGists(githubToken)
|
||||
const gist = gists.find((gist) => gist.description === 'Auto Synced Mihomo Party Runtime Config')
|
||||
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 Mihomo Party Runtime Config'
|
||||
)
|
||||
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
|
||||
}
|
||||
@ -95,7 +93,7 @@ export async function uploadRuntimeConfig(): Promise<void> {
|
||||
const { githubToken } = await getAppConfig()
|
||||
if (!githubToken) return
|
||||
const gists = await listGists(githubToken)
|
||||
const gist = gists.find((gist) => gist.description === 'Auto Synced Mihomo Party Runtime Config')
|
||||
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)
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import { getAppConfig, getControledMihomoConfig } from '../config'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { dataDir, mihomoWorkDir, subStoreDir, substoreLogPath } from '../utils/dirs'
|
||||
import subStoreIcon from '../../../resources/subStoreIcon.png?asset'
|
||||
import { createWriteStream, existsSync, mkdirSync } from 'fs'
|
||||
import { writeFile, rm, cp } from 'fs/promises'
|
||||
import http from 'http'
|
||||
@ -9,8 +6,12 @@ import net from 'net'
|
||||
import path from 'path'
|
||||
import { nativeImage } from 'electron'
|
||||
import express from 'express'
|
||||
import axios from 'axios'
|
||||
import AdmZip from 'adm-zip'
|
||||
import * as chromeRequest from '../utils/chromeRequest'
|
||||
import subStoreIcon from '../../../resources/subStoreIcon.png?asset'
|
||||
import { dataDir, mihomoWorkDir, subStoreDir, substoreLogPath } from '../utils/dirs'
|
||||
import { getAppConfig, getControledMihomoConfig } from '../config'
|
||||
import { systemLogger } from '../utils/logger'
|
||||
|
||||
export let pacPort: number
|
||||
export let subStorePort: number
|
||||
@ -111,14 +112,14 @@ export async function startSubStoreBackendServer(): Promise<void> {
|
||||
SUB_STORE_BACKEND_API_HOST: subStoreHost,
|
||||
SUB_STORE_DATA_BASE_PATH: subStoreDir(),
|
||||
SUB_STORE_BACKEND_CUSTOM_ICON: icon.toDataURL(),
|
||||
SUB_STORE_BACKEND_CUSTOM_NAME: 'Mihomo Party',
|
||||
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.js'), {
|
||||
subStoreBackendWorker = new Worker(path.join(mihomoWorkDir(), 'sub-store.bundle.cjs'), {
|
||||
env: useProxyInSubStore
|
||||
? {
|
||||
...env,
|
||||
@ -142,7 +143,7 @@ export async function stopSubStoreBackendServer(): Promise<void> {
|
||||
export async function downloadSubStore(): Promise<void> {
|
||||
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
|
||||
const frontendDir = path.join(mihomoWorkDir(), 'sub-store-frontend')
|
||||
const backendPath = path.join(mihomoWorkDir(), 'sub-store.bundle.js')
|
||||
const backendPath = path.join(mihomoWorkDir(), 'sub-store.bundle.cjs')
|
||||
const tempDir = path.join(dataDir(), 'temp')
|
||||
|
||||
try {
|
||||
@ -153,8 +154,8 @@ export async function downloadSubStore(): Promise<void> {
|
||||
mkdirSync(tempDir, { recursive: true })
|
||||
|
||||
// 下载后端文件
|
||||
const tempBackendPath = path.join(tempDir, 'sub-store.bundle.js')
|
||||
const backendRes = await axios.get(
|
||||
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',
|
||||
@ -166,10 +167,9 @@ export async function downloadSubStore(): Promise<void> {
|
||||
}
|
||||
}
|
||||
)
|
||||
await writeFile(tempBackendPath, Buffer.from(backendRes.data))
|
||||
await writeFile(tempBackendPath, Buffer.from(backendRes.data as Buffer))
|
||||
// 下载前端文件
|
||||
const tempFrontendDir = path.join(tempDir, 'dist')
|
||||
const frontendRes = await axios.get(
|
||||
const frontendRes = await chromeRequest.get(
|
||||
'https://github.com/sub-store-org/Sub-Store-Front-End/releases/latest/download/dist.zip',
|
||||
{
|
||||
responseType: 'arraybuffer',
|
||||
@ -182,7 +182,7 @@ export async function downloadSubStore(): Promise<void> {
|
||||
}
|
||||
)
|
||||
// 先解压到临时目录
|
||||
const zip = new AdmZip(Buffer.from(frontendRes.data))
|
||||
const zip = new AdmZip(Buffer.from(frontendRes.data as Buffer))
|
||||
zip.extractAllTo(tempDir, true)
|
||||
await cp(tempBackendPath, backendPath)
|
||||
if (existsSync(frontendDir)) {
|
||||
@ -192,7 +192,7 @@ export async function downloadSubStore(): Promise<void> {
|
||||
await cp(path.join(tempDir, 'dist'), frontendDir, { recursive: true })
|
||||
await rm(tempDir, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error('substore.downloadFailed:', error)
|
||||
await systemLogger.error('substore.downloadFailed', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { app, globalShortcut, ipcMain, Notification } from 'electron'
|
||||
import { mainWindow, triggerMainWindow } from '..'
|
||||
import { mainWindow, triggerMainWindow } from '../window'
|
||||
import {
|
||||
getAppConfig,
|
||||
getControledMihomoConfig,
|
||||
@ -9,8 +9,9 @@ import {
|
||||
import { triggerSysProxy } from '../sys/sysproxy'
|
||||
import { patchMihomoConfig } from '../core/mihomoApi'
|
||||
import { quitWithoutCore, restartCore } from '../core/manager'
|
||||
import { floatingWindow, triggerFloatingWindow } from './floatingWindow'
|
||||
import i18next from '../../shared/i18n'
|
||||
import { floatingWindow, triggerFloatingWindow } from './floatingWindow'
|
||||
import { updateTrayIcon } from './tray'
|
||||
|
||||
export async function registerShortcut(
|
||||
oldShortcut: string,
|
||||
@ -26,7 +27,7 @@ export async function registerShortcut(
|
||||
switch (action) {
|
||||
case 'showWindowShortcut': {
|
||||
return globalShortcut.register(newShortcut, () => {
|
||||
triggerMainWindow()
|
||||
triggerMainWindow(true)
|
||||
})
|
||||
}
|
||||
case 'showFloatingWindowShortcut': {
|
||||
@ -43,7 +44,11 @@ export async function registerShortcut(
|
||||
await triggerSysProxy(!enable)
|
||||
await patchAppConfig({ sysProxy: { enable: !enable } })
|
||||
new Notification({
|
||||
title: i18next.t(!enable ? 'common.notification.systemProxyEnabled' : 'common.notification.systemProxyDisabled')
|
||||
title: i18next.t(
|
||||
!enable
|
||||
? 'common.notification.systemProxyEnabled'
|
||||
: 'common.notification.systemProxyDisabled'
|
||||
)
|
||||
}).show()
|
||||
mainWindow?.webContents.send('appConfigUpdated')
|
||||
floatingWindow?.webContents.send('appConfigUpdated')
|
||||
@ -51,6 +56,7 @@ export async function registerShortcut(
|
||||
// ignore
|
||||
} finally {
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
await updateTrayIcon()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -66,7 +72,9 @@ export async function registerShortcut(
|
||||
}
|
||||
await restartCore()
|
||||
new Notification({
|
||||
title: i18next.t(!enable ? 'common.notification.tunEnabled' : 'common.notification.tunDisabled')
|
||||
title: i18next.t(
|
||||
!enable ? 'common.notification.tunEnabled' : 'common.notification.tunDisabled'
|
||||
)
|
||||
}).show()
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
floatingWindow?.webContents.send('appConfigUpdated')
|
||||
@ -74,6 +82,7 @@ export async function registerShortcut(
|
||||
// ignore
|
||||
} finally {
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
await updateTrayIcon()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -86,6 +95,7 @@ export async function registerShortcut(
|
||||
}).show()
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
await updateTrayIcon()
|
||||
})
|
||||
}
|
||||
case 'globalModeShortcut': {
|
||||
@ -97,6 +107,7 @@ export async function registerShortcut(
|
||||
}).show()
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
await updateTrayIcon()
|
||||
})
|
||||
}
|
||||
case 'directModeShortcut': {
|
||||
@ -108,6 +119,7 @@ export async function registerShortcut(
|
||||
}).show()
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
await updateTrayIcon()
|
||||
})
|
||||
}
|
||||
case 'quitWithoutCoreShortcut': {
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { copyFile, readdir, readFile, writeFile } from 'fs/promises'
|
||||
import { themesDir } from '../utils/dirs'
|
||||
import path from 'path'
|
||||
import axios from 'axios'
|
||||
import AdmZip from 'adm-zip'
|
||||
import { getControledMihomoConfig } from '../config'
|
||||
import { existsSync } from 'fs'
|
||||
import { mainWindow } from '..'
|
||||
import { floatingWindow } from './floatingWindow'
|
||||
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
|
||||
@ -36,7 +36,7 @@ export async function resolveThemes(): Promise<{ key: string; label: string }[]>
|
||||
export async function fetchThemes(): Promise<void> {
|
||||
const zipUrl = 'https://github.com/mihomo-party-org/theme-hub/releases/download/latest/themes.zip'
|
||||
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
|
||||
const zipData = await axios.get(zipUrl, {
|
||||
const zipData = await chromeRequest.get(zipUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
proxy: {
|
||||
@ -45,7 +45,7 @@ export async function fetchThemes(): Promise<void> {
|
||||
port: mixedPort
|
||||
}
|
||||
})
|
||||
const zip = new AdmZip(zipData.data as Buffer)
|
||||
const zip = new AdmZip(Buffer.from(zipData.data as Buffer))
|
||||
zip.extractAllTo(themesDir(), true)
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { ChildProcess, spawn } from 'child_process'
|
||||
import { getAppConfig } from '../config'
|
||||
import { dataDir, resourcesFilesDir } from '../utils/dirs'
|
||||
import path from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { readFile, rm, writeFile } from 'fs/promises'
|
||||
import { dataDir, resourcesFilesDir } from '../utils/dirs'
|
||||
import { getAppConfig } from '../config'
|
||||
|
||||
let child: ChildProcess
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { app, clipboard, ipcMain, Menu, nativeImage, shell, Tray } from 'electron'
|
||||
import { t } from 'i18next'
|
||||
import {
|
||||
changeCurrentProfile,
|
||||
getAppConfig,
|
||||
@ -7,29 +9,50 @@ import {
|
||||
patchControledMihomoConfig
|
||||
} from '../config'
|
||||
import icoIcon from '../../../resources/icon.ico?asset'
|
||||
import icoIconBlue from '../../../resources/icon_blue.ico?asset'
|
||||
import icoIconRed from '../../../resources/icon_red.ico?asset'
|
||||
import icoIconGreen from '../../../resources/icon_green.ico?asset'
|
||||
import pngIcon from '../../../resources/icon.png?asset'
|
||||
import pngIconBlue from '../../../resources/icon_blue.png?asset'
|
||||
import pngIconRed from '../../../resources/icon_red.png?asset'
|
||||
import pngIconGreen from '../../../resources/icon_green.png?asset'
|
||||
import templateIcon from '../../../resources/iconTemplate.png?asset'
|
||||
import {
|
||||
mihomoChangeProxy,
|
||||
mihomoCloseAllConnections,
|
||||
mihomoGroups,
|
||||
patchMihomoConfig
|
||||
patchMihomoConfig,
|
||||
getTrayIconStatus,
|
||||
calculateTrayIconStatus
|
||||
} from '../core/mihomoApi'
|
||||
import { mainWindow, showMainWindow, triggerMainWindow } from '..'
|
||||
import { app, clipboard, ipcMain, Menu, nativeImage, shell, Tray } from 'electron'
|
||||
import { mainWindow, showMainWindow, triggerMainWindow } from '../window'
|
||||
import { dataDir, logDir, mihomoCoreDir, mihomoWorkDir } from '../utils/dirs'
|
||||
import { triggerSysProxy } from '../sys/sysproxy'
|
||||
import { quitWithoutCore, restartCore } from '../core/manager'
|
||||
import {
|
||||
quitWithoutCore,
|
||||
restartCore,
|
||||
checkMihomoCorePermissions,
|
||||
requestTunPermissions,
|
||||
restartAsAdmin
|
||||
} from '../core/manager'
|
||||
import { trayLogger } from '../utils/logger'
|
||||
import { floatingWindow, triggerFloatingWindow } from './floatingWindow'
|
||||
import { t } from 'i18next'
|
||||
|
||||
export let tray: Tray | null = null
|
||||
// macOS 流量显示状态,避免异步读取配置导致的时序问题
|
||||
let macTrafficIconEnabled = false
|
||||
|
||||
export const buildContextMenu = async (): Promise<Menu> => {
|
||||
// 添加调试日志
|
||||
console.log('Current translation for tray.showWindow:', t('tray.showWindow'))
|
||||
console.log('Current translation for tray.hideFloatingWindow:', t('tray.hideFloatingWindow'))
|
||||
console.log('Current translation for tray.showFloatingWindow:', t('tray.showFloatingWindow'))
|
||||
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 {
|
||||
@ -37,6 +60,8 @@ export const buildContextMenu = async (): Promise<Menu> => {
|
||||
envType = process.platform === 'win32' ? ['powershell'] : ['bash'],
|
||||
autoCloseConnection,
|
||||
proxyInTray = true,
|
||||
showCurrentProxyInTray = false,
|
||||
trayProxyGroupStyle = 'default',
|
||||
triggerSysProxyShortcut = '',
|
||||
showFloatingWindowShortcut = '',
|
||||
showWindowShortcut = '',
|
||||
@ -51,11 +76,13 @@ export const buildContextMenu = async (): Promise<Menu> => {
|
||||
if (proxyInTray && process.platform !== 'linux') {
|
||||
try {
|
||||
const groups = await mihomoGroups()
|
||||
groupsMenu = groups.map((group) => {
|
||||
const groupItems: Electron.MenuItemConstructorOptions[] = groups.map((group) => {
|
||||
const groupLabel = showCurrentProxyInTray ? `${group.name} | ${group.now}` : group.name
|
||||
|
||||
return {
|
||||
id: group.name,
|
||||
label: group.name,
|
||||
type: 'submenu',
|
||||
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)`
|
||||
@ -68,7 +95,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
|
||||
return {
|
||||
id: proxy.name,
|
||||
label: `${proxy.name} ${displayDelay}`,
|
||||
type: 'radio',
|
||||
type: 'radio' as const,
|
||||
checked: proxy.name === group.now,
|
||||
click: async (): Promise<void> => {
|
||||
await mihomoChangeProxy(group.name, proxy.name)
|
||||
@ -80,8 +107,22 @@ export const buildContextMenu = async (): Promise<Menu> => {
|
||||
})
|
||||
}
|
||||
})
|
||||
groupsMenu.unshift({ type: 'separator' })
|
||||
} catch (e) {
|
||||
|
||||
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
|
||||
// 避免出错时无法创建托盘菜单
|
||||
}
|
||||
@ -101,7 +142,9 @@ export const buildContextMenu = async (): Promise<Menu> => {
|
||||
{
|
||||
id: 'show-floating',
|
||||
accelerator: showFloatingWindowShortcut,
|
||||
label: floatingWindow?.isVisible() ? t('tray.hideFloatingWindow') : t('tray.showFloatingWindow'),
|
||||
label: floatingWindow?.isVisible()
|
||||
? t('tray.hideFloatingWindow')
|
||||
: t('tray.showFloatingWindow'),
|
||||
type: 'normal',
|
||||
click: async (): Promise<void> => {
|
||||
await triggerFloatingWindow()
|
||||
@ -119,6 +162,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
mainWindow?.webContents.send('groupsUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
await updateTrayIcon()
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -133,6 +177,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
mainWindow?.webContents.send('groupsUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
await updateTrayIcon()
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -147,6 +192,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
mainWindow?.webContents.send('groupsUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
await updateTrayIcon()
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
@ -162,10 +208,11 @@ export const buildContextMenu = async (): Promise<Menu> => {
|
||||
await patchAppConfig({ sysProxy: { enable } })
|
||||
mainWindow?.webContents.send('appConfigUpdated')
|
||||
floatingWindow?.webContents.send('appConfigUpdated')
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
await updateTrayIcon()
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -178,6 +225,39 @@ export const buildContextMenu = async (): Promise<Menu> => {
|
||||
const enable = item.checked
|
||||
try {
|
||||
if (enable) {
|
||||
// 检查权限
|
||||
try {
|
||||
const hasPermissions = await checkMihomoCorePermissions()
|
||||
|
||||
if (!hasPermissions) {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
await restartAsAdmin()
|
||||
return
|
||||
} catch (error) {
|
||||
await trayLogger.error('Failed to restart as admin from tray', error)
|
||||
item.checked = false
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await requestTunPermissions()
|
||||
} catch (error) {
|
||||
await trayLogger.error('Failed to grant TUN permissions from tray', error)
|
||||
item.checked = false
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await trayLogger.warn('Permission check failed in tray', error)
|
||||
item.checked = false
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
return
|
||||
}
|
||||
|
||||
await patchControledMihomoConfig({ tun: { enable }, dns: { enable: true } })
|
||||
} else {
|
||||
await patchControledMihomoConfig({ tun: { enable } })
|
||||
@ -189,6 +269,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
|
||||
// ignore
|
||||
} finally {
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
await updateTrayIcon()
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -207,6 +288,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
|
||||
await changeCurrentProfile(item.id)
|
||||
mainWindow?.webContents.send('profileConfigUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
await updateTrayIcon()
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -291,7 +373,7 @@ export const buildContextMenu = async (): Promise<Menu> => {
|
||||
}
|
||||
|
||||
export async function createTray(): Promise<void> {
|
||||
const { useDockIcon = true } = await getAppConfig()
|
||||
const { useDockIcon = true, swapTrayClick = false } = await getAppConfig()
|
||||
if (process.platform === 'linux') {
|
||||
tray = new Tray(pngIcon)
|
||||
const menu = await buildContextMenu()
|
||||
@ -305,36 +387,65 @@ export async function createTray(): Promise<void> {
|
||||
if (process.platform === 'win32') {
|
||||
tray = new Tray(icoIcon)
|
||||
}
|
||||
tray?.setToolTip('Mihomo Party')
|
||||
tray?.setToolTip('Clash Party')
|
||||
tray?.setIgnoreDoubleClickEvents(true)
|
||||
|
||||
await updateTrayIcon()
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
if (!useDockIcon) {
|
||||
hideDockIcon()
|
||||
}
|
||||
ipcMain.on('trayIconUpdate', async (_, png: string) => {
|
||||
// 移除旧监听器防止累积
|
||||
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)
|
||||
})
|
||||
tray?.addListener('right-click', async () => {
|
||||
triggerMainWindow()
|
||||
})
|
||||
// macOS 默认行为:左键显示窗口,右键显示菜单
|
||||
tray?.addListener('click', async () => {
|
||||
await updateTrayMenu()
|
||||
if (swapTrayClick) {
|
||||
await updateTrayMenu()
|
||||
} else {
|
||||
triggerMainWindow()
|
||||
}
|
||||
})
|
||||
tray?.addListener('right-click', async () => {
|
||||
if (swapTrayClick) {
|
||||
triggerMainWindow()
|
||||
} else {
|
||||
await updateTrayMenu()
|
||||
}
|
||||
})
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
tray?.addListener('click', () => {
|
||||
triggerMainWindow()
|
||||
tray?.addListener('click', async () => {
|
||||
if (swapTrayClick) {
|
||||
await updateTrayMenu()
|
||||
} else {
|
||||
triggerMainWindow()
|
||||
}
|
||||
})
|
||||
tray?.addListener('right-click', async () => {
|
||||
await updateTrayMenu()
|
||||
if (swapTrayClick) {
|
||||
triggerMainWindow()
|
||||
} else {
|
||||
await updateTrayMenu()
|
||||
}
|
||||
})
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
tray?.addListener('click', () => {
|
||||
triggerMainWindow()
|
||||
tray?.addListener('click', async () => {
|
||||
if (swapTrayClick) {
|
||||
await updateTrayMenu()
|
||||
} else {
|
||||
triggerMainWindow()
|
||||
}
|
||||
})
|
||||
// 移除旧监听器防止累积
|
||||
ipcMain.removeAllListeners('updateTrayMenu')
|
||||
ipcMain.on('updateTrayMenu', async () => {
|
||||
await updateTrayMenu()
|
||||
})
|
||||
@ -349,26 +460,38 @@ async function updateTrayMenu(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyEnv(type: 'bash' | 'cmd' | 'powershell'): Promise<void> {
|
||||
export async function copyEnv(
|
||||
type: 'bash' | 'cmd' | 'powershell' | 'fish' | 'nushell'
|
||||
): Promise<void> {
|
||||
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
|
||||
const { sysProxy } = await getAppConfig()
|
||||
const { host } = sysProxy
|
||||
const proxyUrl = `http://${host || '127.0.0.1'}:${mixedPort}`
|
||||
|
||||
switch (type) {
|
||||
case 'bash': {
|
||||
clipboard.writeText(
|
||||
`export https_proxy=http://${host || '127.0.0.1'}:${mixedPort} http_proxy=http://${host || '127.0.0.1'}:${mixedPort} all_proxy=http://${host || '127.0.0.1'}:${mixedPort}`
|
||||
`export https_proxy=${proxyUrl} http_proxy=${proxyUrl} all_proxy=${proxyUrl}`
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'cmd': {
|
||||
clipboard.writeText(
|
||||
`set http_proxy=http://${host || '127.0.0.1'}:${mixedPort}\r\nset https_proxy=http://${host || '127.0.0.1'}:${mixedPort}`
|
||||
)
|
||||
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(
|
||||
`$env:HTTP_PROXY="http://${host || '127.0.0.1'}:${mixedPort}"; $env:HTTPS_PROXY="http://${host || '127.0.0.1'}:${mixedPort}"`
|
||||
`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
|
||||
}
|
||||
@ -389,13 +512,82 @@ export async function closeTrayIcon(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function showDockIcon(): Promise<void> {
|
||||
if (process.platform === 'darwin' && !app.dock.isVisible()) {
|
||||
if (process.platform === 'darwin' && app.dock && !app.dock.isVisible()) {
|
||||
await app.dock.show()
|
||||
}
|
||||
}
|
||||
|
||||
export async function hideDockIcon(): Promise<void> {
|
||||
if (process.platform === 'darwin' && app.dock.isVisible()) {
|
||||
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<void> {
|
||||
if (!tray) return
|
||||
// macOS 流量显示开启时,由 trayIconUpdate 负责图标更新
|
||||
if (process.platform === 'darwin' && macTrafficIconEnabled) return
|
||||
|
||||
const { disableTrayIconColor = false } = await getAppConfig()
|
||||
const status = await getTrayIconStatus()
|
||||
const iconPaths = getIconPaths()
|
||||
const iconPath = disableTrayIconColor ? iconPaths.white : iconPaths[status]
|
||||
|
||||
try {
|
||||
if (process.platform === 'darwin') {
|
||||
const icon = nativeImage.createFromPath(iconPath).resize({ height: 16 })
|
||||
tray.setImage(icon)
|
||||
} else if (process.platform === 'win32') {
|
||||
tray.setImage(iconPath)
|
||||
} else if (process.platform === 'linux') {
|
||||
tray.setImage(iconPath)
|
||||
}
|
||||
} catch {
|
||||
// Failed to update tray icon
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import { exePath, homeDir, taskDir } from '../utils/dirs'
|
||||
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(): string {
|
||||
function getTaskXml(asAdmin: boolean): string {
|
||||
const runLevel = asAdmin ? 'HighestAvailable' : 'LeastPrivilege'
|
||||
return `<?xml version="1.0" encoding="UTF-16"?>
|
||||
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<Triggers>
|
||||
@ -19,7 +22,7 @@ function getTaskXml(): string {
|
||||
<Principals>
|
||||
<Principal id="Author">
|
||||
<LogonType>InteractiveToken</LogonType>
|
||||
<RunLevel>HighestAvailable</RunLevel>
|
||||
<RunLevel>${runLevel}</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
@ -43,8 +46,7 @@ function getTaskXml(): string {
|
||||
</Settings>
|
||||
<Actions Context="Author">
|
||||
<Exec>
|
||||
<Command>"${path.join(taskDir(), `mihomo-party-run.exe`)}"</Command>
|
||||
<Arguments>"${exePath()}"</Arguments>
|
||||
<Command>"${exePath()}"</Command>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>
|
||||
@ -54,12 +56,24 @@ function getTaskXml(): string {
|
||||
export async function checkAutoRun(): Promise<boolean> {
|
||||
if (process.platform === 'win32') {
|
||||
const execPromise = promisify(exec)
|
||||
// 先检查任务计划程序
|
||||
try {
|
||||
const { stdout } = await execPromise(
|
||||
`chcp 437 && %SystemRoot%\\System32\\schtasks.exe /query /tn "${appName}"`
|
||||
)
|
||||
if (stdout.includes(appName)) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// 任务计划程序中不存在,继续检查注册表
|
||||
}
|
||||
|
||||
// 检查注册表备用方案
|
||||
try {
|
||||
const regPath = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
|
||||
const { stdout } = await execPromise(`reg query "${regPath}" /v "${appName}"`)
|
||||
return stdout.includes(appName)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -81,11 +95,51 @@ export async function checkAutoRun(): Promise<boolean> {
|
||||
export async function enableAutoRun(): Promise<void> {
|
||||
if (process.platform === 'win32') {
|
||||
const execPromise = promisify(exec)
|
||||
const taskFilePath = path.join(taskDir(), `${appName}.xml`)
|
||||
await writeFile(taskFilePath, Buffer.from(`\ufeff${getTaskXml()}`, 'utf-16le'))
|
||||
await execPromise(
|
||||
`%SystemRoot%\\System32\\schtasks.exe /create /tn "${appName}" /xml "${taskFilePath}" /f`
|
||||
)
|
||||
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)
|
||||
@ -102,7 +156,7 @@ Terminal=false
|
||||
Type=Application
|
||||
Icon=mihomo-party
|
||||
StartupWMClass=mihomo-party
|
||||
Comment=Mihomo Party
|
||||
Comment=Clash Party
|
||||
Categories=Utility;
|
||||
`
|
||||
|
||||
@ -121,7 +175,29 @@ Categories=Utility;
|
||||
export async function disableAutoRun(): Promise<void> {
|
||||
if (process.platform === 'win32') {
|
||||
const execPromise = promisify(exec)
|
||||
await execPromise(`%SystemRoot%\\System32\\schtasks.exe /delete /tn "${appName}" /f`)
|
||||
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)
|
||||
|
||||
@ -1,23 +1,21 @@
|
||||
import { exec, execFile, execSync, spawn } from 'child_process'
|
||||
import { app, dialog, nativeTheme, shell } from 'electron'
|
||||
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,
|
||||
resourcesFilesDir,
|
||||
taskDir
|
||||
resourcesDir
|
||||
} from '../utils/dirs'
|
||||
import { copyFileSync, writeFileSync } from 'fs'
|
||||
|
||||
export function getFilePath(ext: string[]): string[] | undefined {
|
||||
return dialog.showOpenDialogSync({
|
||||
title: '选择订阅文件',
|
||||
title: i18next.t('common.dialog.selectSubscriptionFile'),
|
||||
filters: [{ name: `${ext} file`, extensions: ext }],
|
||||
properties: ['openFile']
|
||||
})
|
||||
@ -37,8 +35,20 @@ export function openFile(type: 'profile' | 'override', id: string, ext?: 'yaml'
|
||||
}
|
||||
|
||||
export async function openUWPTool(): Promise<void> {
|
||||
const execPromise = promisify(exec)
|
||||
const execFilePromise = promisify(execFile)
|
||||
const uwpToolPath = path.join(resourcesDir(), 'files', 'enableLoopback.exe')
|
||||
|
||||
const { checkAdminPrivileges } = await import('../core/manager')
|
||||
const isAdmin = await checkAdminPrivileges()
|
||||
|
||||
if (!isAdmin) {
|
||||
const escapedPath = uwpToolPath.replace(/'/g, "''")
|
||||
const command = `powershell -NoProfile -Command "Start-Process -FilePath '${escapedPath}' -Verb RunAs -Wait"`
|
||||
|
||||
await execPromise(command, { windowsHide: true })
|
||||
return
|
||||
}
|
||||
await execFilePromise(uwpToolPath)
|
||||
}
|
||||
|
||||
@ -68,57 +78,6 @@ export function setNativeTheme(theme: 'system' | 'light' | 'dark'): void {
|
||||
nativeTheme.themeSource = theme
|
||||
}
|
||||
|
||||
function getElevateTaskXml(): string {
|
||||
return `<?xml version="1.0" encoding="UTF-16"?>
|
||||
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<Triggers />
|
||||
<Principals>
|
||||
<Principal id="Author">
|
||||
<LogonType>InteractiveToken</LogonType>
|
||||
<RunLevel>HighestAvailable</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
<MultipleInstancesPolicy>Parallel</MultipleInstancesPolicy>
|
||||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<AllowHardTerminate>false</AllowHardTerminate>
|
||||
<StartWhenAvailable>false</StartWhenAvailable>
|
||||
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
||||
<IdleSettings>
|
||||
<StopOnIdleEnd>false</StopOnIdleEnd>
|
||||
<RestartOnIdle>false</RestartOnIdle>
|
||||
</IdleSettings>
|
||||
<AllowStartOnDemand>true</AllowStartOnDemand>
|
||||
<Enabled>true</Enabled>
|
||||
<Hidden>false</Hidden>
|
||||
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
||||
<WakeToRun>false</WakeToRun>
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||
<Priority>3</Priority>
|
||||
</Settings>
|
||||
<Actions Context="Author">
|
||||
<Exec>
|
||||
<Command>"${path.join(taskDir(), `mihomo-party-run.exe`)}"</Command>
|
||||
<Arguments>"${exePath()}"</Arguments>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>
|
||||
`
|
||||
}
|
||||
|
||||
export function createElevateTask(): void {
|
||||
const taskFilePath = path.join(taskDir(), `mihomo-party-run.xml`)
|
||||
writeFileSync(taskFilePath, Buffer.from(`\ufeff${getElevateTaskXml()}`, 'utf-16le'))
|
||||
copyFileSync(
|
||||
path.join(resourcesFilesDir(), 'mihomo-party-run.exe'),
|
||||
path.join(taskDir(), 'mihomo-party-run.exe')
|
||||
)
|
||||
execSync(
|
||||
`%SystemRoot%\\System32\\schtasks.exe /create /tn "mihomo-party-run" /xml "${taskFilePath}" /f`
|
||||
)
|
||||
}
|
||||
|
||||
export function resetAppConfig(): void {
|
||||
if (process.platform === 'win32') {
|
||||
spawn(
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { ipcMain, net } from 'electron'
|
||||
import { getAppConfig, patchControledMihomoConfig } from '../config'
|
||||
import { patchMihomoConfig } from '../core/mihomoApi'
|
||||
import { mainWindow } from '..'
|
||||
import { ipcMain, net } from 'electron'
|
||||
import { mainWindow } from '../window'
|
||||
import { getDefaultDevice } from '../core/manager'
|
||||
|
||||
export async function getCurrentSSID(): Promise<string | undefined> {
|
||||
@ -32,6 +32,8 @@ export async function getCurrentSSID(): Promise<string | undefined> {
|
||||
}
|
||||
|
||||
let lastSSID: string | undefined
|
||||
let ssidCheckInterval: NodeJS.Timeout | null = null
|
||||
|
||||
export async function checkSSID(): Promise<void> {
|
||||
try {
|
||||
const { pauseSSID = [] } = await getAppConfig()
|
||||
@ -56,8 +58,18 @@ export async function checkSSID(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function startSSIDCheck(): Promise<void> {
|
||||
if (ssidCheckInterval) {
|
||||
clearInterval(ssidCheckInterval)
|
||||
}
|
||||
await checkSSID()
|
||||
setInterval(checkSSID, 30000)
|
||||
ssidCheckInterval = setInterval(checkSSID, 30000)
|
||||
}
|
||||
|
||||
export function stopSSIDCheck(): void {
|
||||
if (ssidCheckInterval) {
|
||||
clearInterval(ssidCheckInterval)
|
||||
ssidCheckInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
async function getSSIDByAirport(): Promise<string | undefined> {
|
||||
|
||||
@ -1,55 +1,59 @@
|
||||
import { triggerAutoProxy, triggerManualProxy } from '@mihomo-party/sysproxy'
|
||||
import { getAppConfig, getControledMihomoConfig } from '../config'
|
||||
import { pacPort, startPacServer, stopPacServer } from '../resolve/server'
|
||||
import { promisify } from 'util'
|
||||
import { execFile } from 'child_process'
|
||||
import path from 'path'
|
||||
import { resourcesFilesDir } from '../utils/dirs'
|
||||
import { exec } from 'child_process'
|
||||
import fs from 'fs'
|
||||
import { triggerAutoProxy, triggerManualProxy } from 'sysproxy-rs'
|
||||
import { net } from 'electron'
|
||||
import axios from 'axios'
|
||||
import fs from 'fs'
|
||||
import { getAppConfig, getControledMihomoConfig } from '../config'
|
||||
import { pacPort, startPacServer, stopPacServer } from '../resolve/server'
|
||||
import { proxyLogger } from '../utils/logger'
|
||||
|
||||
let defaultBypass: string[]
|
||||
let triggerSysProxyTimer: NodeJS.Timeout | null = null
|
||||
const helperSocketPath = '/tmp/mihomo-party-helper.sock'
|
||||
|
||||
if (process.platform === 'linux')
|
||||
defaultBypass = ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1']
|
||||
if (process.platform === 'darwin')
|
||||
defaultBypass = [
|
||||
'127.0.0.1',
|
||||
'192.168.0.0/16',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'localhost',
|
||||
'*.local',
|
||||
'*.crashlytics.com',
|
||||
'<local>'
|
||||
]
|
||||
if (process.platform === 'win32')
|
||||
defaultBypass = [
|
||||
'localhost',
|
||||
'127.*',
|
||||
'192.168.*',
|
||||
'10.*',
|
||||
'172.16.*',
|
||||
'172.17.*',
|
||||
'172.18.*',
|
||||
'172.19.*',
|
||||
'172.20.*',
|
||||
'172.21.*',
|
||||
'172.22.*',
|
||||
'172.23.*',
|
||||
'172.24.*',
|
||||
'172.25.*',
|
||||
'172.26.*',
|
||||
'172.27.*',
|
||||
'172.28.*',
|
||||
'172.29.*',
|
||||
'172.30.*',
|
||||
'172.31.*',
|
||||
'<local>'
|
||||
]
|
||||
const defaultBypass: string[] = (() => {
|
||||
switch (process.platform) {
|
||||
case 'linux':
|
||||
return ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1']
|
||||
case 'darwin':
|
||||
return [
|
||||
'127.0.0.1',
|
||||
'192.168.0.0/16',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'localhost',
|
||||
'*.local',
|
||||
'*.crashlytics.com',
|
||||
'<local>'
|
||||
]
|
||||
case 'win32':
|
||||
return [
|
||||
'localhost',
|
||||
'127.*',
|
||||
'192.168.*',
|
||||
'10.*',
|
||||
'172.16.*',
|
||||
'172.17.*',
|
||||
'172.18.*',
|
||||
'172.19.*',
|
||||
'172.20.*',
|
||||
'172.21.*',
|
||||
'172.22.*',
|
||||
'172.23.*',
|
||||
'172.24.*',
|
||||
'172.25.*',
|
||||
'172.26.*',
|
||||
'172.27.*',
|
||||
'172.28.*',
|
||||
'172.29.*',
|
||||
'172.30.*',
|
||||
'172.31.*',
|
||||
'<local>'
|
||||
]
|
||||
default:
|
||||
return ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1']
|
||||
}
|
||||
})()
|
||||
|
||||
export async function triggerSysProxy(enable: boolean): Promise<void> {
|
||||
if (net.isOnline()) {
|
||||
@ -70,87 +74,59 @@ async function enableSysProxy(): Promise<void> {
|
||||
const { sysProxy } = await getAppConfig()
|
||||
const { mode, host, bypass = defaultBypass } = sysProxy
|
||||
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
|
||||
const execFilePromise = promisify(execFile)
|
||||
switch (mode || 'manual') {
|
||||
case 'auto': {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
await execFilePromise(path.join(resourcesFilesDir(), 'sysproxy.exe'), [
|
||||
'pac',
|
||||
`http://${host || '127.0.0.1'}:${pacPort}/pac`
|
||||
])
|
||||
} catch {
|
||||
triggerAutoProxy(true, `http://${host || '127.0.0.1'}:${pacPort}/pac`)
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
await helperRequest(() =>
|
||||
axios.post(
|
||||
'http://localhost/pac',
|
||||
{ url: `http://${host || '127.0.0.1'}:${pacPort}/pac` },
|
||||
{
|
||||
socketPath: helperSocketPath
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
triggerAutoProxy(true, `http://${host || '127.0.0.1'}:${pacPort}/pac`)
|
||||
}
|
||||
const proxyHost = host || '127.0.0.1'
|
||||
|
||||
break
|
||||
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 }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
case 'manual': {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
await execFilePromise(path.join(resourcesFilesDir(), 'sysproxy.exe'), [
|
||||
'global',
|
||||
`${host || '127.0.0.1'}:${port}`,
|
||||
bypass.join(';')
|
||||
])
|
||||
} catch {
|
||||
triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(','))
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
await helperRequest(() =>
|
||||
axios.post(
|
||||
'http://localhost/global',
|
||||
{ host: host || '127.0.0.1', port: port.toString(), bypass: bypass.join(',') },
|
||||
{
|
||||
socketPath: helperSocketPath
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// Windows / Linux 直接使用 sysproxy-rs
|
||||
try {
|
||||
if (mode === 'auto') {
|
||||
triggerAutoProxy(true, `http://${proxyHost}:${pacPort}/pac`)
|
||||
} else {
|
||||
triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(','))
|
||||
triggerManualProxy(true, proxyHost, port, bypass.join(','))
|
||||
}
|
||||
break
|
||||
} catch (error) {
|
||||
await proxyLogger.error('Failed to enable system proxy', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function disableSysProxy(): Promise<void> {
|
||||
await stopPacServer()
|
||||
const execFilePromise = promisify(execFile)
|
||||
if (process.platform === 'win32') {
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
await helperRequest(() => axios.get('http://localhost/off', { socketPath: helperSocketPath }))
|
||||
} else {
|
||||
// Windows / Linux 直接使用 sysproxy-rs
|
||||
try {
|
||||
await execFilePromise(path.join(resourcesFilesDir(), 'sysproxy.exe'), ['set', '1'])
|
||||
} catch {
|
||||
triggerAutoProxy(false, '')
|
||||
triggerManualProxy(false, '', 0, '')
|
||||
} catch (error) {
|
||||
await proxyLogger.error('Failed to disable system proxy', error)
|
||||
throw error
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
await helperRequest(() =>
|
||||
axios.get('http://localhost/off', {
|
||||
socketPath: helperSocketPath
|
||||
})
|
||||
)
|
||||
} else {
|
||||
triggerAutoProxy(false, '')
|
||||
triggerManualProxy(false, '', 0, '')
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if socket file exists
|
||||
function isSocketFileExists(): boolean {
|
||||
try {
|
||||
return fs.existsSync(helperSocketPath)
|
||||
@ -159,64 +135,88 @@ function isSocketFileExists(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to send signal to recreate socket
|
||||
async function isHelperRunning(): Promise<boolean> {
|
||||
try {
|
||||
const execPromise = promisify(exec)
|
||||
const { stdout } = await execPromise('pgrep -f party.mihomo.helper')
|
||||
return stdout.trim().length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function startHelperService(): Promise<void> {
|
||||
const execPromise = promisify(exec)
|
||||
const shell = `launchctl kickstart -k system/party.mihomo.helper`
|
||||
const command = `do shell script "${shell}" with administrator privileges`
|
||||
await execPromise(`osascript -e '${command}'`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
}
|
||||
|
||||
async function requestSocketRecreation(): Promise<void> {
|
||||
try {
|
||||
// Send SIGUSR1 signal to helper process to recreate socket
|
||||
const { exec } = require('child_process')
|
||||
const { promisify } = require('util')
|
||||
const execPromise = promisify(exec)
|
||||
|
||||
// Use osascript with administrator privileges (same pattern as manualGrantCorePermition)
|
||||
const shell = `pkill -USR1 -f party.mihomo.helper`
|
||||
const command = `do shell script "${shell}" with administrator privileges`
|
||||
await execPromise(`osascript -e '${command}'`)
|
||||
|
||||
// Wait a bit for socket recreation
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
} catch (error) {
|
||||
console.log('Failed to send signal to helper:', error)
|
||||
await proxyLogger.error('Failed to send signal to helper', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper function for helper requests with auto-retry on socket issues
|
||||
async function helperRequest(requestFn: () => Promise<unknown>, maxRetries = 1): Promise<unknown> {
|
||||
async function helperRequest(requestFn: () => Promise<unknown>, maxRetries = 2): Promise<unknown> {
|
||||
let lastError: Error | null = null
|
||||
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await requestFn()
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
|
||||
// Check if it's a connection error and socket file doesn't exist
|
||||
if (attempt < maxRetries &&
|
||||
((error as NodeJS.ErrnoException).code === 'ECONNREFUSED' ||
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT' ||
|
||||
(error as Error).message?.includes('connect ECONNREFUSED') ||
|
||||
(error as Error).message?.includes('ENOENT'))) {
|
||||
|
||||
console.log(`Helper request failed (attempt ${attempt + 1}), checking socket file...`)
|
||||
|
||||
if (!isSocketFileExists()) {
|
||||
console.log('Socket file missing, requesting recreation...')
|
||||
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()
|
||||
console.log('Socket recreation requested, retrying...')
|
||||
await proxyLogger.info('Socket recreation requested, retrying...')
|
||||
continue
|
||||
} catch (signalError) {
|
||||
console.log('Failed to request socket recreation:', signalError)
|
||||
await proxyLogger.warn('Failed to request socket recreation', signalError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not a connection error or we've exhausted retries, throw the error
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
throw lastError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
63
src/main/utils/appName.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { spawnSync } from 'child_process'
|
||||
import plist from 'plist'
|
||||
import { findBestAppPath, isIOSApp } from './icon'
|
||||
|
||||
export async function getAppName(appPath: string): Promise<string> {
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const targetPath = findBestAppPath(appPath)
|
||||
if (!targetPath) return ''
|
||||
|
||||
if (isIOSApp(targetPath)) {
|
||||
const plistPath = path.join(targetPath, 'Info.plist')
|
||||
const xml = fs.readFileSync(plistPath, 'utf-8')
|
||||
const parsed = plist.parse(xml) as Record<string, unknown>
|
||||
return (parsed.CFBundleDisplayName as string) || (parsed.CFBundleName as string) || ''
|
||||
}
|
||||
|
||||
try {
|
||||
const appName = getLocalizedAppName(targetPath)
|
||||
if (appName) return appName
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const plistPath = path.join(targetPath, 'Contents', 'Info.plist')
|
||||
if (fs.existsSync(plistPath)) {
|
||||
const xml = fs.readFileSync(plistPath, 'utf-8')
|
||||
const parsed = plist.parse(xml) as Record<string, unknown>
|
||||
|
||||
return (parsed.CFBundleDisplayName as string) || (parsed.CFBundleName as string) || ''
|
||||
} else {
|
||||
// ignore
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getLocalizedAppName(appPath: string): string {
|
||||
const escapedPath = appPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
||||
const jxa = `
|
||||
ObjC.import('Foundation');
|
||||
const fm = $.NSFileManager.defaultManager;
|
||||
const name = fm.displayNameAtPath('${escapedPath}');
|
||||
name.js;
|
||||
`
|
||||
const res = spawnSync('osascript', ['-l', 'JavaScript'], {
|
||||
input: jxa,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
if (res.error) {
|
||||
throw res.error
|
||||
}
|
||||
if (res.status !== 0) {
|
||||
throw new Error(res.stderr.trim() || `osascript exited ${res.status}`)
|
||||
}
|
||||
return res.stdout.trim()
|
||||
}
|
||||
@ -21,7 +21,7 @@ export function calcTraffic(byte: number): string {
|
||||
function formatNumString(num: number): string {
|
||||
let str = num.toFixed(2)
|
||||
if (str.length <= 5) return str
|
||||
if (str.length == 6) {
|
||||
if (str.length === 6) {
|
||||
str = num.toFixed(1)
|
||||
return str
|
||||
} else {
|
||||
|
||||
271
src/main/utils/chromeRequest.ts
Normal file
@ -0,0 +1,271 @@
|
||||
import { net, session } from 'electron'
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
||||
headers?: Record<string, string>
|
||||
body?: string | Buffer
|
||||
proxy?:
|
||||
| {
|
||||
protocol: 'http' | 'https' | 'socks5'
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
| false
|
||||
timeout?: number
|
||||
responseType?: 'text' | 'json' | 'arraybuffer'
|
||||
followRedirect?: boolean
|
||||
maxRedirects?: number
|
||||
onProgress?: (loaded: number, total: number) => void
|
||||
}
|
||||
|
||||
export interface Response<T = unknown> {
|
||||
data: T
|
||||
status: number
|
||||
statusText: string
|
||||
headers: Record<string, string>
|
||||
url: string
|
||||
}
|
||||
|
||||
// 复用单个 session 用于代理请求
|
||||
let proxySession: Electron.Session | null = null
|
||||
let currentProxyUrl: string | null = null
|
||||
let proxySetupPromise: Promise<void> | null = null
|
||||
|
||||
async function getProxySession(proxyUrl: string): Promise<Electron.Session> {
|
||||
if (!proxySession) {
|
||||
proxySession = session.fromPartition('proxy-requests', { cache: false })
|
||||
}
|
||||
if (currentProxyUrl !== proxyUrl) {
|
||||
proxySetupPromise = proxySession.setProxy({ proxyRules: proxyUrl })
|
||||
currentProxyUrl = proxyUrl
|
||||
}
|
||||
if (proxySetupPromise) {
|
||||
await proxySetupPromise
|
||||
}
|
||||
return proxySession
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP request using Chromium's network stack (via electron.net)
|
||||
* This provides better compatibility, HTTP/2 support, and system certificate integration
|
||||
*/
|
||||
export async function request<T = unknown>(
|
||||
url: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<Response<T>> {
|
||||
const {
|
||||
method = 'GET',
|
||||
headers = {},
|
||||
body,
|
||||
proxy,
|
||||
timeout = 30000,
|
||||
responseType = 'text',
|
||||
followRedirect = true,
|
||||
maxRedirects = 20,
|
||||
onProgress
|
||||
} = options
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let sessionToUse: Electron.Session = session.defaultSession
|
||||
|
||||
// Set up proxy if specified
|
||||
const setupProxy = async (): Promise<void> => {
|
||||
if (proxy) {
|
||||
const proxyUrl = `${proxy.protocol}://${proxy.host}:${proxy.port}`
|
||||
sessionToUse = await getProxySession(proxyUrl)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = (): void => {}
|
||||
|
||||
setupProxy()
|
||||
.then(() => {
|
||||
const req = net.request({
|
||||
method,
|
||||
url,
|
||||
session: sessionToUse,
|
||||
redirect: followRedirect ? 'follow' : 'manual',
|
||||
useSessionCookies: true
|
||||
})
|
||||
|
||||
// Set request headers
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
req.setHeader(key, value)
|
||||
})
|
||||
|
||||
// Timeout handling
|
||||
let timeoutId: NodeJS.Timeout | undefined
|
||||
if (timeout > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
req.abort()
|
||||
cleanup()
|
||||
reject(new Error(`Request timeout after ${timeout}ms`))
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
let redirectCount = 0
|
||||
|
||||
req.on('redirect', () => {
|
||||
redirectCount++
|
||||
if (redirectCount > maxRedirects) {
|
||||
req.abort()
|
||||
cleanup()
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
reject(new Error(`Too many redirects (>${maxRedirects})`))
|
||||
}
|
||||
})
|
||||
|
||||
req.on('response', (res) => {
|
||||
const { statusCode, statusMessage } = res
|
||||
|
||||
// Extract response headers
|
||||
const responseHeaders: Record<string, string> = {}
|
||||
const rawHeaders = res.rawHeaders || []
|
||||
for (let i = 0; i < rawHeaders.length; i += 2) {
|
||||
responseHeaders[rawHeaders[i].toLowerCase()] = rawHeaders[i + 1]
|
||||
}
|
||||
|
||||
const totalSize = parseInt(responseHeaders['content-length'] || '0', 10)
|
||||
let loadedSize = 0
|
||||
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk)
|
||||
if (onProgress && totalSize > 0) {
|
||||
loadedSize += chunk.length
|
||||
onProgress(loadedSize, totalSize)
|
||||
}
|
||||
})
|
||||
|
||||
res.on('end', () => {
|
||||
cleanup()
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
|
||||
const buffer = Buffer.concat(chunks)
|
||||
let data: unknown
|
||||
|
||||
try {
|
||||
switch (responseType) {
|
||||
case 'json':
|
||||
data = JSON.parse(buffer.toString('utf-8'))
|
||||
break
|
||||
case 'arraybuffer':
|
||||
data = buffer
|
||||
break
|
||||
case 'text':
|
||||
default:
|
||||
data = buffer.toString('utf-8')
|
||||
}
|
||||
|
||||
resolve({
|
||||
data: data as T,
|
||||
status: statusCode,
|
||||
statusText: statusMessage,
|
||||
headers: responseHeaders,
|
||||
url: url
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
reject(new Error(`Failed to parse response: ${String(error)}`))
|
||||
}
|
||||
})
|
||||
|
||||
res.on('error', (error: unknown) => {
|
||||
cleanup()
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', (error: unknown) => {
|
||||
cleanup()
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
req.on('abort', () => {
|
||||
cleanup()
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
reject(new Error('Request aborted'))
|
||||
})
|
||||
|
||||
// Send request body
|
||||
if (body) {
|
||||
if (typeof body === 'string') {
|
||||
req.write(body, 'utf-8')
|
||||
} else {
|
||||
req.write(body)
|
||||
}
|
||||
}
|
||||
|
||||
req.end()
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
cleanup()
|
||||
reject(new Error(`Failed to setup proxy: ${String(error)}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for GET requests
|
||||
*/
|
||||
export const get = <T = unknown>(
|
||||
url: string,
|
||||
options?: Omit<RequestOptions, 'method' | 'body'>
|
||||
): Promise<Response<T>> => request<T>(url, { ...options, method: 'GET' })
|
||||
|
||||
/**
|
||||
* Convenience method for POST requests
|
||||
*/
|
||||
export const post = <T = unknown>(
|
||||
url: string,
|
||||
data: unknown,
|
||||
options?: Omit<RequestOptions, 'method' | 'body'>
|
||||
): Promise<Response<T>> => {
|
||||
const body = typeof data === 'string' ? data : JSON.stringify(data)
|
||||
const headers = options?.headers || {}
|
||||
if (typeof data !== 'string' && !headers['content-type']) {
|
||||
headers['content-type'] = 'application/json'
|
||||
}
|
||||
return request<T>(url, { ...options, method: 'POST', body, headers })
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for PUT requests
|
||||
*/
|
||||
export const put = <T = unknown>(
|
||||
url: string,
|
||||
data: unknown,
|
||||
options?: Omit<RequestOptions, 'method' | 'body'>
|
||||
): Promise<Response<T>> => {
|
||||
const body = typeof data === 'string' ? data : JSON.stringify(data)
|
||||
const headers = options?.headers || {}
|
||||
if (typeof data !== 'string' && !headers['content-type']) {
|
||||
headers['content-type'] = 'application/json'
|
||||
}
|
||||
return request<T>(url, { ...options, method: 'PUT', body, headers })
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for DELETE requests
|
||||
*/
|
||||
export const del = <T = unknown>(
|
||||
url: string,
|
||||
options?: Omit<RequestOptions, 'method' | 'body'>
|
||||
): Promise<Response<T>> => request<T>(url, { ...options, method: 'DELETE' })
|
||||
|
||||
/**
|
||||
* Convenience method for PATCH requests
|
||||
*/
|
||||
export const patch = <T = unknown>(
|
||||
url: string,
|
||||
data: unknown,
|
||||
options?: Omit<RequestOptions, 'method' | 'body'>
|
||||
): Promise<Response<T>> => {
|
||||
const body = typeof data === 'string' ? data : JSON.stringify(data)
|
||||
const headers = options?.headers || {}
|
||||
if (typeof data !== 'string' && !headers['content-type']) {
|
||||
headers['content-type'] = 'application/json'
|
||||
}
|
||||
return request<T>(url, { ...options, method: 'PATCH', body, headers })
|
||||
}
|
||||
6
src/main/utils/defaultIcon.ts
Normal file
@ -1,7 +1,7 @@
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { app } from 'electron'
|
||||
import path from 'path'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { app } from 'electron'
|
||||
|
||||
export const homeDir = app.getPath('home')
|
||||
|
||||
@ -23,7 +23,7 @@ export function taskDir(): string {
|
||||
if (!existsSync(userDataDir)) {
|
||||
mkdirSync(userDataDir, { recursive: true })
|
||||
}
|
||||
|
||||
|
||||
const dir = path.join(userDataDir, 'tasks')
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
@ -69,6 +69,10 @@ export function mihomoCoreDir(): string {
|
||||
|
||||
export function mihomoCorePath(core: string): string {
|
||||
const isWin = process.platform === 'win32'
|
||||
// 处理 Smart 内核
|
||||
if (core === 'mihomo-smart') {
|
||||
return path.join(mihomoCoreDir(), `mihomo-smart${isWin ? '.exe' : ''}`)
|
||||
}
|
||||
return path.join(mihomoCoreDir(), `${core}${isWin ? '.exe' : ''}`)
|
||||
}
|
||||
|
||||
@ -130,12 +134,35 @@ export function logDir(): string {
|
||||
|
||||
export function logPath(): string {
|
||||
const date = new Date()
|
||||
const name = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const name = `clash-party-${year}-${month}-${day}`
|
||||
return path.join(logDir(), `${name}.log`)
|
||||
}
|
||||
|
||||
export function substoreLogPath(): string {
|
||||
const date = new Date()
|
||||
const name = `sub-store-${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const name = `sub-store-${year}-${month}-${day}`
|
||||
return path.join(logDir(), `${name}.log`)
|
||||
}
|
||||
|
||||
export function coreLogPath(): string {
|
||||
const date = new Date()
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const name = `core-${year}-${month}-${day}`
|
||||
return path.join(logDir(), `${name}.log`)
|
||||
}
|
||||
|
||||
export function rulesDir(): string {
|
||||
return path.join(dataDir(), 'rules')
|
||||
}
|
||||
|
||||
export function rulePath(id: string): string {
|
||||
return path.join(rulesDir(), `${id}.yaml`)
|
||||
}
|
||||
|
||||
228
src/main/utils/github.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import { createWriteStream, createReadStream, existsSync, rmSync } from 'fs'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { execSync } from 'child_process'
|
||||
import { platform } from 'os'
|
||||
import { join } from 'path'
|
||||
import { createGunzip } from 'zlib'
|
||||
import AdmZip from 'adm-zip'
|
||||
import { stopCore } from '../core/manager'
|
||||
import { mihomoCoreDir } from './dirs'
|
||||
import * as chromeRequest from './chromeRequest'
|
||||
import { createLogger } from './logger'
|
||||
|
||||
const log = createLogger('GitHub')
|
||||
|
||||
export interface GitHubTag {
|
||||
name: string
|
||||
zipball_url: string
|
||||
tarball_url: string
|
||||
}
|
||||
|
||||
interface VersionCache {
|
||||
data: GitHubTag[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const CACHE_EXPIRY = 5 * 60 * 1000
|
||||
|
||||
const GITHUB_API_CONFIG = {
|
||||
BASE_URL: 'https://api.github.com',
|
||||
API_VERSION: '2022-11-28',
|
||||
TAGS_PER_PAGE: 100
|
||||
}
|
||||
|
||||
const PLATFORM_MAP: Record<string, string> = {
|
||||
'win32-x64': 'mihomo-windows-amd64-compatible',
|
||||
'win32-ia32': 'mihomo-windows-386',
|
||||
'win32-arm64': 'mihomo-windows-arm64',
|
||||
'darwin-x64': 'mihomo-darwin-amd64-compatible',
|
||||
'darwin-arm64': 'mihomo-darwin-arm64',
|
||||
'linux-x64': 'mihomo-linux-amd64-compatible',
|
||||
'linux-arm64': 'mihomo-linux-arm64'
|
||||
}
|
||||
|
||||
const versionCache = new Map<string, VersionCache>()
|
||||
|
||||
/**
|
||||
* 获取 GitHub 仓库的标签列表(带缓存)
|
||||
* @param owner 仓库所有者
|
||||
* @param repo 仓库名称
|
||||
* @param forceRefresh 是否强制刷新缓存
|
||||
* @returns 标签列表
|
||||
*/
|
||||
export async function getGitHubTags(
|
||||
owner: string,
|
||||
repo: string,
|
||||
forceRefresh = false
|
||||
): Promise<GitHubTag[]> {
|
||||
const cacheKey = `${owner}/${repo}`
|
||||
|
||||
// 检查缓存
|
||||
if (!forceRefresh && versionCache.has(cacheKey)) {
|
||||
const cache = versionCache.get(cacheKey)
|
||||
if (cache && Date.now() - cache.timestamp < CACHE_EXPIRY) {
|
||||
log.debug(`Returning cached tags for ${owner}/${repo}`)
|
||||
return cache.data
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug(`Fetching tags for ${owner}/${repo}`)
|
||||
const response = await chromeRequest.get<GitHubTag[]>(
|
||||
`${GITHUB_API_CONFIG.BASE_URL}/repos/${owner}/${repo}/tags?per_page=${GITHUB_API_CONFIG.TAGS_PER_PAGE}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': GITHUB_API_CONFIG.API_VERSION
|
||||
},
|
||||
responseType: 'json',
|
||||
timeout: 10000
|
||||
}
|
||||
)
|
||||
|
||||
// 更新缓存
|
||||
versionCache.set(cacheKey, {
|
||||
data: response.data,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
log.debug(`Successfully fetched ${response.data.length} tags for ${owner}/${repo}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
log.error(`Failed to fetch tags for ${owner}/${repo}`, error)
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`GitHub API error: ${error.message}`)
|
||||
}
|
||||
throw new Error('Failed to fetch version list')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除版本缓存
|
||||
* @param owner 仓库所有者
|
||||
* @param repo 仓库名称
|
||||
*/
|
||||
export function clearVersionCache(owner: string, repo: string): void {
|
||||
const cacheKey = `${owner}/${repo}`
|
||||
const hasCache = versionCache.has(cacheKey)
|
||||
versionCache.delete(cacheKey)
|
||||
log.debug(`Cache ${hasCache ? 'cleared' : 'not found'} for ${owner}/${repo}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 GitHub Release 资产
|
||||
* @param url 下载 URL
|
||||
* @param outputPath 输出路径
|
||||
*/
|
||||
async function downloadGitHubAsset(url: string, outputPath: string): Promise<void> {
|
||||
try {
|
||||
log.debug(`Downloading asset from ${url}`)
|
||||
const response = await chromeRequest.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
await writeFile(outputPath, Buffer.from(response.data as Buffer))
|
||||
log.debug(`Successfully downloaded asset to ${outputPath}`)
|
||||
} catch (error) {
|
||||
log.error(`Failed to download asset from ${url}`, error)
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Download error: ${error.message}`)
|
||||
}
|
||||
throw new Error('Failed to download core file')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装特定版本的 mihomo 核心
|
||||
* @param version 版本号
|
||||
*/
|
||||
export async function installMihomoCore(version: string): Promise<void> {
|
||||
try {
|
||||
log.info(`Installing mihomo core version ${version}`)
|
||||
|
||||
const plat = platform()
|
||||
const arch = process.arch
|
||||
|
||||
// 映射平台和架构到 GitHub Release 文件名
|
||||
const key = `${plat}-${arch}`
|
||||
const name = PLATFORM_MAP[key]
|
||||
|
||||
if (!name) {
|
||||
throw new Error(`Unsupported platform "${plat}-${arch}"`)
|
||||
}
|
||||
|
||||
const isWin = plat === 'win32'
|
||||
const urlExt = isWin ? 'zip' : 'gz'
|
||||
const downloadURL = `https://github.com/MetaCubeX/mihomo/releases/download/${version}/${name}-${version}.${urlExt}`
|
||||
|
||||
const coreDir = mihomoCoreDir()
|
||||
const tempZip = join(coreDir, `temp-core.${urlExt}`)
|
||||
const exeFile = `${name}${isWin ? '.exe' : ''}`
|
||||
const targetFile = `mihomo-specific${isWin ? '.exe' : ''}`
|
||||
const targetPath = join(coreDir, targetFile)
|
||||
|
||||
// 如果目标文件已存在,先停止核心
|
||||
if (existsSync(targetPath)) {
|
||||
log.debug('Stopping core before extracting new core file')
|
||||
// 先停止核心
|
||||
await stopCore(true)
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
await downloadGitHubAsset(downloadURL, tempZip)
|
||||
|
||||
// 解压文件
|
||||
if (urlExt === 'zip') {
|
||||
log.debug(`Extracting ZIP file ${tempZip}`)
|
||||
const zip = new AdmZip(tempZip)
|
||||
const entries = zip.getEntries()
|
||||
const entry = entries.find((e) => e.entryName.includes(exeFile))
|
||||
|
||||
if (entry) {
|
||||
zip.extractEntryTo(entry, coreDir, false, true, false, targetFile)
|
||||
log.debug(`Successfully extracted ${exeFile} to ${targetPath}`)
|
||||
} else {
|
||||
throw new Error(`Executable file not found in zip: ${exeFile}`)
|
||||
}
|
||||
} else {
|
||||
// 处理.gz 文件
|
||||
log.debug(`Extracting GZ file ${tempZip}`)
|
||||
const readStream = createReadStream(tempZip)
|
||||
const writeStream = createWriteStream(targetPath)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error) => {
|
||||
log.error('Gzip decompression failed', error)
|
||||
reject(new Error(`Gzip decompression failed: ${error.message}`))
|
||||
}
|
||||
|
||||
readStream
|
||||
.pipe(createGunzip().on('error', onError))
|
||||
.pipe(writeStream)
|
||||
.on('finish', () => {
|
||||
log.debug('Gunzip finished')
|
||||
try {
|
||||
execSync(`chmod 755 ${targetPath}`)
|
||||
log.debug('Chmod binary finished')
|
||||
} catch (chmodError) {
|
||||
log.warn('Failed to chmod binary', chmodError)
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
.on('error', onError)
|
||||
})
|
||||
}
|
||||
|
||||
// 清理临时文件
|
||||
log.debug(`Cleaning up temporary file ${tempZip}`)
|
||||
rmSync(tempZip)
|
||||
|
||||
log.info(`Successfully installed mihomo core version ${version}`)
|
||||
} catch (error) {
|
||||
log.error('Failed to install mihomo core', error)
|
||||
throw new Error(
|
||||
`Failed to install core: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
287
src/main/utils/icon.ts
Normal file
@ -0,0 +1,287 @@
|
||||
import { exec } from 'child_process'
|
||||
import fs, { existsSync } from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import axios from 'axios'
|
||||
import { getIcon } from 'file-icon-info'
|
||||
import { app } from 'electron'
|
||||
import { getControledMihomoConfig } from '../config'
|
||||
import { windowsDefaultIcon, darwinDefaultIcon, otherDevicesIcon } from './defaultIcon'
|
||||
|
||||
export function isIOSApp(appPath: string): boolean {
|
||||
const appDir = appPath.endsWith('.app')
|
||||
? appPath
|
||||
: appPath.includes('.app')
|
||||
? appPath.substring(0, appPath.indexOf('.app') + 4)
|
||||
: path.dirname(appPath)
|
||||
|
||||
return !fs.existsSync(path.join(appDir, 'Contents'))
|
||||
}
|
||||
|
||||
function hasIOSAppIcon(appPath: string): boolean {
|
||||
try {
|
||||
const items = fs.readdirSync(appPath)
|
||||
return items.some((item) => {
|
||||
const lower = item.toLowerCase()
|
||||
const ext = path.extname(item).toLowerCase()
|
||||
return lower.startsWith('appicon') && (ext === '.png' || ext === '.jpg' || ext === '.jpeg')
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function hasMacOSAppIcon(appPath: string): boolean {
|
||||
const resourcesDir = path.join(appPath, 'Contents', 'Resources')
|
||||
if (!fs.existsSync(resourcesDir)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const items = fs.readdirSync(resourcesDir)
|
||||
return items.some((item) => path.extname(item).toLowerCase() === '.icns')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function findBestAppPath(appPath: string): string | null {
|
||||
if (!appPath.includes('.app') && !appPath.includes('.xpc')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parts = appPath.split(path.sep)
|
||||
const appPaths: string[] = []
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (parts[i].endsWith('.app') || parts[i].endsWith('.xpc')) {
|
||||
const fullPath = parts.slice(0, i + 1).join(path.sep)
|
||||
appPaths.push(fullPath)
|
||||
}
|
||||
}
|
||||
if (appPaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (appPaths.length === 1) {
|
||||
return appPaths[0]
|
||||
}
|
||||
for (let i = appPaths.length - 1; i >= 0; i--) {
|
||||
const appDir = appPaths[i]
|
||||
if (isIOSApp(appDir)) {
|
||||
if (hasIOSAppIcon(appDir)) {
|
||||
return appDir
|
||||
}
|
||||
} else {
|
||||
if (hasMacOSAppIcon(appDir)) {
|
||||
return appDir
|
||||
}
|
||||
}
|
||||
}
|
||||
return appPaths[0]
|
||||
}
|
||||
|
||||
async function findDesktopFile(appPath: string): Promise<string | null> {
|
||||
try {
|
||||
const execName = path.isAbsolute(appPath) ? path.basename(appPath) : appPath
|
||||
const desktopDirs = ['/usr/share/applications', `${process.env.HOME}/.local/share/applications`]
|
||||
|
||||
for (const dir of desktopDirs) {
|
||||
if (!existsSync(dir)) continue
|
||||
|
||||
const files = fs.readdirSync(dir)
|
||||
const desktopFiles = files.filter((file) => file.endsWith('.desktop'))
|
||||
|
||||
for (const file of desktopFiles) {
|
||||
const fullPath = path.join(dir, file)
|
||||
try {
|
||||
const content = fs.readFileSync(fullPath, 'utf-8')
|
||||
|
||||
const execMatch = content.match(/^Exec\s*=\s*(.+?)$/m)
|
||||
if (execMatch) {
|
||||
const execLine = execMatch[1].trim()
|
||||
const execCmd = execLine.split(/\s+/)[0]
|
||||
const execBasename = path.basename(execCmd)
|
||||
|
||||
if (
|
||||
execCmd === appPath ||
|
||||
execBasename === execName ||
|
||||
execCmd.endsWith(appPath) ||
|
||||
appPath.endsWith(execBasename)
|
||||
) {
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
|
||||
const nameRegex = new RegExp(`^Name\\s*=\\s*${appPath}\\s*$`, 'im')
|
||||
const genericNameRegex = new RegExp(`^GenericName\\s*=\\s*${appPath}\\s*$`, 'im')
|
||||
|
||||
if (nameRegex.test(content) || genericNameRegex.test(content)) {
|
||||
return fullPath
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseIconNameFromDesktopFile(content: string): string | null {
|
||||
const match = content.match(/^Icon\s*=\s*(.+?)$/m)
|
||||
return match ? match[1].trim() : null
|
||||
}
|
||||
|
||||
function resolveIconPath(iconName: string): string | null {
|
||||
if (path.isAbsolute(iconName) && existsSync(iconName)) {
|
||||
return iconName
|
||||
}
|
||||
|
||||
const searchPaths: string[] = []
|
||||
const sizes = ['512x512', '256x256', '128x128', '64x64', '48x48', '32x32', '24x24', '16x16']
|
||||
const extensions = ['png', 'svg', 'xpm']
|
||||
const iconDirs = [
|
||||
'/usr/share/icons/hicolor',
|
||||
'/usr/share/pixmaps',
|
||||
'/usr/share/icons/Adwaita',
|
||||
`${process.env.HOME}/.local/share/icons`
|
||||
]
|
||||
|
||||
for (const dir of iconDirs) {
|
||||
for (const size of sizes) {
|
||||
for (const ext of extensions) {
|
||||
searchPaths.push(path.join(dir, size, 'apps', `${iconName}.${ext}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ext of extensions) {
|
||||
searchPaths.push(`/usr/share/pixmaps/${iconName}.${ext}`)
|
||||
}
|
||||
for (const dir of iconDirs) {
|
||||
for (const ext of extensions) {
|
||||
searchPaths.push(path.join(dir, `${iconName}.${ext}`))
|
||||
}
|
||||
}
|
||||
|
||||
return searchPaths.find((iconPath) => existsSync(iconPath)) || null
|
||||
}
|
||||
|
||||
export async function getIconDataURL(appPath: string): Promise<string> {
|
||||
if (!appPath) {
|
||||
return otherDevicesIcon
|
||||
}
|
||||
if (appPath === 'mihomo') {
|
||||
appPath = app.getPath('exe')
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
if (!appPath.includes('.app') && !appPath.includes('.xpc')) {
|
||||
return darwinDefaultIcon
|
||||
}
|
||||
const { fileIconToBuffer } = await import('file-icon')
|
||||
const targetPath = findBestAppPath(appPath)
|
||||
if (!targetPath) {
|
||||
return darwinDefaultIcon
|
||||
}
|
||||
const iconBuffer = await fileIconToBuffer(targetPath, { size: 512 })
|
||||
const base64Icon = Buffer.from(iconBuffer).toString('base64')
|
||||
return `data:image/png;base64,${base64Icon}`
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (fs.existsSync(appPath) && /\.(exe|dll)$/i.test(appPath)) {
|
||||
try {
|
||||
let targetPath = appPath
|
||||
let tempLinkPath: string | null = null
|
||||
|
||||
if (/[\u4e00-\u9fff]/.test(appPath)) {
|
||||
const tempDir = os.tmpdir()
|
||||
const randomName = crypto.randomBytes(8).toString('hex')
|
||||
const fileExt = path.extname(appPath)
|
||||
tempLinkPath = path.join(tempDir, `${randomName}${fileExt}`)
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
exec(`mklink "${tempLinkPath}" "${appPath}"`, (error) => {
|
||||
if (!error && tempLinkPath && fs.existsSync(tempLinkPath)) {
|
||||
targetPath = tempLinkPath
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
} catch {
|
||||
// ignore mklink errors
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const iconBuffer = await new Promise<Buffer>((resolve, reject) => {
|
||||
getIcon(targetPath, (b64d) => {
|
||||
try {
|
||||
resolve(Buffer.from(b64d, 'base64'))
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return `data:image/png;base64,${iconBuffer.toString('base64')}`
|
||||
} finally {
|
||||
if (tempLinkPath && fs.existsSync(tempLinkPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempLinkPath)
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return windowsDefaultIcon
|
||||
}
|
||||
} else {
|
||||
return windowsDefaultIcon
|
||||
}
|
||||
} else if (process.platform === 'linux') {
|
||||
const desktopFile = await findDesktopFile(appPath)
|
||||
if (desktopFile) {
|
||||
const content = fs.readFileSync(desktopFile, 'utf-8')
|
||||
const iconName = parseIconNameFromDesktopFile(content)
|
||||
if (iconName) {
|
||||
const iconPath = resolveIconPath(iconName)
|
||||
if (iconPath) {
|
||||
try {
|
||||
const iconBuffer = fs.readFileSync(iconPath)
|
||||
return `data:image/png;base64,${iconBuffer.toString('base64')}`
|
||||
} catch {
|
||||
return darwinDefaultIcon
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return darwinDefaultIcon
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export async function getImageDataURL(url: string): Promise<string> {
|
||||
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
|
||||
const res = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
...(port !== 0 && {
|
||||
proxy: {
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
port
|
||||
}
|
||||
})
|
||||
})
|
||||
const mimeType = res.headers['content-type']
|
||||
const dataURL = `data:${mimeType};base64,${Buffer.from(res.data).toString('base64')}`
|
||||
return dataURL
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
import axios from 'axios'
|
||||
import { getControledMihomoConfig } from '../config'
|
||||
import * as chromeRequest from './chromeRequest'
|
||||
|
||||
export async function getImageDataURL(url: string): Promise<string> {
|
||||
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
|
||||
const res = await axios.get(url, {
|
||||
const res = await chromeRequest.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
proxy: {
|
||||
protocol: 'http',
|
||||
@ -12,6 +12,6 @@ export async function getImageDataURL(url: string): Promise<string> {
|
||||
}
|
||||
})
|
||||
const mimeType = res.headers['content-type']
|
||||
const dataURL = `data:${mimeType};base64,${Buffer.from(res.data).toString('base64')}`
|
||||
const dataURL = `data:${mimeType};base64,${Buffer.from(res.data as Buffer).toString('base64')}`
|
||||
return dataURL
|
||||
}
|
||||
|
||||
@ -1,3 +1,31 @@
|
||||
import { mkdir, writeFile, rm, readdir, cp, stat, rename } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import path from 'path'
|
||||
import { app, dialog } from 'electron'
|
||||
import {
|
||||
startPacServer,
|
||||
startSubStoreBackendServer,
|
||||
startSubStoreFrontendServer
|
||||
} from '../resolve/server'
|
||||
import { triggerSysProxy } from '../sys/sysproxy'
|
||||
import {
|
||||
getAppConfig,
|
||||
getControledMihomoConfig,
|
||||
patchAppConfig,
|
||||
patchControledMihomoConfig
|
||||
} from '../config'
|
||||
import { startSSIDCheck } from '../sys/ssid'
|
||||
import i18next, { resources } from '../../shared/i18n'
|
||||
import { stringify } from './yaml'
|
||||
import {
|
||||
defaultConfig,
|
||||
defaultControledMihomoConfig,
|
||||
defaultOverrideConfig,
|
||||
defaultProfile,
|
||||
defaultProfileConfig
|
||||
} from './template'
|
||||
import {
|
||||
appConfigPath,
|
||||
controledMihomoConfigPath,
|
||||
@ -11,36 +39,26 @@ import {
|
||||
profilePath,
|
||||
profilesDir,
|
||||
resourcesFilesDir,
|
||||
rulesDir,
|
||||
subStoreDir,
|
||||
themesDir
|
||||
} from './dirs'
|
||||
import {
|
||||
defaultConfig,
|
||||
defaultControledMihomoConfig,
|
||||
defaultOverrideConfig,
|
||||
defaultProfile,
|
||||
defaultProfileConfig
|
||||
} from './template'
|
||||
import yaml from 'yaml'
|
||||
import { mkdir, writeFile, rm, readdir, cp, stat } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import path from 'path'
|
||||
import {
|
||||
startPacServer,
|
||||
startSubStoreBackendServer,
|
||||
startSubStoreFrontendServer
|
||||
} from '../resolve/server'
|
||||
import { triggerSysProxy } from '../sys/sysproxy'
|
||||
import {
|
||||
getAppConfig,
|
||||
getControledMihomoConfig,
|
||||
patchAppConfig,
|
||||
patchControledMihomoConfig
|
||||
} from '../config'
|
||||
import { app } from 'electron'
|
||||
import { startSSIDCheck } from '../sys/ssid'
|
||||
import { initLogger } from './logger'
|
||||
|
||||
let isInitBasicCompleted = false
|
||||
|
||||
export function safeShowErrorBox(titleKey: string, message: string): void {
|
||||
let title: string
|
||||
try {
|
||||
title = i18next.t(titleKey)
|
||||
if (!title || title === titleKey) throw new Error('Translation not ready')
|
||||
} catch {
|
||||
const isZh = app.getLocale().startsWith('zh')
|
||||
const lang = isZh ? resources['zh-CN'].translation : resources['en-US'].translation
|
||||
title = lang[titleKey] || (isZh ? '错误' : 'Error')
|
||||
}
|
||||
dialog.showErrorBox(title, message)
|
||||
}
|
||||
|
||||
async function fixDataDirPermissions(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return
|
||||
@ -65,190 +83,284 @@ async function fixDataDirPermissions(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function initDirs(): Promise<void> {
|
||||
await fixDataDirPermissions()
|
||||
|
||||
if (!existsSync(dataDir())) {
|
||||
await mkdir(dataDir())
|
||||
}
|
||||
if (!existsSync(themesDir())) {
|
||||
await mkdir(themesDir())
|
||||
}
|
||||
if (!existsSync(profilesDir())) {
|
||||
await mkdir(profilesDir())
|
||||
}
|
||||
if (!existsSync(overrideDir())) {
|
||||
await mkdir(overrideDir())
|
||||
}
|
||||
if (!existsSync(mihomoWorkDir())) {
|
||||
await mkdir(mihomoWorkDir())
|
||||
}
|
||||
if (!existsSync(logDir())) {
|
||||
await mkdir(logDir())
|
||||
}
|
||||
if (!existsSync(mihomoTestDir())) {
|
||||
await mkdir(mihomoTestDir())
|
||||
}
|
||||
if (!existsSync(subStoreDir())) {
|
||||
await mkdir(subStoreDir())
|
||||
async function isSourceNewer(sourcePath: string, targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
const [sourceStats, targetStats] = await Promise.all([stat(sourcePath), stat(targetPath)])
|
||||
return sourceStats.mtime > targetStats.mtime
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function initDirs(): Promise<void> {
|
||||
await fixDataDirPermissions()
|
||||
|
||||
const dirsToCreate = [
|
||||
dataDir(),
|
||||
themesDir(),
|
||||
profilesDir(),
|
||||
overrideDir(),
|
||||
rulesDir(),
|
||||
mihomoWorkDir(),
|
||||
logDir(),
|
||||
mihomoTestDir(),
|
||||
subStoreDir()
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
dirsToCreate.map(async (dir) => {
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true })
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function initConfig(): Promise<void> {
|
||||
if (!existsSync(appConfigPath())) {
|
||||
await writeFile(appConfigPath(), yaml.stringify(defaultConfig))
|
||||
}
|
||||
if (!existsSync(profileConfigPath())) {
|
||||
await writeFile(profileConfigPath(), yaml.stringify(defaultProfileConfig))
|
||||
}
|
||||
if (!existsSync(overrideConfigPath())) {
|
||||
await writeFile(overrideConfigPath(), yaml.stringify(defaultOverrideConfig))
|
||||
}
|
||||
if (!existsSync(profilePath('default'))) {
|
||||
await writeFile(profilePath('default'), yaml.stringify(defaultProfile))
|
||||
}
|
||||
if (!existsSync(controledMihomoConfigPath())) {
|
||||
await writeFile(controledMihomoConfigPath(), yaml.stringify(defaultControledMihomoConfig))
|
||||
const configs = [
|
||||
{ path: appConfigPath(), content: defaultConfig, name: 'app config' },
|
||||
{ path: profileConfigPath(), content: defaultProfileConfig, name: 'profile config' },
|
||||
{ path: overrideConfigPath(), content: defaultOverrideConfig, name: 'override config' },
|
||||
{ path: profilePath('default'), content: defaultProfile, name: 'default profile' },
|
||||
{
|
||||
path: controledMihomoConfigPath(),
|
||||
content: defaultControledMihomoConfig,
|
||||
name: 'mihomo config'
|
||||
}
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
configs.map(async (config) => {
|
||||
if (!existsSync(config.path)) {
|
||||
await writeFile(config.path, stringify(config.content))
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function killOldMihomoProcesses(): Promise<void> {
|
||||
if (process.platform !== 'win32') return
|
||||
|
||||
const execPromise = promisify(exec)
|
||||
try {
|
||||
const { stdout } = await execPromise(
|
||||
'powershell -NoProfile -Command "Get-Process | Where-Object {$_.ProcessName -like \'*mihomo*\'} | Select-Object Id | ConvertTo-Json"',
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
|
||||
if (!stdout.trim()) return
|
||||
|
||||
const processes = JSON.parse(stdout)
|
||||
const processArray = Array.isArray(processes) ? processes : [processes]
|
||||
|
||||
for (const proc of processArray) {
|
||||
const pid = proc.Id
|
||||
if (pid && pid !== process.pid) {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM')
|
||||
await initLogger.info(`Terminated old mihomo process ${pid}`)
|
||||
} catch {
|
||||
// 进程可能退出
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
async function initFiles(): Promise<void> {
|
||||
const copy = async (file: string): Promise<void> => {
|
||||
const targetPath = path.join(mihomoWorkDir(), file)
|
||||
const testTargetPath = path.join(mihomoTestDir(), file)
|
||||
await killOldMihomoProcesses()
|
||||
|
||||
const copyFile = async (file: string): Promise<void> => {
|
||||
const sourcePath = path.join(resourcesFilesDir(), file)
|
||||
if (!existsSync(targetPath) && existsSync(sourcePath)) {
|
||||
await cp(sourcePath, targetPath, { recursive: true })
|
||||
}
|
||||
if (!existsSync(testTargetPath) && existsSync(sourcePath)) {
|
||||
await cp(sourcePath, testTargetPath, { recursive: true })
|
||||
if (!existsSync(sourcePath)) return
|
||||
|
||||
const targets = [path.join(mihomoWorkDir(), file), path.join(mihomoTestDir(), file)]
|
||||
|
||||
await Promise.all(
|
||||
targets.map(async (targetPath) => {
|
||||
const shouldCopy = !existsSync(targetPath) || (await isSourceNewer(sourcePath, targetPath))
|
||||
if (!shouldCopy) return
|
||||
|
||||
try {
|
||||
await cp(sourcePath, targetPath, { recursive: true, force: true })
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
// 文件被占用或权限问题,如果目标已存在则跳过
|
||||
if (
|
||||
(code === 'EPERM' || code === 'EBUSY' || code === 'EACCES') &&
|
||||
existsSync(targetPath)
|
||||
) {
|
||||
await initLogger.warn(`Skipping ${file}: file is in use or permission denied`)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const files = [
|
||||
'country.mmdb',
|
||||
'geoip.metadb',
|
||||
'geoip.dat',
|
||||
'geosite.dat',
|
||||
'ASN.mmdb',
|
||||
'sub-store.bundle.cjs',
|
||||
'sub-store-frontend'
|
||||
]
|
||||
|
||||
const criticalFiles = ['country.mmdb', 'geoip.dat', 'geosite.dat']
|
||||
|
||||
const results = await Promise.allSettled(files.map(copyFile))
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i]
|
||||
if (result.status === 'rejected') {
|
||||
const file = files[i]
|
||||
await initLogger.error(`Failed to copy ${file}`, result.reason)
|
||||
if (criticalFiles.includes(file)) {
|
||||
throw new Error(`Failed to copy critical file ${file}: ${result.reason}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
copy('country.mmdb'),
|
||||
copy('geoip.metadb'),
|
||||
copy('geoip.dat'),
|
||||
copy('geosite.dat'),
|
||||
copy('ASN.mmdb'),
|
||||
copy('sub-store.bundle.js'),
|
||||
copy('sub-store-frontend')
|
||||
])
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
// update cache
|
||||
const files = await readdir(dataDir())
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.exe') || file.endsWith('.pkg') || file.endsWith('.7z')) {
|
||||
try {
|
||||
await rm(path.join(dataDir(), file))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const [dataFiles, logFiles] = await Promise.all([readdir(dataDir()), readdir(logDir())])
|
||||
|
||||
// 清理更新缓存
|
||||
const cacheExtensions = ['.exe', '.pkg', '.7z']
|
||||
const cacheCleanup = dataFiles
|
||||
.filter((file) => cacheExtensions.some((ext) => file.endsWith(ext)))
|
||||
.map((file) => rm(path.join(dataDir(), file)).catch(() => {}))
|
||||
|
||||
// 清理过期日志
|
||||
const { maxLogDays = 7 } = await getAppConfig()
|
||||
const maxAge = maxLogDays * 24 * 60 * 60 * 1000
|
||||
const datePattern = /\d{4}-\d{2}-\d{2}/
|
||||
|
||||
const logCleanup = logFiles
|
||||
.filter((log) => {
|
||||
const match = log.match(datePattern)
|
||||
if (!match) return false
|
||||
const date = new Date(match[0])
|
||||
return !isNaN(date.getTime()) && Date.now() - date.getTime() > maxAge
|
||||
})
|
||||
.map((log) => rm(path.join(logDir(), log)).catch(() => {}))
|
||||
|
||||
await Promise.all([...cacheCleanup, ...logCleanup])
|
||||
}
|
||||
|
||||
async function migrateSubStoreFiles(): Promise<void> {
|
||||
const oldJsPath = path.join(mihomoWorkDir(), 'sub-store.bundle.js')
|
||||
const newCjsPath = path.join(mihomoWorkDir(), 'sub-store.bundle.cjs')
|
||||
|
||||
if (existsSync(oldJsPath) && !existsSync(newCjsPath)) {
|
||||
try {
|
||||
await rename(oldJsPath, newCjsPath)
|
||||
} catch (error) {
|
||||
await initLogger.error('Failed to rename sub-store.bundle.js to sub-store.bundle.cjs', error)
|
||||
}
|
||||
}
|
||||
// logs
|
||||
const { maxLogDays = 7 } = await getAppConfig()
|
||||
const logs = await readdir(logDir())
|
||||
for (const log of logs) {
|
||||
const date = new Date(log.split('.')[0])
|
||||
const diff = Date.now() - date.getTime()
|
||||
if (diff > maxLogDays * 24 * 60 * 60 * 1000) {
|
||||
try {
|
||||
await rm(path.join(logDir(), log))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移:添加 substore 到侧边栏
|
||||
async function migrateSiderOrder(): Promise<void> {
|
||||
const { siderOrder = [], useSubStore = true } = await getAppConfig()
|
||||
if (useSubStore && !siderOrder.includes('substore')) {
|
||||
await patchAppConfig({ siderOrder: [...siderOrder, 'substore'] })
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移:修复 appTheme
|
||||
async function migrateAppTheme(): Promise<void> {
|
||||
const { appTheme = 'system' } = await getAppConfig()
|
||||
if (!['system', 'light', 'dark'].includes(appTheme)) {
|
||||
await patchAppConfig({ appTheme: 'system' })
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移:envType 字符串转数组
|
||||
async function migrateEnvType(): Promise<void> {
|
||||
const { envType } = await getAppConfig()
|
||||
if (typeof envType === 'string') {
|
||||
await patchAppConfig({ envType: [envType] })
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移:禁用托盘时必须显示悬浮窗
|
||||
async function migrateTraySettings(): Promise<void> {
|
||||
const { showFloatingWindow = false, disableTray = false } = await getAppConfig()
|
||||
if (!showFloatingWindow && disableTray) {
|
||||
await patchAppConfig({ disableTray: false })
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移:移除加密密码
|
||||
async function migrateRemovePassword(): Promise<void> {
|
||||
const { encryptedPassword } = await getAppConfig()
|
||||
if (encryptedPassword) {
|
||||
await patchAppConfig({ encryptedPassword: undefined })
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移:mihomo 配置默认值
|
||||
async function migrateMihomoConfig(): Promise<void> {
|
||||
const config = await getControledMihomoConfig()
|
||||
const patches: Partial<IMihomoConfig> = {}
|
||||
|
||||
// skip-auth-prefixes
|
||||
if (!config['skip-auth-prefixes']) {
|
||||
patches['skip-auth-prefixes'] = ['127.0.0.1/32', '::1/128']
|
||||
} else if (
|
||||
config['skip-auth-prefixes'].length >= 1 &&
|
||||
config['skip-auth-prefixes'][0] === '127.0.0.1/32' &&
|
||||
!config['skip-auth-prefixes'].includes('::1/128')
|
||||
) {
|
||||
patches['skip-auth-prefixes'] = [
|
||||
'127.0.0.1/32',
|
||||
'::1/128',
|
||||
...config['skip-auth-prefixes'].slice(1)
|
||||
]
|
||||
}
|
||||
|
||||
// 其他默认值
|
||||
if (!config.authentication) patches.authentication = []
|
||||
if (!config['bind-address']) patches['bind-address'] = '*'
|
||||
if (!config['lan-allowed-ips']) patches['lan-allowed-ips'] = ['0.0.0.0/0', '::/0']
|
||||
if (!config['lan-disallowed-ips']) patches['lan-disallowed-ips'] = []
|
||||
|
||||
// tun device
|
||||
if (!config.tun?.device || (process.platform === 'darwin' && config.tun.device === 'Mihomo')) {
|
||||
patches.tun = {
|
||||
...config.tun,
|
||||
device: process.platform === 'darwin' ? 'utun1500' : 'Mihomo'
|
||||
}
|
||||
}
|
||||
|
||||
// 移除废弃配置
|
||||
if (config['external-controller-unix']) patches['external-controller-unix'] = undefined
|
||||
if (config['external-controller-pipe']) patches['external-controller-pipe'] = undefined
|
||||
if (config['external-controller'] === undefined) patches['external-controller'] = ''
|
||||
|
||||
if (Object.keys(patches).length > 0) {
|
||||
await patchControledMihomoConfig(patches)
|
||||
}
|
||||
}
|
||||
|
||||
async function migration(): Promise<void> {
|
||||
const {
|
||||
siderOrder = [
|
||||
'sysproxy',
|
||||
'tun',
|
||||
'profile',
|
||||
'proxy',
|
||||
'rule',
|
||||
'resource',
|
||||
'override',
|
||||
'connection',
|
||||
'mihomo',
|
||||
'dns',
|
||||
'sniff',
|
||||
'log',
|
||||
'substore'
|
||||
],
|
||||
appTheme = 'system',
|
||||
envType = [process.platform === 'win32' ? 'powershell' : 'bash'],
|
||||
useSubStore = true,
|
||||
showFloatingWindow = false,
|
||||
disableTray = false,
|
||||
encryptedPassword
|
||||
} = await getAppConfig()
|
||||
const {
|
||||
'external-controller-pipe': externalControllerPipe,
|
||||
'external-controller-unix': externalControllerUnix,
|
||||
'external-controller': externalController,
|
||||
'skip-auth-prefixes': skipAuthPrefixes,
|
||||
authentication,
|
||||
'bind-address': bindAddress,
|
||||
'lan-allowed-ips': lanAllowedIps,
|
||||
'lan-disallowed-ips': lanDisallowedIps
|
||||
} = await getControledMihomoConfig()
|
||||
// add substore sider card
|
||||
if (useSubStore && !siderOrder.includes('substore')) {
|
||||
await patchAppConfig({ siderOrder: [...siderOrder, 'substore'] })
|
||||
}
|
||||
// add default skip auth prefix
|
||||
if (!skipAuthPrefixes) {
|
||||
await patchControledMihomoConfig({ 'skip-auth-prefixes': ['127.0.0.1/32'] })
|
||||
}
|
||||
// add default authentication
|
||||
if (!authentication) {
|
||||
await patchControledMihomoConfig({ authentication: [] })
|
||||
}
|
||||
// add default bind address
|
||||
if (!bindAddress) {
|
||||
await patchControledMihomoConfig({ 'bind-address': '*' })
|
||||
}
|
||||
// add default lan allowed ips
|
||||
if (!lanAllowedIps) {
|
||||
await patchControledMihomoConfig({ 'lan-allowed-ips': ['0.0.0.0/0', '::/0'] })
|
||||
}
|
||||
// add default lan disallowed ips
|
||||
if (!lanDisallowedIps) {
|
||||
await patchControledMihomoConfig({ 'lan-disallowed-ips': [] })
|
||||
}
|
||||
// remove custom app theme
|
||||
if (!['system', 'light', 'dark'].includes(appTheme)) {
|
||||
await patchAppConfig({ appTheme: 'system' })
|
||||
}
|
||||
// change env type
|
||||
if (typeof envType === 'string') {
|
||||
await patchAppConfig({ envType: [envType] })
|
||||
}
|
||||
// use unix socket
|
||||
if (externalControllerUnix) {
|
||||
await patchControledMihomoConfig({ 'external-controller-unix': undefined })
|
||||
}
|
||||
// use named pipe
|
||||
if (externalControllerPipe) {
|
||||
await patchControledMihomoConfig({
|
||||
'external-controller-pipe': undefined
|
||||
})
|
||||
}
|
||||
if (externalController === undefined) {
|
||||
await patchControledMihomoConfig({ 'external-controller': '' })
|
||||
}
|
||||
if (!showFloatingWindow && disableTray) {
|
||||
await patchAppConfig({ disableTray: false })
|
||||
}
|
||||
// remove password
|
||||
if (encryptedPassword) {
|
||||
await patchAppConfig({ encryptedPassword: undefined })
|
||||
}
|
||||
await Promise.all([
|
||||
migrateSiderOrder(),
|
||||
migrateAppTheme(),
|
||||
migrateEnvType(),
|
||||
migrateTraySettings(),
|
||||
migrateRemovePassword(),
|
||||
migrateMihomoConfig()
|
||||
])
|
||||
}
|
||||
|
||||
function initDeeplink(): void {
|
||||
@ -263,24 +375,41 @@ function initDeeplink(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
export async function initBasic(): Promise<void> {
|
||||
if (isInitBasicCompleted) return
|
||||
|
||||
await initDirs()
|
||||
await initConfig()
|
||||
await migration()
|
||||
await migrateSubStoreFiles()
|
||||
await initFiles()
|
||||
await cleanup()
|
||||
await startSubStoreFrontendServer()
|
||||
await startSubStoreBackendServer()
|
||||
const { sysProxy } = await getAppConfig()
|
||||
try {
|
||||
if (sysProxy.enable) {
|
||||
await startPacServer()
|
||||
}
|
||||
await triggerSysProxy(sysProxy.enable)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await startSSIDCheck()
|
||||
|
||||
isInitBasicCompleted = true
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
const { sysProxy } = await getAppConfig()
|
||||
|
||||
const initTasks: Promise<void>[] = [
|
||||
startSubStoreFrontendServer(),
|
||||
startSubStoreBackendServer(),
|
||||
startSSIDCheck()
|
||||
]
|
||||
|
||||
initTasks.push(
|
||||
(async (): Promise<void> => {
|
||||
try {
|
||||
if (sysProxy.enable) {
|
||||
await startPacServer()
|
||||
}
|
||||
await triggerSysProxy(sysProxy.enable)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})()
|
||||
)
|
||||
|
||||
await Promise.all(initTasks)
|
||||
initDeeplink()
|
||||
}
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { app, dialog, ipcMain } from 'electron'
|
||||
import path from 'path'
|
||||
import v8 from 'v8'
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import i18next from 'i18next'
|
||||
import {
|
||||
mihomoChangeProxy,
|
||||
mihomoCloseAllConnections,
|
||||
@ -15,8 +19,13 @@ import {
|
||||
mihomoUpdateRuleProviders,
|
||||
mihomoUpgrade,
|
||||
mihomoUpgradeGeo,
|
||||
mihomoUpgradeUI,
|
||||
mihomoUpgradeConfig,
|
||||
mihomoVersion,
|
||||
patchMihomoConfig
|
||||
patchMihomoConfig,
|
||||
mihomoSmartGroupWeights,
|
||||
mihomoSmartFlushCache,
|
||||
mihomoRulesDisable
|
||||
} from '../core/mihomoApi'
|
||||
import { checkAutoRun, disableAutoRun, enableAutoRun } from '../sys/autoRun'
|
||||
import {
|
||||
@ -43,7 +52,8 @@ import {
|
||||
removeOverrideItem,
|
||||
getOverride,
|
||||
setOverride,
|
||||
updateOverrideItem
|
||||
updateOverrideItem,
|
||||
convertMrsRuleset
|
||||
} from '../config'
|
||||
import {
|
||||
startSubStoreFrontendServer,
|
||||
@ -54,7 +64,20 @@ import {
|
||||
subStoreFrontendPort,
|
||||
subStorePort
|
||||
} from '../resolve/server'
|
||||
import { manualGrantCorePermition, quitWithoutCore, restartCore } from '../core/manager'
|
||||
import {
|
||||
quitWithoutCore,
|
||||
restartCore,
|
||||
checkTunPermissions,
|
||||
grantTunPermissions,
|
||||
manualGrantCorePermition,
|
||||
checkAdminPrivileges,
|
||||
restartAsAdmin,
|
||||
checkMihomoCorePermissions,
|
||||
requestTunPermissions,
|
||||
checkHighPrivilegeCore,
|
||||
showTunPermissionDialog,
|
||||
showErrorDialog
|
||||
} from '../core/manager'
|
||||
import { triggerSysProxy } from '../sys/sysproxy'
|
||||
import { checkUpdate, downloadAndInstallUpdate } from '../resolve/autoUpdater'
|
||||
import {
|
||||
@ -67,11 +90,25 @@ import {
|
||||
setupFirewall
|
||||
} from '../sys/misc'
|
||||
import { getRuntimeConfig, getRuntimeConfigStr } from '../core/factory'
|
||||
import { listWebdavBackups, webdavBackup, webdavDelete, webdavRestore } from '../resolve/backup'
|
||||
import {
|
||||
listWebdavBackups,
|
||||
webdavBackup,
|
||||
webdavDelete,
|
||||
webdavRestore,
|
||||
exportLocalBackup,
|
||||
importLocalBackup,
|
||||
reinitScheduler
|
||||
} from '../resolve/backup'
|
||||
import { getInterfaces } from '../sys/interface'
|
||||
import { closeTrayIcon, copyEnv, showTrayIcon } from '../resolve/tray'
|
||||
import {
|
||||
closeTrayIcon,
|
||||
copyEnv,
|
||||
showTrayIcon,
|
||||
updateTrayIcon,
|
||||
updateTrayIconImmediate
|
||||
} from '../resolve/tray'
|
||||
import { registerShortcut } from '../resolve/shortcut'
|
||||
import { closeMainWindow, mainWindow, showMainWindow, triggerMainWindow } from '..'
|
||||
import { closeMainWindow, mainWindow, showMainWindow, triggerMainWindow } from '../window'
|
||||
import {
|
||||
applyTheme,
|
||||
fetchThemes,
|
||||
@ -81,188 +118,264 @@ import {
|
||||
writeTheme
|
||||
} from '../resolve/theme'
|
||||
import { subStoreCollections, subStoreSubs } from '../core/subStoreApi'
|
||||
import { logDir } from './dirs'
|
||||
import path from 'path'
|
||||
import v8 from 'v8'
|
||||
import { getGistUrl } from '../resolve/gistApi'
|
||||
import { getImageDataURL } from './image'
|
||||
import { startMonitor } from '../resolve/trafficMonitor'
|
||||
import { closeFloatingWindow, showContextMenu, showFloatingWindow } from '../resolve/floatingWindow'
|
||||
import i18next from 'i18next'
|
||||
import { addProfileUpdater } from '../core/profileUpdater'
|
||||
import { addProfileUpdater, removeProfileUpdater } from '../core/profileUpdater'
|
||||
import { getImageDataURL } from './image'
|
||||
import { get as httpGet } from './chromeRequest'
|
||||
import { getIconDataURL } from './icon'
|
||||
import { getAppName } from './appName'
|
||||
import { logDir, rulePath } from './dirs'
|
||||
import { installMihomoCore, getGitHubTags, clearVersionCache } from './github'
|
||||
|
||||
function ipcErrorWrapper<T>( // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fn: (...args: any[]) => Promise<T> // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
): (...args: any[]) => Promise<T | { invokeError: unknown }> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return async (...args: any[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AsyncFn = (...args: any[]) => Promise<any>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type SyncFn = (...args: any[]) => any
|
||||
|
||||
function wrapAsync<T extends AsyncFn>(
|
||||
fn: T
|
||||
): (...args: Parameters<T>) => Promise<ReturnType<T> | { invokeError: unknown }> {
|
||||
return async (...args) => {
|
||||
try {
|
||||
return await fn(...args)
|
||||
} catch (e) {
|
||||
if (e && typeof e === 'object') {
|
||||
if ('message' in e) {
|
||||
return { invokeError: e.message }
|
||||
} else {
|
||||
return { invokeError: JSON.stringify(e) }
|
||||
}
|
||||
if (e && typeof e === 'object' && 'message' in e) {
|
||||
return { invokeError: e.message }
|
||||
}
|
||||
if (e instanceof Error || typeof e === 'string') {
|
||||
return { invokeError: e }
|
||||
}
|
||||
return { invokeError: 'Unknown Error' }
|
||||
return { invokeError: typeof e === 'string' ? e : 'Unknown Error' }
|
||||
}
|
||||
}
|
||||
}
|
||||
export function registerIpcMainHandlers(): void {
|
||||
ipcMain.handle('mihomoVersion', ipcErrorWrapper(mihomoVersion))
|
||||
ipcMain.handle('mihomoCloseConnection', (_e, id) => ipcErrorWrapper(mihomoCloseConnection)(id))
|
||||
ipcMain.handle('mihomoCloseAllConnections', ipcErrorWrapper(mihomoCloseAllConnections))
|
||||
ipcMain.handle('mihomoRules', ipcErrorWrapper(mihomoRules))
|
||||
ipcMain.handle('mihomoProxies', ipcErrorWrapper(mihomoProxies))
|
||||
ipcMain.handle('mihomoGroups', ipcErrorWrapper(mihomoGroups))
|
||||
ipcMain.handle('mihomoProxyProviders', ipcErrorWrapper(mihomoProxyProviders))
|
||||
ipcMain.handle('mihomoUpdateProxyProviders', (_e, name) =>
|
||||
ipcErrorWrapper(mihomoUpdateProxyProviders)(name)
|
||||
)
|
||||
ipcMain.handle('mihomoRuleProviders', ipcErrorWrapper(mihomoRuleProviders))
|
||||
ipcMain.handle('mihomoUpdateRuleProviders', (_e, name) =>
|
||||
ipcErrorWrapper(mihomoUpdateRuleProviders)(name)
|
||||
)
|
||||
ipcMain.handle('mihomoChangeProxy', (_e, group, proxy) =>
|
||||
ipcErrorWrapper(mihomoChangeProxy)(group, proxy)
|
||||
)
|
||||
ipcMain.handle('mihomoUnfixedProxy', (_e, group) => ipcErrorWrapper(mihomoUnfixedProxy)(group))
|
||||
ipcMain.handle('mihomoUpgradeGeo', ipcErrorWrapper(mihomoUpgradeGeo))
|
||||
ipcMain.handle('mihomoUpgrade', ipcErrorWrapper(mihomoUpgrade))
|
||||
ipcMain.handle('mihomoProxyDelay', (_e, proxy, url) =>
|
||||
ipcErrorWrapper(mihomoProxyDelay)(proxy, url)
|
||||
)
|
||||
ipcMain.handle('mihomoGroupDelay', (_e, group, url) =>
|
||||
ipcErrorWrapper(mihomoGroupDelay)(group, url)
|
||||
)
|
||||
ipcMain.handle('patchMihomoConfig', (_e, patch) => ipcErrorWrapper(patchMihomoConfig)(patch))
|
||||
ipcMain.handle('checkAutoRun', ipcErrorWrapper(checkAutoRun))
|
||||
ipcMain.handle('enableAutoRun', ipcErrorWrapper(enableAutoRun))
|
||||
ipcMain.handle('disableAutoRun', ipcErrorWrapper(disableAutoRun))
|
||||
ipcMain.handle('getAppConfig', (_e, force) => ipcErrorWrapper(getAppConfig)(force))
|
||||
ipcMain.handle('patchAppConfig', (_e, config) => ipcErrorWrapper(patchAppConfig)(config))
|
||||
ipcMain.handle('getControledMihomoConfig', (_e, force) =>
|
||||
ipcErrorWrapper(getControledMihomoConfig)(force)
|
||||
)
|
||||
ipcMain.handle('patchControledMihomoConfig', (_e, config) =>
|
||||
ipcErrorWrapper(patchControledMihomoConfig)(config)
|
||||
)
|
||||
ipcMain.handle('getProfileConfig', (_e, force) => ipcErrorWrapper(getProfileConfig)(force))
|
||||
ipcMain.handle('setProfileConfig', (_e, config) => ipcErrorWrapper(setProfileConfig)(config))
|
||||
ipcMain.handle('getCurrentProfileItem', ipcErrorWrapper(getCurrentProfileItem))
|
||||
ipcMain.handle('getProfileItem', (_e, id) => ipcErrorWrapper(getProfileItem)(id))
|
||||
ipcMain.handle('getProfileStr', (_e, id) => ipcErrorWrapper(getProfileStr)(id))
|
||||
ipcMain.handle('getFileStr', (_e, path) => ipcErrorWrapper(getFileStr)(path))
|
||||
ipcMain.handle('setFileStr', (_e, path, str) => ipcErrorWrapper(setFileStr)(path, str))
|
||||
ipcMain.handle('setProfileStr', (_e, id, str) => ipcErrorWrapper(setProfileStr)(id, str))
|
||||
ipcMain.handle('updateProfileItem', (_e, item) => ipcErrorWrapper(updateProfileItem)(item))
|
||||
ipcMain.handle('changeCurrentProfile', (_e, id) => ipcErrorWrapper(changeCurrentProfile)(id))
|
||||
ipcMain.handle('addProfileItem', (_e, item) => ipcErrorWrapper(addProfileItem)(item))
|
||||
ipcMain.handle('removeProfileItem', (_e, id) => ipcErrorWrapper(removeProfileItem)(id))
|
||||
ipcMain.handle('addProfileUpdater', (_e, item) => ipcErrorWrapper(addProfileUpdater)(item))
|
||||
ipcMain.handle('getOverrideConfig', (_e, force) => ipcErrorWrapper(getOverrideConfig)(force))
|
||||
ipcMain.handle('setOverrideConfig', (_e, config) => ipcErrorWrapper(setOverrideConfig)(config))
|
||||
ipcMain.handle('getOverrideItem', (_e, id) => ipcErrorWrapper(getOverrideItem)(id))
|
||||
ipcMain.handle('addOverrideItem', (_e, item) => ipcErrorWrapper(addOverrideItem)(item))
|
||||
ipcMain.handle('removeOverrideItem', (_e, id) => ipcErrorWrapper(removeOverrideItem)(id))
|
||||
ipcMain.handle('updateOverrideItem', (_e, item) => ipcErrorWrapper(updateOverrideItem)(item))
|
||||
ipcMain.handle('getOverride', (_e, id, ext) => ipcErrorWrapper(getOverride)(id, ext))
|
||||
ipcMain.handle('setOverride', (_e, id, ext, str) => ipcErrorWrapper(setOverride)(id, ext, str))
|
||||
ipcMain.handle('restartCore', ipcErrorWrapper(restartCore))
|
||||
ipcMain.handle('startMonitor', (_e, detached) => ipcErrorWrapper(startMonitor)(detached))
|
||||
ipcMain.handle('triggerSysProxy', (_e, enable) => ipcErrorWrapper(triggerSysProxy)(enable))
|
||||
ipcMain.handle('manualGrantCorePermition', () => ipcErrorWrapper(manualGrantCorePermition)())
|
||||
ipcMain.handle('getFilePath', (_e, ext) => getFilePath(ext))
|
||||
ipcMain.handle('readTextFile', (_e, filePath) => ipcErrorWrapper(readTextFile)(filePath))
|
||||
ipcMain.handle('getRuntimeConfigStr', ipcErrorWrapper(getRuntimeConfigStr))
|
||||
ipcMain.handle('getRuntimeConfig', ipcErrorWrapper(getRuntimeConfig))
|
||||
ipcMain.handle('downloadAndInstallUpdate', (_e, version) =>
|
||||
ipcErrorWrapper(downloadAndInstallUpdate)(version)
|
||||
)
|
||||
ipcMain.handle('checkUpdate', ipcErrorWrapper(checkUpdate))
|
||||
ipcMain.handle('getVersion', () => app.getVersion())
|
||||
ipcMain.handle('platform', () => process.platform)
|
||||
ipcMain.handle('openUWPTool', ipcErrorWrapper(openUWPTool))
|
||||
ipcMain.handle('setupFirewall', ipcErrorWrapper(setupFirewall))
|
||||
ipcMain.handle('getInterfaces', getInterfaces)
|
||||
ipcMain.handle('webdavBackup', ipcErrorWrapper(webdavBackup))
|
||||
ipcMain.handle('webdavRestore', (_e, filename) => ipcErrorWrapper(webdavRestore)(filename))
|
||||
ipcMain.handle('listWebdavBackups', ipcErrorWrapper(listWebdavBackups))
|
||||
ipcMain.handle('webdavDelete', (_e, filename) => ipcErrorWrapper(webdavDelete)(filename))
|
||||
ipcMain.handle('registerShortcut', (_e, oldShortcut, newShortcut, action) =>
|
||||
ipcErrorWrapper(registerShortcut)(oldShortcut, newShortcut, action)
|
||||
)
|
||||
ipcMain.handle('startSubStoreFrontendServer', () =>
|
||||
ipcErrorWrapper(startSubStoreFrontendServer)()
|
||||
)
|
||||
ipcMain.handle('stopSubStoreFrontendServer', () => ipcErrorWrapper(stopSubStoreFrontendServer)())
|
||||
ipcMain.handle('startSubStoreBackendServer', () => ipcErrorWrapper(startSubStoreBackendServer)())
|
||||
ipcMain.handle('stopSubStoreBackendServer', () => ipcErrorWrapper(stopSubStoreBackendServer)())
|
||||
ipcMain.handle('downloadSubStore', () => ipcErrorWrapper(downloadSubStore)())
|
||||
ipcMain.handle('subStorePort', () => subStorePort)
|
||||
ipcMain.handle('subStoreFrontendPort', () => subStoreFrontendPort)
|
||||
ipcMain.handle('subStoreSubs', () => ipcErrorWrapper(subStoreSubs)())
|
||||
ipcMain.handle('subStoreCollections', () => ipcErrorWrapper(subStoreCollections)())
|
||||
ipcMain.handle('getGistUrl', ipcErrorWrapper(getGistUrl))
|
||||
ipcMain.handle('setNativeTheme', (_e, theme) => {
|
||||
setNativeTheme(theme)
|
||||
})
|
||||
ipcMain.handle('setTitleBarOverlay', (_e, overlay) =>
|
||||
ipcErrorWrapper(async (overlay): Promise<void> => {
|
||||
if (mainWindow && typeof mainWindow.setTitleBarOverlay === 'function') {
|
||||
mainWindow.setTitleBarOverlay(overlay)
|
||||
}
|
||||
})(overlay)
|
||||
)
|
||||
ipcMain.handle('setAlwaysOnTop', (_e, alwaysOnTop) => {
|
||||
mainWindow?.setAlwaysOnTop(alwaysOnTop)
|
||||
})
|
||||
ipcMain.handle('isAlwaysOnTop', () => {
|
||||
return mainWindow?.isAlwaysOnTop()
|
||||
})
|
||||
ipcMain.handle('showTrayIcon', () => ipcErrorWrapper(showTrayIcon)())
|
||||
ipcMain.handle('closeTrayIcon', () => ipcErrorWrapper(closeTrayIcon)())
|
||||
ipcMain.handle('showMainWindow', showMainWindow)
|
||||
ipcMain.handle('closeMainWindow', closeMainWindow)
|
||||
ipcMain.handle('triggerMainWindow', triggerMainWindow)
|
||||
ipcMain.handle('showFloatingWindow', () => ipcErrorWrapper(showFloatingWindow)())
|
||||
ipcMain.handle('closeFloatingWindow', () => ipcErrorWrapper(closeFloatingWindow)())
|
||||
ipcMain.handle('showContextMenu', () => ipcErrorWrapper(showContextMenu)())
|
||||
ipcMain.handle('openFile', (_e, type, id, ext) => openFile(type, id, ext))
|
||||
ipcMain.handle('openDevTools', () => {
|
||||
mainWindow?.webContents.openDevTools()
|
||||
})
|
||||
ipcMain.handle('createHeapSnapshot', () => {
|
||||
v8.writeHeapSnapshot(path.join(logDir(), `${Date.now()}.heapsnapshot`))
|
||||
})
|
||||
ipcMain.handle('getImageDataURL', (_e, url) => ipcErrorWrapper(getImageDataURL)(url))
|
||||
ipcMain.handle('resolveThemes', () => ipcErrorWrapper(resolveThemes)())
|
||||
ipcMain.handle('fetchThemes', () => ipcErrorWrapper(fetchThemes)())
|
||||
ipcMain.handle('importThemes', (_e, file) => ipcErrorWrapper(importThemes)(file))
|
||||
ipcMain.handle('readTheme', (_e, theme) => ipcErrorWrapper(readTheme)(theme))
|
||||
ipcMain.handle('writeTheme', (_e, theme, css) => ipcErrorWrapper(writeTheme)(theme, css))
|
||||
ipcMain.handle('applyTheme', (_e, theme) => ipcErrorWrapper(applyTheme)(theme))
|
||||
ipcMain.handle('copyEnv', (_e, type) => ipcErrorWrapper(copyEnv)(type))
|
||||
ipcMain.handle('alert', (_e, msg) => {
|
||||
dialog.showErrorBox('Mihomo Party', msg)
|
||||
})
|
||||
ipcMain.handle('resetAppConfig', resetAppConfig)
|
||||
ipcMain.handle('relaunchApp', () => {
|
||||
|
||||
function registerHandlers(handlers: Record<string, AsyncFn | SyncFn>, async = true): void {
|
||||
for (const [channel, handler] of Object.entries(handlers)) {
|
||||
if (async) {
|
||||
ipcMain.handle(channel, (_e, ...args) => wrapAsync(handler as AsyncFn)(...args))
|
||||
} else {
|
||||
ipcMain.handle(channel, (_e, ...args) => (handler as SyncFn)(...args))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMihomoTags(
|
||||
forceRefresh = false
|
||||
): Promise<{ name: string; zipball_url: string; tarball_url: string }[]> {
|
||||
return await getGitHubTags('MetaCubeX', 'mihomo', forceRefresh)
|
||||
}
|
||||
|
||||
async function installSpecificMihomoCore(version: string): Promise<void> {
|
||||
clearVersionCache('MetaCubeX', 'mihomo')
|
||||
return await installMihomoCore(version)
|
||||
}
|
||||
|
||||
async function clearMihomoVersionCache(): Promise<void> {
|
||||
clearVersionCache('MetaCubeX', 'mihomo')
|
||||
}
|
||||
|
||||
async function getRuleStr(id: string): Promise<string> {
|
||||
return await readFile(rulePath(id), 'utf-8')
|
||||
}
|
||||
|
||||
async function setRuleStr(id: string, str: string): Promise<void> {
|
||||
await writeFile(rulePath(id), str, 'utf-8')
|
||||
}
|
||||
|
||||
async function getSmartOverrideContent(): Promise<string | null> {
|
||||
try {
|
||||
const override = await getOverrideItem('smart-core-override')
|
||||
return override?.file || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchIPInfo(url: string): Promise<unknown> {
|
||||
const res = await httpGet<unknown>(url, { timeout: 10000, responseType: 'json' })
|
||||
return res.data
|
||||
}
|
||||
|
||||
async function measureLatency(url: string): Promise<number | null> {
|
||||
try {
|
||||
const t0 = Date.now()
|
||||
await httpGet<unknown>(url, { timeout: 5000, responseType: 'text' })
|
||||
return Date.now() - t0
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function changeLanguage(lng: string): Promise<void> {
|
||||
await i18next.changeLanguage(lng)
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
}
|
||||
|
||||
async function setTitleBarOverlay(overlay: Electron.TitleBarOverlayOptions): Promise<void> {
|
||||
if (mainWindow && typeof mainWindow.setTitleBarOverlay === 'function') {
|
||||
mainWindow.setTitleBarOverlay(overlay)
|
||||
}
|
||||
}
|
||||
|
||||
const asyncHandlers: Record<string, AsyncFn> = {
|
||||
// Mihomo API
|
||||
mihomoVersion,
|
||||
mihomoCloseConnection,
|
||||
mihomoCloseAllConnections,
|
||||
mihomoRules,
|
||||
mihomoRulesDisable,
|
||||
mihomoProxies,
|
||||
mihomoGroups,
|
||||
mihomoProxyProviders,
|
||||
mihomoUpdateProxyProviders,
|
||||
mihomoRuleProviders,
|
||||
mihomoUpdateRuleProviders,
|
||||
mihomoChangeProxy,
|
||||
mihomoUnfixedProxy,
|
||||
mihomoUpgradeGeo,
|
||||
mihomoUpgrade,
|
||||
mihomoUpgradeUI,
|
||||
mihomoUpgradeConfig,
|
||||
mihomoProxyDelay,
|
||||
mihomoGroupDelay,
|
||||
patchMihomoConfig,
|
||||
mihomoSmartGroupWeights,
|
||||
mihomoSmartFlushCache,
|
||||
// AutoRun
|
||||
checkAutoRun,
|
||||
enableAutoRun,
|
||||
disableAutoRun,
|
||||
// Config
|
||||
getAppConfig,
|
||||
patchAppConfig,
|
||||
getControledMihomoConfig,
|
||||
patchControledMihomoConfig,
|
||||
// Profile
|
||||
getProfileConfig,
|
||||
setProfileConfig,
|
||||
getCurrentProfileItem,
|
||||
getProfileItem,
|
||||
getProfileStr,
|
||||
setProfileStr,
|
||||
addProfileItem,
|
||||
removeProfileItem,
|
||||
updateProfileItem,
|
||||
changeCurrentProfile,
|
||||
addProfileUpdater,
|
||||
removeProfileUpdater,
|
||||
// Override
|
||||
getOverrideConfig,
|
||||
setOverrideConfig,
|
||||
getOverrideItem,
|
||||
addOverrideItem,
|
||||
removeOverrideItem,
|
||||
updateOverrideItem,
|
||||
getOverride,
|
||||
setOverride,
|
||||
// File
|
||||
getFileStr,
|
||||
setFileStr,
|
||||
convertMrsRuleset,
|
||||
getRuntimeConfig,
|
||||
getRuntimeConfigStr,
|
||||
getSmartOverrideContent,
|
||||
getRuleStr,
|
||||
setRuleStr,
|
||||
readTextFile,
|
||||
// Core
|
||||
restartCore,
|
||||
startMonitor,
|
||||
quitWithoutCore,
|
||||
// System
|
||||
triggerSysProxy,
|
||||
checkTunPermissions,
|
||||
grantTunPermissions,
|
||||
manualGrantCorePermition,
|
||||
checkAdminPrivileges,
|
||||
restartAsAdmin,
|
||||
checkMihomoCorePermissions,
|
||||
requestTunPermissions,
|
||||
checkHighPrivilegeCore,
|
||||
showTunPermissionDialog,
|
||||
showErrorDialog,
|
||||
openUWPTool,
|
||||
setupFirewall,
|
||||
copyEnv,
|
||||
// Update
|
||||
checkUpdate,
|
||||
downloadAndInstallUpdate,
|
||||
fetchMihomoTags,
|
||||
installSpecificMihomoCore,
|
||||
clearMihomoVersionCache,
|
||||
// Backup
|
||||
webdavBackup,
|
||||
webdavRestore,
|
||||
listWebdavBackups,
|
||||
webdavDelete,
|
||||
reinitWebdavBackupScheduler: reinitScheduler,
|
||||
exportLocalBackup,
|
||||
importLocalBackup,
|
||||
// SubStore
|
||||
startSubStoreFrontendServer,
|
||||
stopSubStoreFrontendServer,
|
||||
startSubStoreBackendServer,
|
||||
stopSubStoreBackendServer,
|
||||
downloadSubStore,
|
||||
subStoreSubs,
|
||||
subStoreCollections,
|
||||
// Theme
|
||||
resolveThemes,
|
||||
fetchThemes,
|
||||
importThemes,
|
||||
readTheme,
|
||||
writeTheme,
|
||||
applyTheme,
|
||||
// Tray
|
||||
showTrayIcon,
|
||||
closeTrayIcon,
|
||||
updateTrayIcon,
|
||||
// Floating Window
|
||||
showFloatingWindow,
|
||||
closeFloatingWindow,
|
||||
showContextMenu,
|
||||
// Misc
|
||||
getGistUrl,
|
||||
fetchIPInfo,
|
||||
measureLatency,
|
||||
getImageDataURL,
|
||||
getIconDataURL,
|
||||
getAppName,
|
||||
changeLanguage,
|
||||
setTitleBarOverlay,
|
||||
registerShortcut
|
||||
}
|
||||
|
||||
const syncHandlers: Record<string, SyncFn> = {
|
||||
resetAppConfig,
|
||||
getFilePath,
|
||||
openFile,
|
||||
getInterfaces,
|
||||
setNativeTheme,
|
||||
getVersion: () => app.getVersion(),
|
||||
platform: () => process.platform,
|
||||
subStorePort: () => subStorePort,
|
||||
subStoreFrontendPort: () => subStoreFrontendPort,
|
||||
updateTrayIconImmediate,
|
||||
showMainWindow,
|
||||
closeMainWindow,
|
||||
triggerMainWindow,
|
||||
setAlwaysOnTop: (alwaysOnTop: boolean) => mainWindow?.setAlwaysOnTop(alwaysOnTop),
|
||||
isAlwaysOnTop: () => mainWindow?.isAlwaysOnTop(),
|
||||
openDevTools: () => mainWindow?.webContents.openDevTools(),
|
||||
createHeapSnapshot: () => v8.writeHeapSnapshot(path.join(logDir(), `${Date.now()}.heapsnapshot`)),
|
||||
relaunchApp: () => {
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
})
|
||||
ipcMain.handle('quitWithoutCore', ipcErrorWrapper(quitWithoutCore))
|
||||
ipcMain.handle('quitApp', () => app.quit())
|
||||
|
||||
// Add language change handler
|
||||
ipcMain.handle('changeLanguage', async (_e, lng) => {
|
||||
await i18next.changeLanguage(lng)
|
||||
// 触发托盘菜单更新
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
})
|
||||
},
|
||||
quitApp: () => app.quit()
|
||||
}
|
||||
|
||||
export function registerIpcMainHandlers(): void {
|
||||
registerHandlers(asyncHandlers, true)
|
||||
registerHandlers(syncHandlers, false)
|
||||
}
|
||||
|
||||