mirror of
https://gh.catmak.name/https://github.com/mihomo-party-org/mihomo-party
synced 2025-12-28 05:30:29 +08:00
Compare commits
291 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
364578f210 | ||
|
|
674cefcc29 | ||
|
|
827e744601 | ||
|
|
151726fcce | ||
|
|
6f3845151d | ||
|
|
a9b5887e15 | ||
|
|
727fd48684 | ||
|
|
e2ab88f4e2 | ||
|
|
1a5c001dbd | ||
|
|
6744e14c66 | ||
|
|
62a04cc5ad | ||
|
|
555130001b | ||
|
|
78f9211ebe | ||
|
|
c6d0e05851 | ||
|
|
caf962f921 | ||
|
|
0b8c77d200 | ||
|
|
d6e456302e | ||
|
|
daa8e7ba7e | ||
|
|
27ab6d1b5c | ||
|
|
da5336ee36 | ||
|
|
bdb7fb6489 | ||
|
|
1a1992c617 | ||
|
|
511eb0c7fa | ||
|
|
f54ffcf42b | ||
|
|
5a84d6485e | ||
|
|
afe93774b0 | ||
|
|
00be605e6e | ||
|
|
944475d791 | ||
|
|
08f15c7e01 | ||
|
|
0218f72c5f | ||
|
|
c54ce3577d | ||
|
|
6cb432dea9 | ||
|
|
a6a3afd3bb | ||
|
|
a2faf0fc8f | ||
|
|
b15fc6ce3a | ||
|
|
fcb323a17a | ||
|
|
69e65a3959 | ||
|
|
6ffcf544b8 | ||
|
|
36746074da | ||
|
|
d763e93984 | ||
|
|
6ab920ddbd | ||
|
|
59bd7e8a08 | ||
|
|
f30596231b | ||
|
|
4b238d4dc2 | ||
|
|
81bb2c44e0 | ||
|
|
6cf1ae2c25 | ||
|
|
c906a10562 | ||
|
|
eb12f13525 | ||
|
|
502c089f86 | ||
|
|
83c5b03773 | ||
|
|
55a3bc9cd3 | ||
|
|
95df5e7712 | ||
|
|
928bae7b26 | ||
|
|
356914a5ed | ||
|
|
b102c4c59b | ||
|
|
c60b041192 | ||
|
|
4da4c242ad | ||
|
|
8d88851444 | ||
|
|
d28d33849c | ||
|
|
a187bc7362 | ||
|
|
9d84c6ac9b | ||
|
|
bb6021877d | ||
|
|
da3f3878f1 | ||
|
|
32d91335cc | ||
|
|
f2a4049f6c | ||
|
|
4a48445043 | ||
|
|
a69d24b29c | ||
|
|
9acab62d1f | ||
|
|
116c83ee38 | ||
|
|
0c1d259332 | ||
|
|
8210b477ab | ||
|
|
fa3c412146 | ||
|
|
31190e169f | ||
|
|
4b7ab042f3 | ||
|
|
7c8661ec0b | ||
|
|
35c239e02d | ||
|
|
77b67849ed | ||
|
|
e746ab4241 | ||
|
|
26a95bdb89 | ||
|
|
749ac64698 | ||
|
|
df7066a5f4 | ||
|
|
d733141e2a | ||
|
|
671892d6a3 | ||
|
|
8f426d7aaf | ||
|
|
dd62627811 | ||
|
|
1d7e907c87 | ||
|
|
e6b5cd8d72 | ||
|
|
aee24a9d86 | ||
|
|
857132fcdb | ||
|
|
bf97b5e03f | ||
|
|
c19fb6d989 | ||
|
|
7cece23769 | ||
|
|
7d70008148 | ||
|
|
c7e252d418 | ||
|
|
c8269c3496 | ||
|
|
ed9963c0a5 | ||
|
|
0b06d2ac9d | ||
|
|
0c345faddb | ||
|
|
7b28f9253b | ||
|
|
1e3febdc80 | ||
|
|
07a1730615 | ||
|
|
1166e88f67 | ||
|
|
182f2375f1 | ||
|
|
09921d984a | ||
|
|
04aad7fd40 | ||
|
|
2b42f68a30 | ||
|
|
ceb89d28c8 | ||
|
|
6d1fe87fe2 | ||
|
|
8db77267d3 | ||
|
|
f1cfa97c39 | ||
|
|
01c2b96acb | ||
|
|
56139a5e9f | ||
|
|
22c35b606e | ||
|
|
ec3efe89c7 | ||
|
|
c338051802 | ||
|
|
e2653170c0 | ||
|
|
7b1fc24be4 | ||
|
|
b411a8fb51 | ||
|
|
f004921ca0 | ||
|
|
4c51a4a611 | ||
|
|
148f4f217e | ||
|
|
9e078d78a5 | ||
|
|
bb437f96a7 | ||
|
|
fddb641945 | ||
|
|
75e754f03b | ||
|
|
13f8705dc6 | ||
|
|
2faa1659dc | ||
|
|
5bafb7ccc1 | ||
|
|
9f6aac708b | ||
|
|
dac2336eb6 | ||
|
|
fe163c40c2 | ||
|
|
f85e1ecd55 | ||
|
|
955b3fe666 | ||
|
|
4ce63aec7e | ||
|
|
5308db9209 | ||
|
|
94cc2f59c8 | ||
|
|
94c126b601 | ||
|
|
b38bd8d1f8 | ||
|
|
74f56ae49d | ||
|
|
c0d3adfe21 | ||
|
|
700c5d26b9 | ||
|
|
c043281b9f | ||
|
|
d02421414f | ||
|
|
f20f15df85 | ||
|
|
181ac91421 | ||
|
|
5a525aee2b | ||
|
|
57142e2181 | ||
|
|
23e773ded2 | ||
|
|
35d2a10a62 | ||
|
|
8abfeb1213 | ||
|
|
9a860cbe94 | ||
|
|
5c2715b989 | ||
|
|
56ba9a1bc9 | ||
|
|
e4fab9d5e8 | ||
|
|
82c4708636 | ||
|
|
55cc48d9d7 | ||
|
|
d06f70ccb9 | ||
|
|
742f044cee | ||
|
|
975c8db829 | ||
|
|
acde7a55c4 | ||
|
|
4cea742bc0 | ||
|
|
128c3ababa | ||
|
|
c4f7861870 | ||
|
|
6e4a87fda8 | ||
|
|
cd0a9dd911 | ||
|
|
1b4457eee9 | ||
|
|
cb44c13d7a | ||
|
|
d6ce915161 | ||
|
|
7df1894af3 | ||
|
|
30bf7567fb | ||
|
|
911d72975e | ||
|
|
cebece3748 | ||
|
|
e0612344d7 | ||
|
|
f193dbe696 | ||
|
|
a5f825d5ca | ||
|
|
7078f4a5ab | ||
|
|
b6dc50b7ae | ||
|
|
82d7cf05bd | ||
|
|
95f48a5d4d | ||
|
|
d731c21804 | ||
|
|
338c0193f3 | ||
|
|
2d8560351e | ||
|
|
75b2df4de1 | ||
|
|
dc8ffb79c6 | ||
|
|
dc9a41ed04 | ||
|
|
caefbe915c | ||
|
|
2164aa7520 | ||
|
|
64ed2864dc | ||
|
|
3b139263ed | ||
|
|
697319b953 | ||
|
|
2705bba00f | ||
|
|
636a59ec91 | ||
|
|
195ed68152 | ||
|
|
ee0a0b4867 | ||
|
|
710780456a | ||
|
|
ea5397f250 | ||
|
|
dcb59e767c | ||
|
|
b4a9da92f6 | ||
|
|
07101a1b84 | ||
|
|
e0af19583c | ||
|
|
36261a6211 | ||
|
|
ad0a5c7932 | ||
|
|
65149c33da | ||
|
|
3d7b3c703a | ||
|
|
b446d2d1ac | ||
|
|
43115d1da4 | ||
|
|
484f21fac4 | ||
|
|
704109daa4 | ||
|
|
48475fb0eb | ||
|
|
eab55bc7dd | ||
|
|
61847d94f5 | ||
|
|
2f1eabcdc9 | ||
|
|
e7ba8e7739 | ||
|
|
b1e39ab4b3 | ||
|
|
14075c6b9b | ||
|
|
72c1acdfd2 | ||
|
|
ccc03c8ae0 | ||
|
|
9be085dd90 | ||
|
|
9483794d20 | ||
|
|
1af35bff7f | ||
|
|
23068d2819 | ||
|
|
f00dfa95fd | ||
|
|
a9b8b4193c | ||
|
|
3b6148c841 | ||
|
|
9a0f06d195 | ||
|
|
3e8755621a | ||
|
|
c4047ff57a | ||
|
|
558b9e7c30 | ||
|
|
f49850ae8a | ||
|
|
0e129ab00f | ||
|
|
708944d078 | ||
|
|
5291bbd596 | ||
|
|
c0a1dc6056 | ||
|
|
34b7ba97b7 | ||
|
|
3c29165028 | ||
|
|
1b523c94f5 | ||
|
|
0d9e28f8d1 | ||
|
|
d2c92eedd3 | ||
|
|
8d09bfb1a9 | ||
|
|
bf52f02b82 | ||
|
|
d247dbebfc | ||
|
|
e68c4e134d | ||
|
|
ac734ecf71 | ||
|
|
32822703e9 | ||
|
|
b16d0186a1 | ||
|
|
04706eb9de | ||
|
|
a481ca3c89 | ||
|
|
a061d440d7 | ||
|
|
db84e6d318 | ||
|
|
79c7074b8e | ||
|
|
ecdd5ae88d | ||
|
|
0de454fc25 | ||
|
|
a007434fc5 | ||
|
|
e16a12e401 | ||
|
|
8edc9673d8 | ||
|
|
c494907b19 | ||
|
|
90b7373af2 | ||
|
|
9f0a18ecce | ||
|
|
1f2eee133b | ||
|
|
a111131a16 | ||
|
|
46486d75c9 | ||
|
|
f55ea7d320 | ||
|
|
09815f9614 | ||
|
|
91395d4b31 | ||
|
|
807a789c83 | ||
|
|
7ee832d71f | ||
|
|
1957191d07 | ||
|
|
927b438a4b | ||
|
|
c420f0397c | ||
|
|
c516f8d1d9 | ||
|
|
d9f6aad7d4 | ||
|
|
037b01e08c | ||
|
|
2fec36d41b | ||
|
|
0ea63a78b4 | ||
|
|
582d159332 | ||
|
|
35cf36f8c0 | ||
|
|
a22e92b1b5 | ||
|
|
15b219a773 | ||
|
|
571992106d | ||
|
|
78106b0ad3 | ||
|
|
21418954c1 | ||
|
|
bad68830ff | ||
|
|
d3bb076b71 | ||
|
|
a750aedeb8 | ||
|
|
27247aec12 | ||
|
|
ddcf370ffa | ||
|
|
d681ed762d | ||
|
|
9165b3abb7 | ||
|
|
41d3b4fbba | ||
|
|
fc0c34d974 | ||
|
|
a1e8a37147 |
7
.github/ISSUE_TEMPLATE/bug_report_zh.yml
vendored
7
.github/ISSUE_TEMPLATE/bug_report_zh.yml
vendored
@ -9,9 +9,10 @@ body:
|
||||
description: 在提交之前,请勾选以下所有选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。
|
||||
options:
|
||||
- label: 我已在标题简短的描述了我所遇到的问题
|
||||
- label: 我未在[Issue Tracker](./?q=is%3Aissue)中找到我要提出的问题
|
||||
- label: 我未在[常见问题](https://mihomo.party/docs/issues/common)中找到我要提出的问题
|
||||
- label: 这是GUI程序的问题,而不是内核程序的问题
|
||||
- label: 我已在 [Issue Tracker](./?q=is%3Aissue) 中寻找过我要提出的问题,但未找到相同的问题
|
||||
- label: 我已在 [常见问题](https://mihomo.party/docs/issues/common) 中寻找过我要提出的问题,并没有找到答案
|
||||
- label: 这是 GUI 程序的问题,而不是内核程序的问题
|
||||
- label: 我已经关闭所有杀毒软件/代理软件后测试过,问题依旧存在
|
||||
- label: 我已经使用最新的测试版本测试过,问题依旧存在
|
||||
|
||||
- type: dropdown
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -3,3 +3,6 @@ contact_links:
|
||||
- name: '常见问题'
|
||||
about: '提出问题前请先查看常见问题'
|
||||
url: 'https://mihomo.party/docs/issues/common'
|
||||
- name: '交流群组'
|
||||
about: '提问/讨论性质的问题请勿提交issue'
|
||||
url: 'https://t.me/mihomo_party_group'
|
||||
|
||||
@ -9,8 +9,8 @@ body:
|
||||
description: 在提交之前,请勾选以下所有选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。
|
||||
options:
|
||||
- label: 我已在标题简短的描述了我所需的功能
|
||||
- label: 我已在[Issue Tracker](./?q=is%3Aissue)中寻找过,但未找到我所需的功能
|
||||
- label: 这是向GUI程序提出的功能请求,而不是内核程序
|
||||
- label: 我已在 [Issue Tracker](./?q=is%3Aissue) 中寻找过,但未找到我所需的功能
|
||||
- label: 这是向 GUI 程序提出的功能请求,而不是内核程序
|
||||
- label: 我未在最新的测试版本找到我所需的功能
|
||||
|
||||
- type: dropdown
|
||||
|
||||
147
.github/workflows/build.yml
vendored
147
.github/workflows/build.yml
vendored
@ -1,10 +1,13 @@
|
||||
name: Build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- '.github/workflows/issues.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: write-all
|
||||
|
||||
@ -142,6 +145,7 @@ jobs:
|
||||
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 }}
|
||||
- name: Build
|
||||
env:
|
||||
@ -196,9 +200,35 @@ jobs:
|
||||
env:
|
||||
npm_config_arch: ${{ matrix.arch }}
|
||||
npm_config_target_arch: ${{ matrix.arch }}
|
||||
run: pnpm build:mac --${{ matrix.arch }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
run: |
|
||||
chmod +x build/pkg-scripts/postinstall build/pkg-scripts/preinstall
|
||||
pnpm build:mac --${{ matrix.arch }}
|
||||
- name: Setup temporary installer signing keychain
|
||||
uses: apple-actions/import-codesign-certs@v3
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.CSC_INSTALLER_LINK }}
|
||||
p12-password: ${{ secrets.CSC_INSTALLER_KEY_PASSWORD }}
|
||||
- name: Sign the Apple pkg
|
||||
run: |
|
||||
for pkg_name in $(ls -1 dist/*.pkg); do
|
||||
pkg_name=$(ls -1 dist/*.pkg)
|
||||
mv $pkg_name Unsigned-Workbench.pkg
|
||||
productsign --sign "Developer ID Installer: Prometheus Advertising Corp (489PDK5LP3)" Unsigned-Workbench.pkg $pkg_name
|
||||
rm -f Unsigned-Workbench.pkg
|
||||
xcrun notarytool submit $pkg_name --apple-id $APPLE_ID --team-id $APPLE_TEAM_ID --password $APPLE_APP_SPECIFIC_PASSWORD --wait
|
||||
done
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
- name: Generate checksums
|
||||
run: pnpm checksum .dmg
|
||||
run: pnpm checksum .pkg
|
||||
- name: Upload Artifacts
|
||||
if: startsWith(github.ref, 'refs/heads/')
|
||||
uses: actions/upload-artifact@v4
|
||||
@ -206,7 +236,7 @@ jobs:
|
||||
name: MacOS ${{ matrix.arch }}
|
||||
path: |
|
||||
dist/*.sha256
|
||||
dist/*.dmg
|
||||
dist/*.pkg
|
||||
if-no-files-found: error
|
||||
- name: Publish Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
@ -214,25 +244,102 @@ jobs:
|
||||
with:
|
||||
files: |
|
||||
dist/*.sha256
|
||||
dist/*.dmg
|
||||
dist/*.pkg
|
||||
body_path: changelog.md
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
macos10:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
run: npm install -g pnpm
|
||||
- name: Install Dependencies
|
||||
env:
|
||||
npm_config_arch: ${{ matrix.arch }}
|
||||
npm_config_target_arch: ${{ matrix.arch }}
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm add @mihomo-party/sysproxy-darwin-${{ matrix.arch }}
|
||||
pnpm add -D electron@32.2.2
|
||||
pnpm prepare --${{ matrix.arch }}
|
||||
- name: Build
|
||||
env:
|
||||
npm_config_arch: ${{ matrix.arch }}
|
||||
npm_config_target_arch: ${{ matrix.arch }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
run: |
|
||||
sed -i "" -e "s/macos/catalina/" electron-builder.yml
|
||||
chmod +x build/pkg-scripts/postinstall build/pkg-scripts/preinstall
|
||||
pnpm build:mac --${{ matrix.arch }}
|
||||
- name: Setup temporary installer signing keychain
|
||||
uses: apple-actions/import-codesign-certs@v3
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.CSC_INSTALLER_LINK }}
|
||||
p12-password: ${{ secrets.CSC_INSTALLER_KEY_PASSWORD }}
|
||||
- name: Sign the Apple pkg
|
||||
run: |
|
||||
for pkg_name in $(ls -1 dist/*.pkg); do
|
||||
pkg_name=$(ls -1 dist/*.pkg)
|
||||
mv $pkg_name Unsigned-Workbench.pkg
|
||||
productsign --sign "Developer ID Installer: Prometheus Advertising Corp (489PDK5LP3)" Unsigned-Workbench.pkg $pkg_name
|
||||
rm -f Unsigned-Workbench.pkg
|
||||
xcrun notarytool submit $pkg_name --apple-id $APPLE_ID --team-id $APPLE_TEAM_ID --password $APPLE_APP_SPECIFIC_PASSWORD --wait
|
||||
done
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
- name: Generate checksums
|
||||
run: pnpm checksum .pkg
|
||||
- name: Upload Artifacts
|
||||
if: startsWith(github.ref, 'refs/heads/')
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Catalina ${{ matrix.arch }}
|
||||
path: |
|
||||
dist/*.sha256
|
||||
dist/*.pkg
|
||||
if-no-files-found: error
|
||||
- name: Publish Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
dist/*.sha256
|
||||
dist/*.pkg
|
||||
body_path: changelog.md
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
updater:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [windows, macos, windows7]
|
||||
needs: [windows, macos, windows7, macos10]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
run: npm install -g pnpm
|
||||
- name: Build Latest
|
||||
run: pnpm install && pnpm updater
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
- name: Telegram Notification
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
run: pnpm telegram
|
||||
- name: Generate latest.yml
|
||||
run: pnpm updater
|
||||
- name: Publish Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
@ -280,7 +387,7 @@ jobs:
|
||||
pkgbuild: aur/${{ matrix.pkgname }}/PKGBUILD
|
||||
commit_username: pompurin404
|
||||
commit_email: pompurin404@mihomo.party
|
||||
ssh_private_key: ${{ secrets.PRIVATE_KEY }}
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_message: Update AUR package
|
||||
ssh_keyscan_types: rsa,ed25519
|
||||
allow_empty_commits: false
|
||||
@ -302,7 +409,7 @@ jobs:
|
||||
pkgbuild: aur/mihomo-party-git/PKGBUILD
|
||||
commit_username: pompurin404
|
||||
commit_email: pompurin404@mihomo.party
|
||||
ssh_private_key: ${{ secrets.PRIVATE_KEY }}
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_message: Update AUR package
|
||||
ssh_keyscan_types: rsa,ed25519
|
||||
allow_empty_commits: false
|
||||
@ -323,21 +430,3 @@ jobs:
|
||||
release-tag: v${{env.VERSION}}
|
||||
installers-regex: 'mihomo-party-windows-.*setup\.exe$'
|
||||
token: ${{ secrets.POMPURIN404_TOKEN }}
|
||||
|
||||
homebrew:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
name: Update Homebrew cask
|
||||
needs: macos
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Set up Git
|
||||
run: |
|
||||
git config --global user.email pompurin404@mihomo.party
|
||||
git config --global user.name pompurin404
|
||||
- name: Update Homebrew cask
|
||||
env:
|
||||
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.POMPURIN404_TOKEN }}
|
||||
run: |
|
||||
brew tap mihomo-party-org/mihomo-party
|
||||
brew update
|
||||
brew bump-cask-pr mihomo-party --version $(echo ${{ github.ref }} | tr -d 'refs/tags/v') --no-browse
|
||||
|
||||
29
.github/workflows/issues.yml
vendored
Normal file
29
.github/workflows/issues.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
name: Review Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate Token
|
||||
uses: tibdex/github-app-token@v2
|
||||
id: generate
|
||||
with:
|
||||
app_id: ${{ secrets.BOT_APP_ID }}
|
||||
private_key: ${{ secrets.BOT_PRIVATE_KEY }}
|
||||
- name: Review Issues
|
||||
uses: mihomo-party-org/universal-assistant@v1.0.3
|
||||
with:
|
||||
github_token: ${{ steps.generate.outputs.token }}
|
||||
openai_base_url: ${{ secrets.OPENAI_BASE_URL }}
|
||||
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
|
||||
openai_model: ${{ vars.OPENAI_MODEL }}
|
||||
system_prompt: ${{ vars.SYSTEM_PROMPT }}
|
||||
available_tools: ${{ vars.AVAILABLE_TOOLS }}
|
||||
user_input: |
|
||||
请审查如下 Issue:
|
||||
标题:"${{ github.event.issue.title }}"
|
||||
内容:"${{ github.event.issue.body }}"
|
||||
2
.npmrc
2
.npmrc
@ -1,3 +1,3 @@
|
||||
shamefully-hoist=true
|
||||
virtual-store-dir-max-length=80
|
||||
public-hoist-pattern[]=*@nextui-org/*
|
||||
public-hoist-pattern[]=*@heroui/*
|
||||
29
README.md
29
README.md
@ -3,28 +3,39 @@
|
||||
<img height='48px' src='./images/icon-black.png#gh-light-mode-only'>
|
||||
</h3>
|
||||
|
||||
<h3 align="center">Another Mihomo GUI</h3>
|
||||
<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>
|
||||
<a href="https://t.me/mihomo_party_channel">
|
||||
<img src="https://img.shields.io/badge/Telegram-Channel-blue?logo=telegram">
|
||||
<a href="https://t.me/mihomo_party_group">
|
||||
<img src="https://img.shields.io/badge/Telegram-Group-blue?logo=telegram">
|
||||
</a>
|
||||
</p>
|
||||
<div align='center'>
|
||||
<img width='90%' src="./images/preview.jpg">
|
||||
</div>
|
||||
|
||||
### 本项目由“[狗狗加速](https://party.dginv.click/#/register?code=ARdo0mXx)”赞助
|
||||
##### [狗狗加速 —— 技术流机场 Doggygo VPN](https://party.dginv.click/#/register?code=ARdo0mXx)
|
||||
|
||||
- 高性能海外机场,稳定首选,海外团队,无跑路风险
|
||||
- Mihomo Party专属8折优惠码:party,仅有500份
|
||||
- Party专属链接注册送 3 天,每天 1G 流量 [免费试用](https://party.dginv.click/#/register?code=ARdo0mXx)
|
||||
- 优惠套餐每月仅需 15.8 元,160G 流量,年付 8 折
|
||||
- 全球首家支持Hysteria1/2 协议,集群负载均衡设计,高速专线,基于最新UDP quic技术,极低延迟,无视晚高峰,4K 秒开,配合Mihomo Party食用更省心!
|
||||
- 解锁流媒体及 ChatGPT
|
||||
- 官网:[https://狗狗加速.com](https://party.dginv.click/#/register?code=ARdo0mXx)
|
||||
|
||||
### 特性
|
||||
|
||||
- [x] 开箱即用,无需服务模式的Tun
|
||||
- [x] 多种配色主题可选,UI焕然一新
|
||||
- [x] 支持大部分Mihomo常用配置修改
|
||||
- [x] 内置稳定版和预览版Mihomo内核
|
||||
- [x] 通过WebDav一键备份和恢复配置
|
||||
- [x] 开箱即用,无需服务模式的 Tun
|
||||
- [x] 多种配色主题可选,UI 焕然一新
|
||||
- [x] 支持大部分 Mihomo 常用配置修改
|
||||
- [x] 内置稳定版和预览版 Mihomo 内核
|
||||
- [x] 通过 WebDAV 一键备份和恢复配置
|
||||
- [x] 强大的覆写功能,任意修订配置文件
|
||||
- [x] 深度集成Sub-Store,轻松管理订阅
|
||||
- [x] 深度集成 Sub-Store,轻松管理订阅
|
||||
|
||||
### 安装/使用指南见 [官方文档](https://mihomo.party)
|
||||
|
||||
@ -10,7 +10,6 @@ conflicts=("$_pkgname" "$_pkgname-git" "$_pkgname-electron" "$_pkgname-electron-
|
||||
conflicts=("mihomo-party-git" 'mihomo-party')
|
||||
depends=('gtk3' 'libnotify' 'nss' 'libxss' 'libxtst' 'xdg-utils' 'at-spi2-core' 'util-linux-libs' 'libsecret')
|
||||
optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayatana indicators in Unity, KDE or Systray (GTK+ 3 library).')
|
||||
makedepends=('nodejs' 'pnpm' 'jq' 'libxcrypt-compat')
|
||||
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")
|
||||
@ -22,11 +21,10 @@ sha256sums_aarch64=('8cd7398b8fc1cd70d41e386af9995cbddc1043d9018391c29f056f14357
|
||||
package() {
|
||||
bsdtar -xf data.tar.xz -C "${pkgdir}/"
|
||||
chmod +x ${pkgdir}/opt/mihomo-party/mihomo-party
|
||||
chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
cd ${pkgdir}/../..
|
||||
install -Dm755 "${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
|
||||
sed -i '3s!/opt/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
install -Dm755 "${srcdir}/${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
|
||||
sed -i '3s!/opt/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
|
||||
|
||||
chown -R root:root ${pkgdir}
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,12 +27,11 @@ package() {
|
||||
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 +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
cd ${pkgdir}/../..
|
||||
install -Dm755 "${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
install -Dm755 "${srcdir}/${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
|
||||
install -Dm644 "${_pkgname}.desktop" "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
|
||||
install -Dm644 "${pkgdir}/opt/mihomo-party/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png"
|
||||
|
||||
chown -R root:root ${pkgdir}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
[Desktop Entry]
|
||||
Name=mihomo-party
|
||||
Name=Mihomo Party
|
||||
Exec=mihomo-party %U
|
||||
Terminal=false
|
||||
Type=Application
|
||||
|
||||
@ -24,6 +24,7 @@ options=('!lto')
|
||||
|
||||
prepare(){
|
||||
cd $srcdir/${_pkgname}-${pkgver}
|
||||
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
|
||||
pnpm install
|
||||
}
|
||||
|
||||
@ -36,12 +37,11 @@ package() {
|
||||
asar extract $srcdir/${_pkgname}-${pkgver}/dist/linux-unpacked/resources/app.asar ${pkgdir}/opt/mihomo-party
|
||||
cp -r $srcdir/${_pkgname}-${pkgver}/extra/sidecar ${pkgdir}/opt/mihomo-party/resources/
|
||||
cp -r $srcdir/${_pkgname}-${pkgver}/extra/files ${pkgdir}/opt/mihomo-party/resources/
|
||||
chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
cd ${pkgdir}/../..
|
||||
install -Dm755 "${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
install -Dm755 "${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
|
||||
install -Dm644 "${_pkgname}.desktop" "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
|
||||
install -Dm644 "${pkgdir}/opt/mihomo-party/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png"
|
||||
|
||||
chown -R root:root ${pkgdir}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
[Desktop Entry]
|
||||
Name=mihomo-party
|
||||
Name=Mihomo Party
|
||||
Exec=mihomo-party %U
|
||||
Terminal=false
|
||||
Type=Application
|
||||
|
||||
@ -25,6 +25,7 @@ pkgver() {
|
||||
|
||||
prepare(){
|
||||
cd $srcdir/${_pkgname}
|
||||
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
|
||||
pnpm install
|
||||
}
|
||||
|
||||
@ -38,12 +39,10 @@ package() {
|
||||
bsdtar -xf mihomo-party-linux-$(jq '.version' $srcdir/${_pkgname}/package.json | tr -d 'v"')*.deb
|
||||
bsdtar -xf data.tar.xz -C "${pkgdir}/"
|
||||
chmod +x ${pkgdir}/opt/mihomo-party/mihomo-party
|
||||
chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
cd ${pkgdir}/../..
|
||||
install -Dm755 "${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
|
||||
|
||||
sed -i '3s!/opt/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
install -Dm755 "${srcdir}/../${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
|
||||
sed -i '3s!/opt/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
|
||||
|
||||
chown -R root:root ${pkgdir}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ options=('!lto')
|
||||
|
||||
prepare(){
|
||||
cd $srcdir/${pkgname}-${pkgver}
|
||||
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
|
||||
pnpm install
|
||||
}
|
||||
|
||||
@ -33,11 +34,10 @@ package() {
|
||||
bsdtar -xf mihomo-party-linux-${pkgver}*.deb
|
||||
bsdtar -xf data.tar.xz -C "${pkgdir}/"
|
||||
chmod +x ${pkgdir}/opt/mihomo-party/mihomo-party
|
||||
chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +x ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
cd ${pkgdir}/../..
|
||||
install -Dm755 "${pkgname}.sh" "${pkgdir}/usr/bin/${pkgname}"
|
||||
sed -i '3s!/opt/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${pkgname}.desktop"
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
|
||||
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
|
||||
install -Dm755 "${srcdir}/../${pkgname}.sh" "${pkgdir}/usr/bin/${pkgname}"
|
||||
sed -i '3s!/opt/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${pkgname}.desktop"
|
||||
|
||||
chown -R root:root ${pkgdir}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
build/background.png
Normal file
BIN
build/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@ -11,6 +11,8 @@ else
|
||||
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
|
||||
|
||||
if hash update-mime-database 2>/dev/null; then
|
||||
update-mime-database /usr/share/mime || true
|
||||
173
build/pkg-scripts/postinstall
Normal file
173
build/pkg-scripts/postinstall
Normal file
@ -0,0 +1,173 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 设置日志文件
|
||||
LOG_FILE="/tmp/mihomo-party-install.log"
|
||||
exec > "$LOG_FILE" 2>&1
|
||||
|
||||
log() {
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
# 检查 root 权限
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
log "Error: Please run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 判断 $2 是否以 .app 结尾
|
||||
if [[ $2 == *".app" ]]; then
|
||||
APP_PATH="$2"
|
||||
else
|
||||
APP_PATH="$2/Mihomo Party.app"
|
||||
fi
|
||||
|
||||
HELPER_PATH="/Library/PrivilegedHelperTools/party.mihomo.helper"
|
||||
LAUNCH_DAEMON="/Library/LaunchDaemons/party.mihomo.helper.plist"
|
||||
|
||||
log "Starting installation..."
|
||||
|
||||
# 创建目录并设置权限
|
||||
log "Creating directories and setting permissions..."
|
||||
mkdir -p "/Library/PrivilegedHelperTools"
|
||||
chmod 755 "/Library/PrivilegedHelperTools"
|
||||
chown root:wheel "/Library/PrivilegedHelperTools"
|
||||
|
||||
# 设置核心文件权限
|
||||
log "Setting core file permissions..."
|
||||
if [ -f "$APP_PATH/Contents/Resources/sidecar/mihomo" ]; then
|
||||
chown root:admin "$APP_PATH/Contents/Resources/sidecar/mihomo"
|
||||
chmod +s "$APP_PATH/Contents/Resources/sidecar/mihomo"
|
||||
log "Set permissions for mihomo"
|
||||
else
|
||||
log "Warning: mihomo binary not found at $APP_PATH/Contents/Resources/sidecar/mihomo"
|
||||
fi
|
||||
|
||||
if [ -f "$APP_PATH/Contents/Resources/sidecar/mihomo-alpha" ]; then
|
||||
chown root:admin "$APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
|
||||
chmod +s "$APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
|
||||
log "Set permissions for mihomo-alpha"
|
||||
else
|
||||
log "Warning: mihomo-alpha binary not found at $APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
|
||||
fi
|
||||
|
||||
# 复制 helper 工具
|
||||
log "Installing helper tool..."
|
||||
if [ -f "$APP_PATH/Contents/Resources/files/party.mihomo.helper" ]; then
|
||||
cp -f "$APP_PATH/Contents/Resources/files/party.mihomo.helper" "$HELPER_PATH"
|
||||
chown root:wheel "$HELPER_PATH"
|
||||
chmod 544 "$HELPER_PATH"
|
||||
log "Helper tool installed successfully"
|
||||
else
|
||||
log "Error: Helper file not found at $APP_PATH/Contents/Resources/files/party.mihomo.helper"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建并配置 LaunchDaemon
|
||||
log "Configuring LaunchDaemon..."
|
||||
mkdir -p "/Library/LaunchDaemons"
|
||||
cat << EOF > "$LAUNCH_DAEMON"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>party.mihomo.helper</string>
|
||||
<key>AssociatedBundleIdentifiers</key>
|
||||
<array>
|
||||
<string>party.mihomo.app</string>
|
||||
</array>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>Program</key>
|
||||
<string>${HELPER_PATH}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/party.mihomo.helper.err</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/party.mihomo.helper.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
chown root:wheel "$LAUNCH_DAEMON"
|
||||
chmod 644 "$LAUNCH_DAEMON"
|
||||
log "LaunchDaemon configured"
|
||||
|
||||
# 验证关键文件
|
||||
log "Verifying installation..."
|
||||
if [ ! -x "$HELPER_PATH" ]; then
|
||||
log "Error: Helper tool is not executable: $HELPER_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查二进制文件有效性
|
||||
if ! file "$HELPER_PATH" | grep -q "Mach-O"; then
|
||||
log "Error: Helper tool is not a valid Mach-O binary"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 验证 plist 格式
|
||||
if ! plutil -lint "$LAUNCH_DAEMON" >/dev/null 2>&1; then
|
||||
log "Error: Invalid plist format"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 获取 macOS 版本
|
||||
macos_version=$(sw_vers -productVersion)
|
||||
macos_major=$(echo "$macos_version" | cut -d. -f1)
|
||||
log "macOS version: $macos_version"
|
||||
|
||||
# 清理现有服务
|
||||
log "Cleaning up existing services..."
|
||||
launchctl bootout system "$LAUNCH_DAEMON" 2>/dev/null || true
|
||||
launchctl unload "$LAUNCH_DAEMON" 2>/dev/null || true
|
||||
|
||||
# 加载服务
|
||||
log "Loading service..."
|
||||
if [ "$macos_major" -ge 11 ]; then
|
||||
# macOS Big Sur 及更新版本使用 bootstrap
|
||||
if ! launchctl bootstrap system "$LAUNCH_DAEMON"; then
|
||||
log "Bootstrap failed, trying legacy load..."
|
||||
if ! launchctl load "$LAUNCH_DAEMON"; then
|
||||
log "Error: Failed to load service with both methods"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# 旧版本使用 load
|
||||
if ! launchctl load "$LAUNCH_DAEMON"; then
|
||||
log "Error: Failed to load service"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 验证服务状态
|
||||
log "Verifying service status..."
|
||||
sleep 2
|
||||
if launchctl list | grep -q "party.mihomo.helper"; then
|
||||
log "Service loaded successfully"
|
||||
else
|
||||
log "Warning: Service may not be running properly"
|
||||
fi
|
||||
|
||||
log "Installation completed successfully"
|
||||
|
||||
# Fix user data directory permissions
|
||||
log "Fixing user data directory permissions..."
|
||||
for user_home in /Users/*; do
|
||||
if [ -d "$user_home" ] && [ "$(basename "$user_home")" != "Shared" ] && [ "$(basename "$user_home")" != ".localized" ]; then
|
||||
username=$(basename "$user_home")
|
||||
user_data_dir="$user_home/Library/Application Support/mihomo-party"
|
||||
|
||||
if [ -d "$user_data_dir" ]; then
|
||||
current_owner=$(stat -f "%Su" "$user_data_dir" 2>/dev/null || echo "unknown")
|
||||
if [ "$current_owner" = "root" ]; then
|
||||
log "Fixing ownership for user: $username"
|
||||
chown -R "$username:staff" "$user_data_dir" 2>/dev/null || true
|
||||
chmod -R u+rwX "$user_data_dir" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
exit 0
|
||||
26
build/pkg-scripts/preinstall
Normal file
26
build/pkg-scripts/preinstall
Normal file
@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 检查 root 权限
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HELPER_PATH="/Library/PrivilegedHelperTools/party.mihomo.helper"
|
||||
LAUNCH_DAEMON="/Library/LaunchDaemons/party.mihomo.helper.plist"
|
||||
|
||||
# 停止并卸载现有的 LaunchDaemon
|
||||
if [ -f "$LAUNCH_DAEMON" ]; then
|
||||
launchctl unload "$LAUNCH_DAEMON" 2>/dev/null || true
|
||||
rm -f "$LAUNCH_DAEMON"
|
||||
fi
|
||||
|
||||
# 移除 helper 工具
|
||||
rm -f "$HELPER_PATH"
|
||||
|
||||
# 清理可能存在的旧版本文件
|
||||
rm -rf "/Applications/Mihomo Party.app"
|
||||
rm -rf "/Applications/Mihomo\\ Party.app"
|
||||
|
||||
exit 0
|
||||
76
changelog.md
76
changelog.md
@ -1,14 +1,72 @@
|
||||
### Breaking Changes
|
||||
## 1.7.7
|
||||
### 新功能 (Feat)
|
||||
- Mihomo 内核升级 v1.19.12
|
||||
- 新增 Webdav 最大备数设置和清理逻辑
|
||||
|
||||
- 为了修复macOS应用内更新问题,此版本需要手动下载dmg进行安装
|
||||
### 修复 (Fix)
|
||||
- 修复 MacOS 下无法启动的问题(重置工作目录权限)
|
||||
- 尝试修复不同版本 MacOS 下安装软件时候的报错(Input/output error)
|
||||
- 部分遗漏的多国语言翻译
|
||||
|
||||
### Features
|
||||
## 1.7.6
|
||||
|
||||
- 支持自定义延迟测试并发数量
|
||||
- 完善Sub-Store环境变量
|
||||
- 支持查看已关闭的连接
|
||||
**此版本修复了 1.7.5 中的几个严重 bug,推荐所有人更新**
|
||||
|
||||
### Bug Fixes
|
||||
### 修复 (Fix)
|
||||
- 修复了内核1.19.8更新后gist同步失效的问题(#780)
|
||||
- 部分遗漏的多国语言翻译
|
||||
- MacOS 下启动Error: EACCES: permission denied
|
||||
- MacOS 系统代理 bypass 不生效
|
||||
- MacOS 系统代理开启时 500 报错
|
||||
|
||||
- 修复macOS应用内更新后权限丢失的问题
|
||||
- 修复高版本macOS SSID获取失败的问题
|
||||
## 1.7.5
|
||||
|
||||
### 新功能 (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)
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
appId: party.mihomo.app
|
||||
productName: mihomo-party
|
||||
executableName: mihomo-party
|
||||
productName: Mihomo Party
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
@ -31,7 +30,6 @@ win:
|
||||
artifactName: ${name}-windows-${version}-${arch}-portable.${ext}
|
||||
nsis:
|
||||
artifactName: ${name}-windows-${version}-${arch}-setup.${ext}
|
||||
shortcutName: Mihomo Party
|
||||
uninstallDisplayName: ${productName}
|
||||
allowToChangeInstallationDirectory: true
|
||||
oneClick: false
|
||||
@ -39,25 +37,34 @@ nsis:
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
target:
|
||||
- dmg
|
||||
- pkg
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
notarize: false
|
||||
notarize: true
|
||||
artifactName: ${name}-macos-${version}-${arch}.${ext}
|
||||
pkg:
|
||||
allowAnywhere: false
|
||||
allowCurrentUserHome: false
|
||||
background:
|
||||
alignment: bottomleft
|
||||
file: build/background.png
|
||||
linux:
|
||||
desktop:
|
||||
Name: Mihomo Party
|
||||
MimeType: 'x-scheme-handler/clash;x-scheme-handler/mihomo'
|
||||
target:
|
||||
- deb
|
||||
- rpm
|
||||
maintainer: mihomo-party
|
||||
maintainer: mihomo-party-org
|
||||
category: Utility
|
||||
artifactName: ${name}-linux-${version}-${arch}.${ext}
|
||||
deb:
|
||||
afterInstall: 'build/linux/deb/postinst'
|
||||
afterInstall: 'build/linux/postinst'
|
||||
rpm:
|
||||
afterInstall: 'build/linux/postinst'
|
||||
npmRebuild: true
|
||||
publish: []
|
||||
|
||||
@ -22,6 +22,14 @@ export default defineConfig({
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
renderer: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/renderer/index.html'),
|
||||
floating: resolve('src/renderer/floating.html')
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src')
|
||||
|
||||
89
package.json
89
package.json
@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "mihomo-party",
|
||||
"version": "1.3.2",
|
||||
"version": "1.7.7",
|
||||
"description": "Mihomo Party",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "mihomo-party",
|
||||
"author": "mihomo-party-org",
|
||||
"homepage": "https://mihomo.party",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
@ -15,6 +15,7 @@
|
||||
"updater": "node scripts/updater.mjs",
|
||||
"checksum": "node scripts/checksum.mjs",
|
||||
"telegram": "node scripts/telegram.mjs",
|
||||
"artifact": "node scripts/artifact.mjs",
|
||||
"dev": "electron-vite dev",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:win": "electron-vite build && electron-builder --publish never --win",
|
||||
@ -24,66 +25,74 @@
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@mihomo-party/sysproxy": "^2.0.4",
|
||||
"@heroui/react": "^2.6.14",
|
||||
"@mihomo-party/sysproxy": "^2.0.7",
|
||||
"@mihomo-party/sysproxy-darwin-arm64": "^2.0.7",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.7.7",
|
||||
"chokidar": "^4.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^4.21.0",
|
||||
"lodash": "^4.17.21",
|
||||
"recharts": "^2.12.7",
|
||||
"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.5.1"
|
||||
"yaml": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@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",
|
||||
"@nextui-org/react": "^2.4.6",
|
||||
"@types/adm-zip": "^0.5.5",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/adm-zip": "^0.5.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/pubsub-js": "^1.8.6",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@types/react": "^19.0.4",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cron-validator": "^1.3.1",
|
||||
"driver.js": "^1.3.1",
|
||||
"electron": "^32.1.2",
|
||||
"electron-builder": "^25.0.5",
|
||||
"driver.js": "^1.3.5",
|
||||
"electron": "^34.0.2",
|
||||
"electron-builder": "25.1.8",
|
||||
"electron-vite": "^2.3.0",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.36.1",
|
||||
"framer-motion": "^11.5.4",
|
||||
"meta-json-schema": "^1.18.8",
|
||||
"monaco-yaml": "^5.2.2",
|
||||
"nanoid": "^5.0.7",
|
||||
"next-themes": "^0.3.0",
|
||||
"postcss": "^8.4.45",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"form-data": "^4.0.1",
|
||||
"framer-motion": "12.0.11",
|
||||
"lodash": "^4.17.21",
|
||||
"meta-json-schema": "^1.18.9",
|
||||
"monaco-yaml": "^5.2.3",
|
||||
"nanoid": "^5.0.8",
|
||||
"next-themes": "^0.4.3",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"pubsub-js": "^1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"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.56.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-virtuoso": "^4.10.4",
|
||||
"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.11",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tar": "^7.4.3",
|
||||
"tsx": "^4.19.1",
|
||||
"tsx": "^4.19.2",
|
||||
"types-pac": "^1.0.3",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.5",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.7",
|
||||
"vite-plugin-monaco-editor": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c"
|
||||
}
|
||||
|
||||
9635
pnpm-lock.yaml
generated
9635
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
27
scripts/cleanup-mac.sh
Executable file
27
scripts/cleanup-mac.sh
Executable file
@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "=== Mihomo Party Cleanup Tool ==="
|
||||
echo "This script will remove all Mihomo Party related files and services."
|
||||
read -p "Are you sure you want to continue? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop and unload services
|
||||
echo "Stopping services..."
|
||||
sudo launchctl unload /Library/LaunchDaemons/party.mihomo.helper.plist 2>/dev/null || true
|
||||
|
||||
# Remove files
|
||||
echo "Removing files..."
|
||||
sudo rm -f /Library/LaunchDaemons/party.mihomo.helper.plist
|
||||
sudo rm -f /Library/PrivilegedHelperTools/party.mihomo.helper
|
||||
sudo rm -rf "/Applications/Mihomo Party.app"
|
||||
sudo rm -rf "/Applications/Mihomo\\ Party.app"
|
||||
sudo rm -rf ~/Library/Application\ Support/mihomo-party
|
||||
sudo rm -rf ~/Library/Caches/mihomo-party
|
||||
sudo rm -f ~/Library/Preferences/party.mihomo.app.helper.plist
|
||||
sudo rm -f ~/Library/Preferences/party.mihomo.app.plist
|
||||
|
||||
echo "Cleanup complete. Please restart your computer to complete the process."
|
||||
@ -241,6 +241,11 @@ const resolveMmdb = () =>
|
||||
file: 'country.mmdb',
|
||||
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country-lite.mmdb`
|
||||
})
|
||||
const resolveMetadb = () =>
|
||||
resolveResource({
|
||||
file: 'geoip.metadb',
|
||||
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb`
|
||||
})
|
||||
const resolveGeosite = () =>
|
||||
resolveResource({
|
||||
file: 'geosite.dat',
|
||||
@ -271,6 +276,28 @@ const resolveRunner = () =>
|
||||
file: 'mihomo-party-run.exe',
|
||||
downloadURL: `https://github.com/mihomo-party-org/mihomo-party-run/releases/download/${arch}/mihomo-party-run.exe`
|
||||
})
|
||||
|
||||
const resolveMonitor = async () => {
|
||||
const tempDir = path.join(TEMP_DIR, 'TrafficMonitor')
|
||||
const tempZip = path.join(tempDir, `${arch}.zip`)
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true })
|
||||
}
|
||||
await downloadFile(
|
||||
`https://github.com/mihomo-party-org/mihomo-party-run/releases/download/monitor/${arch}.zip`,
|
||||
tempZip
|
||||
)
|
||||
const zip = new AdmZip(tempZip)
|
||||
const resDir = path.join(cwd, 'extra', 'files')
|
||||
const targetPath = path.join(resDir, 'TrafficMonitor')
|
||||
if (fs.existsSync(targetPath)) {
|
||||
fs.rmSync(targetPath, { recursive: true })
|
||||
}
|
||||
zip.extractAllTo(targetPath, true)
|
||||
|
||||
console.log(`[INFO]: TrafficMonitor finished`)
|
||||
}
|
||||
|
||||
const resolve7zip = () =>
|
||||
resolveResource({
|
||||
file: '7za.exe',
|
||||
@ -282,6 +309,11 @@ const resolveSubstore = () =>
|
||||
downloadURL:
|
||||
'https://github.com/sub-store-org/Sub-Store/releases/latest/download/sub-store.bundle.js'
|
||||
})
|
||||
const resolveHelper = () =>
|
||||
resolveResource({
|
||||
file: 'party.mihomo.helper',
|
||||
downloadURL: `https://github.com/mihomo-party-org/mihomo-party-helper/releases/download/${arch}/party.mihomo.helper`
|
||||
})
|
||||
const resolveSubstoreFrontend = async () => {
|
||||
const tempDir = path.join(TEMP_DIR, 'substore-frontend')
|
||||
const tempZip = path.join(tempDir, 'dist.zip')
|
||||
@ -329,6 +361,7 @@ const tasks = [
|
||||
retry: 5
|
||||
},
|
||||
{ name: 'mmdb', func: resolveMmdb, retry: 5 },
|
||||
{ name: 'metadb', func: resolveMetadb, retry: 5 },
|
||||
{ name: 'geosite', func: resolveGeosite, retry: 5 },
|
||||
{ name: 'geoip', func: resolveGeoIP, retry: 5 },
|
||||
{ name: 'asn', func: resolveASN, retry: 5 },
|
||||
@ -355,6 +388,12 @@ const tasks = [
|
||||
retry: 5,
|
||||
winOnly: true
|
||||
},
|
||||
{
|
||||
name: 'monitor',
|
||||
func: resolveMonitor,
|
||||
retry: 5,
|
||||
winOnly: true
|
||||
},
|
||||
{
|
||||
name: 'substore',
|
||||
func: resolveSubstore,
|
||||
@ -370,6 +409,12 @@ const tasks = [
|
||||
func: resolve7zip,
|
||||
retry: 5,
|
||||
winOnly: true
|
||||
},
|
||||
{
|
||||
name: 'helper',
|
||||
func: resolveHelper,
|
||||
retry: 5,
|
||||
darwinOnly: true
|
||||
}
|
||||
]
|
||||
|
||||
@ -379,6 +424,7 @@ async function runTask() {
|
||||
if (task.winOnly && platform !== 'win32') return runTask()
|
||||
if (task.linuxOnly && platform !== 'linux') return runTask()
|
||||
if (task.unixOnly && platform === 'win32') return runTask()
|
||||
if (task.darwinOnly && platform !== 'darwin') return runTask()
|
||||
|
||||
for (let i = 0; i < task.retry; i++) {
|
||||
try {
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import axios from 'axios'
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
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) {
|
||||
@ -14,8 +16,26 @@ for (const line of changelog.split('\n')) {
|
||||
content += `${line}\n`
|
||||
}
|
||||
}
|
||||
axios.post(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
|
||||
chat_id: '@mihomo_party_channel',
|
||||
|
||||
content += '\n<b>下载地址:</b>\n<b>Windows10/11:</b>\n'
|
||||
content += `安装版:<a href="${downloadUrl}/mihomo-party-windows-${version}-x64-setup.exe">64位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-ia32-setup.exe">32位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-arm64-setup.exe">ARM64</a>\n`
|
||||
content += `便携版:<a href="${downloadUrl}/mihomo-party-windows-${version}-x64-portable.7z">64位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-ia32-portable.7z">32位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-arm64-portable.7z">ARM64</a>\n`
|
||||
content += '\n<b>Windows7/8:</b>\n'
|
||||
content += `安装版:<a href="${downloadUrl}/mihomo-party-win7-${version}-x64-setup.exe">64位</a> | <a href="${downloadUrl}/mihomo-party-win7-${version}-ia32-setup.exe">32位</a>\n`
|
||||
content += `便携版:<a href="${downloadUrl}/mihomo-party-win7-${version}-x64-portable.7z">64位</a> | <a href="${downloadUrl}/mihomo-party-win7-${version}-ia32-portable.7z">32位</a>\n`
|
||||
content += '\n<b>macOS 11+:</b>\n'
|
||||
content += `PKG:<a href="${downloadUrl}/mihomo-party-macos-${version}-x64.pkg
|
||||
">Intel</a> | <a href="${downloadUrl}/mihomo-party-macos-${version}-arm64.pkg">Apple Silicon</a>\n`
|
||||
content += '\n<b>macOS 10.15+:</b>\n'
|
||||
content += `PKG:<a href="${downloadUrl}/mihomo-party-catalina-${version}-x64.pkg
|
||||
">Intel</a> | <a href="${downloadUrl}/mihomo-party-catalina-${version}-arm64.pkg">Apple Silicon</a>\n`
|
||||
content += '\n<b>Linux:</b>\n'
|
||||
content += `DEB:<a href="${downloadUrl}/mihomo-party-linux-${version}-amd64.deb
|
||||
">64位</a> | <a href="${downloadUrl}/mihomo-party-linux-${version}-arm64.deb">ARM64</a>\n`
|
||||
content += `RPM:<a href="${downloadUrl}/mihomo-party-linux-${version}-x86_64.rpm">64位</a> | <a href="${downloadUrl}/mihomo-party-linux-${version}-aarch64.rpm">ARM64</a>`
|
||||
|
||||
await axios.post(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
|
||||
chat_id,
|
||||
text: content,
|
||||
link_preview_options: {
|
||||
is_disabled: false,
|
||||
|
||||
@ -2,11 +2,27 @@ import yaml from 'yaml'
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
|
||||
const pkg = readFileSync('package.json', 'utf-8')
|
||||
const changelog = readFileSync('changelog.md', '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 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)`
|
||||
|
||||
writeFileSync('latest.yml', yaml.stringify(latest))
|
||||
writeFileSync('changelog.md', changelog)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { controledMihomoConfigPath } from '../utils/dirs'
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import yaml from 'yaml'
|
||||
import { getAxios } from '../core/mihomoApi'
|
||||
import { generateProfile } from '../core/factory'
|
||||
import { getAppConfig } from './app'
|
||||
import { defaultControledMihomoConfig } from '../utils/template'
|
||||
@ -52,9 +51,6 @@ export async function patchControledMihomoConfig(patch: Partial<IMihomoConfig>):
|
||||
if (process.platform === 'darwin') {
|
||||
delete controledMihomoConfig?.tun?.device
|
||||
}
|
||||
if (patch['external-controller'] || patch.secret) {
|
||||
await getAxios(true)
|
||||
}
|
||||
await generateProfile()
|
||||
await writeFile(controledMihomoConfigPath(), yaml.stringify(controledMihomoConfig), 'utf-8')
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@ export {
|
||||
getCurrentProfileItem,
|
||||
getProfileItem,
|
||||
getProfileConfig,
|
||||
getFileStr,
|
||||
setFileStr,
|
||||
setProfileConfig,
|
||||
addProfileItem,
|
||||
removeProfileItem,
|
||||
|
||||
@ -75,7 +75,8 @@ export async function createOverride(item: Partial<IOverrideItem>): Promise<IOve
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: mixedPort
|
||||
}
|
||||
},
|
||||
responseType: 'text'
|
||||
})
|
||||
const data = res.data
|
||||
await setOverride(id, newItem.ext, data)
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import { getControledMihomoConfig } from './controledMihomo'
|
||||
import { profileConfigPath, profilePath } from '../utils/dirs'
|
||||
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 from 'axios'
|
||||
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'
|
||||
|
||||
let profileConfig: IProfileConfig // profile.yaml
|
||||
|
||||
@ -91,6 +94,9 @@ export async function removeProfileItem(id: string): Promise<void> {
|
||||
if (shouldRestart) {
|
||||
await restartCore()
|
||||
}
|
||||
if (existsSync(mihomoProfileWorkDir(id))) {
|
||||
await rm(mihomoProfileWorkDir(id), { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentProfileItem(): Promise<IProfileItem> {
|
||||
@ -105,9 +111,11 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
|
||||
name: item.name || (item.type === 'remote' ? 'Remote File' : 'Local File'),
|
||||
type: item.type,
|
||||
url: item.url,
|
||||
substore: item.substore || false,
|
||||
interval: item.interval || 0,
|
||||
override: item.override || [],
|
||||
useProxy: item.useProxy || false,
|
||||
allowFixedInterval: item.allowFixedInterval || false,
|
||||
updated: new Date().getTime()
|
||||
} as IProfileItem
|
||||
switch (newItem.type) {
|
||||
@ -115,18 +123,38 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
|
||||
const { userAgent } = await getAppConfig()
|
||||
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
|
||||
if (!item.url) throw new Error('Empty URL')
|
||||
const res = await axios.get(item.url, {
|
||||
proxy: newItem.useProxy
|
||||
? {
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: mixedPort
|
||||
}
|
||||
: false,
|
||||
headers: {
|
||||
'User-Agent': userAgent || 'clash.meta'
|
||||
let res: AxiosResponse
|
||||
if (newItem.substore) {
|
||||
const urlObj = new URL(`http://127.0.0.1:${subStorePort}${item.url}`)
|
||||
urlObj.searchParams.set('target', 'ClashMeta')
|
||||
urlObj.searchParams.set('noCache', 'true')
|
||||
if (newItem.useProxy) {
|
||||
urlObj.searchParams.set('proxy', `http://127.0.0.1:${mixedPort}`)
|
||||
} else {
|
||||
urlObj.searchParams.delete('proxy')
|
||||
}
|
||||
})
|
||||
res = await axios.get(urlObj.toString(), {
|
||||
headers: {
|
||||
'User-Agent': userAgent || `mihomo.party/v${app.getVersion()} (clash.meta)`
|
||||
},
|
||||
responseType: 'text'
|
||||
})
|
||||
} else {
|
||||
res = await axios.get(item.url, {
|
||||
proxy: newItem.useProxy
|
||||
? {
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: mixedPort
|
||||
}
|
||||
: false,
|
||||
headers: {
|
||||
'User-Agent': userAgent || `mihomo.party/v${app.getVersion()} (clash.meta)`
|
||||
},
|
||||
responseType: 'text'
|
||||
})
|
||||
}
|
||||
|
||||
const data = res.data
|
||||
const headers = res.headers
|
||||
if (headers['content-disposition'] && newItem.name === 'Remote File') {
|
||||
@ -136,7 +164,9 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
|
||||
newItem.home = headers['profile-web-page-url']
|
||||
}
|
||||
if (headers['profile-update-interval']) {
|
||||
newItem.interval = parseInt(headers['profile-update-interval']) * 60
|
||||
if (!item.allowFixedInterval) {
|
||||
newItem.interval = parseInt(headers['profile-update-interval']) * 60
|
||||
}
|
||||
}
|
||||
if (headers['subscription-userinfo']) {
|
||||
newItem.extra = parseSubinfo(headers['subscription-userinfo'])
|
||||
@ -195,3 +225,34 @@ function parseSubinfo(str: string): ISubscriptionUserInfo {
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
function isAbsolutePath(path: string): boolean {
|
||||
return path.startsWith('/') || /^[a-zA-Z]:\\/.test(path)
|
||||
}
|
||||
|
||||
export async function getFileStr(path: string): Promise<string> {
|
||||
const { diffWorkDir = false } = await getAppConfig()
|
||||
const { current } = await getProfileConfig()
|
||||
if (isAbsolutePath(path)) {
|
||||
return await readFile(path, 'utf-8')
|
||||
} else {
|
||||
return await readFile(
|
||||
join(diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), path),
|
||||
'utf-8'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function setFileStr(path: string, content: string): Promise<void> {
|
||||
const { diffWorkDir = false } = await getAppConfig()
|
||||
const { current } = await getProfileConfig()
|
||||
if (isAbsolutePath(path)) {
|
||||
await writeFile(path, content, 'utf-8')
|
||||
} else {
|
||||
await writeFile(
|
||||
join(diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), path),
|
||||
content,
|
||||
'utf-8'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,28 +5,65 @@ import {
|
||||
getProfileItem,
|
||||
getOverride,
|
||||
getOverrideItem,
|
||||
getOverrideConfig
|
||||
getOverrideConfig,
|
||||
getAppConfig
|
||||
} from '../config'
|
||||
import { mihomoWorkConfigPath, overridePath } from '../utils/dirs'
|
||||
import {
|
||||
mihomoProfileWorkDir,
|
||||
mihomoWorkConfigPath,
|
||||
mihomoWorkDir,
|
||||
overridePath
|
||||
} from '../utils/dirs'
|
||||
import yaml from 'yaml'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { copyFile, mkdir, writeFile } from 'fs/promises'
|
||||
import { deepMerge } from '../utils/merge'
|
||||
import vm from 'vm'
|
||||
import { writeFileSync } from 'fs'
|
||||
import { existsSync, writeFileSync } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
let runtimeConfigStr: string
|
||||
let runtimeConfig: IMihomoConfig
|
||||
|
||||
export async function generateProfile(): Promise<void> {
|
||||
const { current } = await getProfileConfig()
|
||||
const { diffWorkDir = false } = await getAppConfig()
|
||||
const currentProfile = await overrideProfile(current, await getProfile(current))
|
||||
const controledMihomoConfig = await getControledMihomoConfig()
|
||||
const profile = deepMerge(currentProfile, controledMihomoConfig)
|
||||
// 确保可以拿到基础日志信息
|
||||
profile['log-level'] = 'info'
|
||||
// 使用 debug 可以调试内核相关问题 `debug/pprof`
|
||||
if (['info', 'debug'].includes(profile['log-level']) === false) {
|
||||
profile['log-level'] = 'info'
|
||||
}
|
||||
runtimeConfig = profile
|
||||
runtimeConfigStr = yaml.stringify(profile)
|
||||
await writeFile(mihomoWorkConfigPath(), runtimeConfigStr)
|
||||
if (diffWorkDir) {
|
||||
await prepareProfileWorkDir(current)
|
||||
}
|
||||
await writeFile(
|
||||
diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work'),
|
||||
runtimeConfigStr
|
||||
)
|
||||
}
|
||||
|
||||
async function prepareProfileWorkDir(current: string | undefined): Promise<void> {
|
||||
if (!existsSync(mihomoProfileWorkDir(current))) {
|
||||
await mkdir(mihomoProfileWorkDir(current), { recursive: true })
|
||||
}
|
||||
const copy = async (file: string): Promise<void> => {
|
||||
const targetPath = path.join(mihomoProfileWorkDir(current), file)
|
||||
const sourcePath = path.join(mihomoWorkDir(), file)
|
||||
if (!existsSync(targetPath) && existsSync(sourcePath)) {
|
||||
await copyFile(sourcePath, targetPath)
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
copy('country.mmdb'),
|
||||
copy('geoip.metadb'),
|
||||
copy('geoip.dat'),
|
||||
copy('geosite.dat'),
|
||||
copy('ASN.mmdb')
|
||||
])
|
||||
}
|
||||
|
||||
async function overrideProfile(
|
||||
@ -92,7 +129,7 @@ function runOverrideScript(
|
||||
log('info', '脚本执行成功')
|
||||
return newProfile
|
||||
} catch (e) {
|
||||
log('exception', `脚本执行失败: ${e}`)
|
||||
log('exception', `脚本执行失败:${e}`)
|
||||
return profile
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
logPath,
|
||||
mihomoCoreDir,
|
||||
mihomoCorePath,
|
||||
mihomoProfileWorkDir,
|
||||
mihomoTestDir,
|
||||
mihomoWorkConfigPath,
|
||||
mihomoWorkDir
|
||||
@ -12,10 +13,11 @@ import { generateProfile } from './factory'
|
||||
import {
|
||||
getAppConfig,
|
||||
getControledMihomoConfig,
|
||||
getProfileConfig,
|
||||
patchAppConfig,
|
||||
patchControledMihomoConfig
|
||||
} from '../config'
|
||||
import { app, dialog, ipcMain, net, safeStorage } from 'electron'
|
||||
import { app, dialog, ipcMain, net } from 'electron'
|
||||
import {
|
||||
startMihomoTraffic,
|
||||
startMihomoConnections,
|
||||
@ -32,48 +34,55 @@ import { readFile, rm, writeFile } from 'fs/promises'
|
||||
import { promisify } from 'util'
|
||||
import { mainWindow } from '..'
|
||||
import path from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import os from 'os'
|
||||
import { createWriteStream, existsSync } from 'fs'
|
||||
import { uploadRuntimeConfig } from '../resolve/gistApi'
|
||||
import { startMonitor } from '../resolve/trafficMonitor'
|
||||
import i18next from '../../shared/i18n'
|
||||
|
||||
chokidar.watch(path.join(mihomoCoreDir(), 'meta-update'), {}).on('unlinkDir', async () => {
|
||||
try {
|
||||
await stopCore(true)
|
||||
await startCore()
|
||||
} catch (e) {
|
||||
dialog.showErrorBox('内核启动出错', `${e}`)
|
||||
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`)
|
||||
}
|
||||
})
|
||||
|
||||
export const mihomoIpcPath =
|
||||
process.platform === 'win32' ? '\\\\.\\pipe\\MihomoParty\\mihomo' : '/tmp/mihomo-party.sock'
|
||||
const ctlParam = process.platform === 'win32' ? '-ext-ctl-pipe' : '-ext-ctl-unix'
|
||||
|
||||
let setPublicDNSTimer: NodeJS.Timeout | null = null
|
||||
let recoverDNSTimer: NodeJS.Timeout | null = null
|
||||
let child: ChildProcess
|
||||
let retry = 10
|
||||
|
||||
export async function startCore(detached = false): Promise<Promise<void>[]> {
|
||||
const { core = 'mihomo', autoSetDNS = true, encryptedPassword } = await getAppConfig()
|
||||
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'))
|
||||
try {
|
||||
process.kill(pid, 'SIGINT')
|
||||
} catch {
|
||||
if (process.platform !== 'win32' && encryptedPassword && isEncryptionAvailable()) {
|
||||
const execPromise = promisify(exec)
|
||||
const password = safeStorage.decryptString(Buffer.from(encryptedPassword))
|
||||
try {
|
||||
await execPromise(`echo "${password}" | sudo -S kill ${pid}`)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// ignore
|
||||
} finally {
|
||||
await rm(path.join(dataDir(), 'core.pid'))
|
||||
}
|
||||
}
|
||||
|
||||
const { current } = await getProfileConfig()
|
||||
const { tun } = await getControledMihomoConfig()
|
||||
const corePath = mihomoCorePath(core)
|
||||
await autoGrantCorePermition(corePath)
|
||||
await generateProfile()
|
||||
await checkProfile()
|
||||
await stopCore()
|
||||
@ -86,10 +95,26 @@ export async function startCore(detached = false): Promise<Promise<void>[]> {
|
||||
})
|
||||
}
|
||||
}
|
||||
child = spawn(corePath, ['-d', mihomoWorkDir()], {
|
||||
detached: detached,
|
||||
stdio: detached ? 'ignore' : undefined
|
||||
})
|
||||
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)
|
||||
}
|
||||
child = spawn(
|
||||
corePath,
|
||||
['-d', diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), ctlParam, mihomoIpcPath],
|
||||
{
|
||||
detached: detached,
|
||||
stdio: detached ? 'ignore' : undefined,
|
||||
env: env
|
||||
}
|
||||
)
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
os.setPriority(child.pid, os.constants.priority[mihomoCpuPriority])
|
||||
}
|
||||
if (detached) {
|
||||
child.unref()
|
||||
return new Promise((resolve) => {
|
||||
@ -108,36 +133,35 @@ export async function startCore(detached = false): Promise<Promise<void>[]> {
|
||||
await stopCore()
|
||||
}
|
||||
})
|
||||
child.stdout?.on('data', async (data) => {
|
||||
await writeFile(logPath(), data, { flag: 'a' })
|
||||
})
|
||||
child.stdout?.pipe(stdout)
|
||||
child.stderr?.pipe(stderr)
|
||||
return new Promise((resolve, reject) => {
|
||||
child.stdout?.on('data', async (data) => {
|
||||
if (data.toString().includes('configure tun interface: operation not permitted')) {
|
||||
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('虚拟网卡启动失败, 请尝试手动授予内核权限')
|
||||
reject(i18next.t('tun.error.tunPermissionDenied'))
|
||||
}
|
||||
if (data.toString().includes('External controller listen error')) {
|
||||
if (retry) {
|
||||
retry--
|
||||
try {
|
||||
resolve(await startCore())
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
} else {
|
||||
reject('内核连接失败, 请尝试修改外部控制端口或重启电脑')
|
||||
}
|
||||
|
||||
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'))
|
||||
}
|
||||
if (data.toString().includes('RESTful API listening at')) {
|
||||
|
||||
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().includes('Start initial Compatible provider default')) {
|
||||
if (data.toString().toLowerCase().includes('start initial compatible provider default')) {
|
||||
try {
|
||||
mainWindow?.webContents.send('coreRestart')
|
||||
mainWindow?.webContents.send('groupsUpdated')
|
||||
mainWindow?.webContents.send('rulesUpdated')
|
||||
await uploadRuntimeConfig()
|
||||
} catch {
|
||||
// ignore
|
||||
@ -183,7 +207,7 @@ export async function restartCore(): Promise<void> {
|
||||
try {
|
||||
await startCore()
|
||||
} catch (e) {
|
||||
dialog.showErrorBox('内核启动出错', `${e}`)
|
||||
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
@ -194,21 +218,36 @@ export async function keepCoreAlive(): Promise<void> {
|
||||
await writeFile(path.join(dataDir(), 'core.pid'), child.pid.toString())
|
||||
}
|
||||
} catch (e) {
|
||||
dialog.showErrorBox('内核启动出错', `${e}`)
|
||||
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function quitWithoutCore(): Promise<void> {
|
||||
await keepCoreAlive()
|
||||
await startMonitor(true)
|
||||
app.exit()
|
||||
}
|
||||
|
||||
async function checkProfile(): Promise<void> {
|
||||
const { core = 'mihomo' } = await getAppConfig()
|
||||
const {
|
||||
core = 'mihomo',
|
||||
diffWorkDir = false,
|
||||
skipSafePathCheck = false
|
||||
} = await getAppConfig()
|
||||
const { current } = await getProfileConfig()
|
||||
const corePath = mihomoCorePath(core)
|
||||
const execFilePromise = promisify(execFile)
|
||||
const env = {
|
||||
SKIP_SAFE_PATH_CHECK: String(skipSafePathCheck)
|
||||
}
|
||||
try {
|
||||
await execFilePromise(corePath, ['-t', '-f', mihomoWorkConfigPath(), '-d', mihomoTestDir()])
|
||||
await execFilePromise(corePath, [
|
||||
'-t',
|
||||
'-f',
|
||||
diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work'),
|
||||
'-d',
|
||||
mihomoTestDir()
|
||||
], { env })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'stdout' in error) {
|
||||
const { stdout } = error as { stdout: string }
|
||||
@ -216,71 +255,45 @@ async function checkProfile(): Promise<void> {
|
||||
.split('\n')
|
||||
.filter((line) => line.includes('level=error'))
|
||||
.map((line) => line.split('level=error')[1])
|
||||
throw new Error(`Profile Check Failed:\n${errorLines.join('\n')}`)
|
||||
throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}:\n${errorLines.join('\n')}`)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function autoGrantCorePermition(corePath: string): Promise<void> {
|
||||
if (process.platform === 'win32') return
|
||||
const { encryptedPassword } = await getAppConfig()
|
||||
const execPromise = promisify(exec)
|
||||
if (encryptedPassword && isEncryptionAvailable()) {
|
||||
try {
|
||||
const password = safeStorage.decryptString(Buffer.from(encryptedPassword))
|
||||
if (process.platform === 'linux') {
|
||||
await execPromise(`echo "${password}" | sudo -S chown root:root "${corePath}"`)
|
||||
await execPromise(`echo "${password}" | sudo -S chmod +sx "${corePath}"`)
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
await execPromise(`echo "${password}" | sudo -S chown root:admin "${corePath}"`)
|
||||
await execPromise(`echo "${password}" | sudo -S chmod +sx "${corePath}"`)
|
||||
}
|
||||
} catch (error) {
|
||||
patchAppConfig({ encryptedPassword: undefined })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function manualGrantCorePermition(password?: string): Promise<void> {
|
||||
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 execPromise(`echo "${password}" | sudo -S chown root:root "${corePath}"`)
|
||||
await execPromise(`echo "${password}" | sudo -S chmod +sx "${corePath}"`)
|
||||
await execFilePromise('pkexec', [
|
||||
'bash',
|
||||
'-c',
|
||||
`chown root:root "${corePath}" && chmod +sx "${corePath}"`
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
export function isEncryptionAvailable(): boolean {
|
||||
return safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
|
||||
export async function getDefaultDevice(password?: string): Promise<string> {
|
||||
export async function getDefaultDevice(): Promise<string> {
|
||||
const execPromise = promisify(exec)
|
||||
let sudo = ''
|
||||
if (password) sudo = `echo "${password}" | sudo -S `
|
||||
const { stdout: deviceOut } = await execPromise(`${sudo}route -n get default`)
|
||||
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(password?: string): Promise<string> {
|
||||
async function getDefaultService(): Promise<string> {
|
||||
const execPromise = promisify(exec)
|
||||
let sudo = ''
|
||||
if (password) sudo = `echo "${password}" | sudo -S `
|
||||
const device = await getDefaultDevice(password)
|
||||
const { stdout: order } = await execPromise(`${sudo}networksetup -listnetworkserviceorder`)
|
||||
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')) {
|
||||
@ -291,12 +304,10 @@ async function getDefaultService(password?: string): Promise<string> {
|
||||
throw new Error('Get service failed')
|
||||
}
|
||||
|
||||
async function getOriginDNS(password?: string): Promise<void> {
|
||||
async function getOriginDNS(): Promise<void> {
|
||||
const execPromise = promisify(exec)
|
||||
let sudo = ''
|
||||
if (password) sudo = `echo "${password}" | sudo -S `
|
||||
const service = await getDefaultService(password)
|
||||
const { stdout: dns } = await execPromise(`${sudo}networksetup -getdnsservers "${service}"`)
|
||||
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 {
|
||||
@ -304,25 +315,19 @@ async function getOriginDNS(password?: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function setDNS(dns: string, password?: string): Promise<void> {
|
||||
const service = await getDefaultService(password)
|
||||
let sudo = ''
|
||||
if (password) sudo = `echo "${password}" | sudo -S `
|
||||
async function setDNS(dns: string): Promise<void> {
|
||||
const service = await getDefaultService()
|
||||
const execPromise = promisify(exec)
|
||||
await execPromise(`${sudo}networksetup -setdnsservers "${service}" ${dns}`)
|
||||
await execPromise(`networksetup -setdnsservers "${service}" ${dns}`)
|
||||
}
|
||||
|
||||
async function setPublicDNS(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return
|
||||
if (net.isOnline()) {
|
||||
const { originDNS, encryptedPassword } = await getAppConfig()
|
||||
const { originDNS } = await getAppConfig()
|
||||
if (!originDNS) {
|
||||
let password: string | undefined
|
||||
if (encryptedPassword && isEncryptionAvailable()) {
|
||||
password = safeStorage.decryptString(Buffer.from(encryptedPassword))
|
||||
}
|
||||
await getOriginDNS(password)
|
||||
await setDNS('223.5.5.5', password)
|
||||
await getOriginDNS()
|
||||
await setDNS('223.5.5.5')
|
||||
}
|
||||
} else {
|
||||
if (setPublicDNSTimer) clearTimeout(setPublicDNSTimer)
|
||||
@ -333,13 +338,9 @@ async function setPublicDNS(): Promise<void> {
|
||||
async function recoverDNS(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return
|
||||
if (net.isOnline()) {
|
||||
const { originDNS, encryptedPassword } = await getAppConfig()
|
||||
const { originDNS } = await getAppConfig()
|
||||
if (originDNS) {
|
||||
let password: string | undefined
|
||||
if (encryptedPassword && isEncryptionAvailable()) {
|
||||
password = safeStorage.decryptString(Buffer.from(encryptedPassword))
|
||||
}
|
||||
await setDNS(originDNS, password)
|
||||
await setDNS(originDNS)
|
||||
await patchAppConfig({ originDNS: undefined })
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -5,6 +5,8 @@ import WebSocket from 'ws'
|
||||
import { tray } from '../resolve/tray'
|
||||
import { calcTraffic } from '../utils/calc'
|
||||
import { getRuntimeConfig } from './factory'
|
||||
import { floatingWindow } from '../resolve/floatingWindow'
|
||||
import { mihomoIpcPath } from './manager'
|
||||
|
||||
let axiosIns: AxiosInstance = null!
|
||||
let mihomoTrafficWs: WebSocket | null = null
|
||||
@ -18,15 +20,10 @@ let connectionsRetry = 10
|
||||
|
||||
export const getAxios = async (force: boolean = false): Promise<AxiosInstance> => {
|
||||
if (axiosIns && !force) return axiosIns
|
||||
const controledMihomoConfig = await getControledMihomoConfig()
|
||||
let server = controledMihomoConfig['external-controller']
|
||||
const secret = controledMihomoConfig.secret ?? ''
|
||||
if (server?.startsWith(':')) server = `127.0.0.1${server}`
|
||||
|
||||
axiosIns = axios.create({
|
||||
baseURL: `http://${server}`,
|
||||
proxy: false,
|
||||
headers: secret ? { Authorization: `Bearer ${secret}` } : {},
|
||||
baseURL: `http://localhost`,
|
||||
socketPath: mihomoIpcPath,
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
@ -79,6 +76,8 @@ export const mihomoProxies = async (): Promise<IMihomoProxies> => {
|
||||
}
|
||||
|
||||
export const mihomoGroups = async (): Promise<IMihomoMixedGroup[]> => {
|
||||
const { mode = 'rule' } = await getControledMihomoConfig()
|
||||
if (mode === 'direct') return []
|
||||
const proxies = await mihomoProxies()
|
||||
const runtime = await getRuntimeConfig()
|
||||
const groups: IMihomoMixedGroup[] = []
|
||||
@ -98,6 +97,10 @@ export const mihomoGroups = async (): Promise<IMihomoMixedGroup[]> => {
|
||||
groups.push({ ...newGlobal, all: newAll })
|
||||
}
|
||||
}
|
||||
if (mode === 'global') {
|
||||
const global = groups.findIndex((group) => group.name === 'GLOBAL')
|
||||
groups.unshift(groups.splice(global, 1)[0])
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
@ -126,6 +129,11 @@ export const mihomoChangeProxy = async (group: string, proxy: string): Promise<I
|
||||
return await instance.put(`/proxies/${encodeURIComponent(group)}`, { name: proxy })
|
||||
}
|
||||
|
||||
export const mihomoUnfixedProxy = async (group: string): Promise<IMihomoProxy> => {
|
||||
const instance = await getAxios()
|
||||
return await instance.delete(`/proxies/${encodeURIComponent(group)}`)
|
||||
}
|
||||
|
||||
export const mihomoUpgradeGeo = async (): Promise<void> => {
|
||||
const instance = await getAxios()
|
||||
return await instance.post('/configs/geo')
|
||||
@ -137,7 +145,7 @@ export const mihomoProxyDelay = async (proxy: string, url?: string): Promise<IMi
|
||||
const instance = await getAxios()
|
||||
return await instance.get(`/proxies/${encodeURIComponent(proxy)}/delay`, {
|
||||
params: {
|
||||
url: url || delayTestUrl || 'https://www.gstatic.com/generate_204',
|
||||
url: url || delayTestUrl || 'http://www.gstatic.com/generate_204',
|
||||
timeout: delayTestTimeout || 5000
|
||||
}
|
||||
})
|
||||
@ -149,7 +157,7 @@ export const mihomoGroupDelay = async (group: string, url?: string): Promise<IMi
|
||||
const instance = await getAxios()
|
||||
return await instance.get(`/group/${encodeURIComponent(group)}/delay`, {
|
||||
params: {
|
||||
url: url || delayTestUrl || 'https://www.gstatic.com/generate_204',
|
||||
url: url || delayTestUrl || 'http://www.gstatic.com/generate_204',
|
||||
timeout: delayTestTimeout || 5000
|
||||
}
|
||||
})
|
||||
@ -175,13 +183,7 @@ export const stopMihomoTraffic = (): void => {
|
||||
}
|
||||
|
||||
const mihomoTraffic = async (): Promise<void> => {
|
||||
const controledMihomoConfig = await getControledMihomoConfig()
|
||||
let server = controledMihomoConfig['external-controller']
|
||||
const secret = controledMihomoConfig.secret ?? ''
|
||||
if (server?.startsWith(':')) server = `127.0.0.1${server}`
|
||||
stopMihomoTraffic()
|
||||
|
||||
mihomoTrafficWs = new WebSocket(`ws://${server}/traffic?token=${encodeURIComponent(secret)}`)
|
||||
mihomoTrafficWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/traffic`)
|
||||
|
||||
mihomoTrafficWs.onmessage = async (e): Promise<void> => {
|
||||
const data = e.data as string
|
||||
@ -197,6 +199,7 @@ const mihomoTraffic = async (): Promise<void> => {
|
||||
`${calcTraffic(json.down)}/s`.padStart(9)
|
||||
)
|
||||
}
|
||||
floatingWindow?.webContents.send('mihomoTraffic', json)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@ -232,13 +235,7 @@ export const stopMihomoMemory = (): void => {
|
||||
}
|
||||
|
||||
const mihomoMemory = async (): Promise<void> => {
|
||||
const controledMihomoConfig = await getControledMihomoConfig()
|
||||
let server = controledMihomoConfig['external-controller']
|
||||
const secret = controledMihomoConfig.secret ?? ''
|
||||
if (server?.startsWith(':')) server = `127.0.0.1${server}`
|
||||
stopMihomoMemory()
|
||||
|
||||
mihomoMemoryWs = new WebSocket(`ws://${server}/memory?token=${encodeURIComponent(secret)}`)
|
||||
mihomoMemoryWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/memory`)
|
||||
|
||||
mihomoMemoryWs.onmessage = (e): void => {
|
||||
const data = e.data as string
|
||||
@ -280,15 +277,9 @@ export const stopMihomoLogs = (): void => {
|
||||
}
|
||||
|
||||
const mihomoLogs = async (): Promise<void> => {
|
||||
const controledMihomoConfig = await getControledMihomoConfig()
|
||||
const { secret = '', 'log-level': level = 'info' } = controledMihomoConfig
|
||||
let { 'external-controller': server } = controledMihomoConfig
|
||||
if (server?.startsWith(':')) server = `127.0.0.1${server}`
|
||||
stopMihomoLogs()
|
||||
const { 'log-level': logLevel = 'info' } = await getControledMihomoConfig()
|
||||
|
||||
mihomoLogsWs = new WebSocket(
|
||||
`ws://${server}/logs?token=${encodeURIComponent(secret)}&level=${level}`
|
||||
)
|
||||
mihomoLogsWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/logs?level=${logLevel}`)
|
||||
|
||||
mihomoLogsWs.onmessage = (e): void => {
|
||||
const data = e.data as string
|
||||
@ -330,15 +321,7 @@ export const stopMihomoConnections = (): void => {
|
||||
}
|
||||
|
||||
const mihomoConnections = async (): Promise<void> => {
|
||||
const controledMihomoConfig = await getControledMihomoConfig()
|
||||
let server = controledMihomoConfig['external-controller']
|
||||
const secret = controledMihomoConfig.secret ?? ''
|
||||
if (server?.startsWith(':')) server = `127.0.0.1${server}`
|
||||
stopMihomoConnections()
|
||||
|
||||
mihomoConnectionsWs = new WebSocket(
|
||||
`ws://${server}/connections?token=${encodeURIComponent(secret)}`
|
||||
)
|
||||
mihomoConnectionsWs = new WebSocket(`ws+unix:${mihomoIpcPath}:/connections`)
|
||||
|
||||
mihomoConnectionsWs.onmessage = (e): void => {
|
||||
const data = e.data as string
|
||||
|
||||
@ -5,13 +5,13 @@ 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`)
|
||||
const res = await axios.get(`${baseUrl}/api/subs`, { responseType: 'json' })
|
||||
return res.data.data as ISubStoreSub[]
|
||||
}
|
||||
|
||||
export async function subStoreCollections(): Promise<ISubStoreSub[]> {
|
||||
const { useCustomSubStore = false, customSubStoreUrl = '' } = await getAppConfig()
|
||||
const baseUrl = useCustomSubStore ? customSubStoreUrl : `http://127.0.0.1:${subStorePort}`
|
||||
const res = await axios.get(`${baseUrl}/api/collections`)
|
||||
const res = await axios.get(`${baseUrl}/api/collections`, { responseType: 'json' })
|
||||
return res.data.data as ISubStoreSub[]
|
||||
}
|
||||
|
||||
@ -1,28 +1,59 @@
|
||||
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 } from 'electron'
|
||||
import { addProfileItem, getAppConfig } from './config'
|
||||
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 } from './resolve/tray'
|
||||
import { createTray, hideDockIcon, showDockIcon } from './resolve/tray'
|
||||
import { init } from './utils/init'
|
||||
import { join } from 'path'
|
||||
import { initShortcut } from './resolve/shortcut'
|
||||
import { execSync } from 'child_process'
|
||||
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 { taskDir } from './utils/dirs'
|
||||
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
|
||||
|
||||
try {
|
||||
const stats = await stat(userDataPath)
|
||||
const currentUid = process.getuid?.() || 0
|
||||
|
||||
if (stats.uid === 0 && currentUid !== 0) {
|
||||
const execPromise = promisify(exec)
|
||||
const username = process.env.USER || process.env.LOGNAME
|
||||
if (username) {
|
||||
await execPromise(`chown -R "${username}:staff" "${userDataPath}"`)
|
||||
await execPromise(`chmod -R u+rwX "${userDataPath}"`)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
let quitTimeout: NodeJS.Timeout | null = null
|
||||
export let mainWindow: BrowserWindow | null = null
|
||||
if (process.platform === 'win32' && !is.dev) {
|
||||
|
||||
if (process.platform === 'win32' && !is.dev && !process.argv.includes('noadmin')) {
|
||||
try {
|
||||
createElevateTask()
|
||||
} catch (e) {
|
||||
} catch (createError) {
|
||||
try {
|
||||
if (process.argv.slice(1).length > 0) {
|
||||
writeFileSync(path.join(taskDir(), 'param.txt'), process.argv.slice(1).join(' '))
|
||||
@ -32,23 +63,67 @@ if (process.platform === 'win32' && !is.dev) {
|
||||
if (!existsSync(path.join(taskDir(), 'mihomo-party-run.exe'))) {
|
||||
throw new Error('mihomo-party-run.exe not found')
|
||||
} else {
|
||||
execSync('schtasks /run /tn mihomo-party-run')
|
||||
execSync('%SystemRoot%\\System32\\schtasks.exe /run /tn mihomo-party-run')
|
||||
}
|
||||
} catch (e) {
|
||||
dialog.showErrorBox('首次启动请以管理员权限运行', '首次启动请以管理员权限运行')
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
async function initApp(): Promise<void> {
|
||||
await fixUserDataPermissions()
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
initApp()
|
||||
.then(() => {
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore permission fix errors
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
export function customRelaunch(): void {
|
||||
const script = `while kill -0 ${process.pid} 2>/dev/null; do
|
||||
sleep 0.1
|
||||
done
|
||||
${process.argv.join(' ')} & disown
|
||||
exit
|
||||
`
|
||||
spawn('sh', ['-c', `"${script}"`], {
|
||||
shell: true,
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
app.relaunch = customRelaunch
|
||||
}
|
||||
|
||||
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')
|
||||
@ -68,32 +143,45 @@ app.on('open-url', async (_event, url) => {
|
||||
showMainWindow()
|
||||
await handleDeepLink(url)
|
||||
})
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', (e) => {
|
||||
e.preventDefault()
|
||||
// if (process.platform !== 'darwin') {
|
||||
// app.quit()
|
||||
// }
|
||||
})
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
await stopCore()
|
||||
app.on('before-quit', async (e) => {
|
||||
e.preventDefault()
|
||||
triggerSysProxy(false)
|
||||
await stopCore()
|
||||
app.exit()
|
||||
})
|
||||
|
||||
powerMonitor.on('shutdown', async () => {
|
||||
triggerSysProxy(false)
|
||||
await stopCore()
|
||||
app.exit()
|
||||
})
|
||||
|
||||
// 获取系统语言
|
||||
function getSystemLanguage(): 'zh-CN' | 'en-US' {
|
||||
const locale = app.getLocale()
|
||||
return locale.startsWith('zh') ? 'zh-CN' : 'en-US'
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(async () => {
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId('party.mihomo.app')
|
||||
|
||||
try {
|
||||
const appConfig = await getAppConfig()
|
||||
// 如果配置中没有语言设置,则使用系统语言
|
||||
if (!appConfig.language) {
|
||||
const systemLanguage = getSystemLanguage()
|
||||
await patchAppConfig({ language: systemLanguage })
|
||||
appConfig.language = systemLanguage
|
||||
}
|
||||
await initI18n({ lng: appConfig.language })
|
||||
await initPromise
|
||||
} catch (e) {
|
||||
dialog.showErrorBox('应用初始化失败', `${e}`)
|
||||
dialog.showErrorBox(i18next.t('common.error.initFailed'), `${e}`)
|
||||
app.quit()
|
||||
}
|
||||
try {
|
||||
@ -102,17 +190,29 @@ app.whenReady().then(async () => {
|
||||
await initProfileUpdater()
|
||||
})
|
||||
} catch (e) {
|
||||
dialog.showErrorBox('内核启动出错', `${e}`)
|
||||
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`)
|
||||
}
|
||||
try {
|
||||
await startMonitor()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
const { showFloatingWindow: showFloating = false, disableTray = false } = await getAppConfig()
|
||||
registerIpcMainHandlers()
|
||||
await createWindow()
|
||||
await createTray()
|
||||
if (showFloating) {
|
||||
showFloatingWindow()
|
||||
}
|
||||
if (!disableTray) {
|
||||
await createTray()
|
||||
}
|
||||
await initShortcut()
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
@ -131,7 +231,7 @@ async function handleDeepLink(url: string): Promise<void> {
|
||||
const profileUrl = urlObj.searchParams.get('url')
|
||||
const profileName = urlObj.searchParams.get('name')
|
||||
if (!profileUrl) {
|
||||
throw new Error('缺少参数 url')
|
||||
throw new Error(i18next.t('profiles.error.urlParamMissing'))
|
||||
}
|
||||
await addProfileItem({
|
||||
type: 'remote',
|
||||
@ -139,10 +239,10 @@ async function handleDeepLink(url: string): Promise<void> {
|
||||
url: profileUrl
|
||||
})
|
||||
mainWindow?.webContents.send('profileConfigUpdated')
|
||||
new Notification({ title: '订阅导入成功' }).show()
|
||||
new Notification({ title: i18next.t('profiles.notification.importSuccess') }).show()
|
||||
break
|
||||
} catch (e) {
|
||||
dialog.showErrorBox('订阅导入失败', `${url}\n${e}`)
|
||||
dialog.showErrorBox(i18next.t('profiles.error.importFailed'), `${url}\n${e}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -152,7 +252,8 @@ export async function createWindow(): Promise<void> {
|
||||
const { useWindowFrame = false } = await getAppConfig()
|
||||
const mainWindowState = windowStateKeeper({
|
||||
defaultWidth: 800,
|
||||
defaultHeight: 600
|
||||
defaultHeight: 600,
|
||||
file: 'window-state.json'
|
||||
})
|
||||
// https://github.com/electron/electron/issues/16521#issuecomment-582955104
|
||||
Menu.setApplicationMenu(null)
|
||||
@ -177,7 +278,8 @@ export async function createWindow(): Promise<void> {
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
spellcheck: false,
|
||||
sandbox: false
|
||||
sandbox: false,
|
||||
devTools: true
|
||||
}
|
||||
})
|
||||
mainWindowState.manage(mainWindow)
|
||||
@ -207,10 +309,21 @@ export async function createWindow(): Promise<void> {
|
||||
mainWindow?.webContents.reload()
|
||||
})
|
||||
|
||||
mainWindow.on('show', () => {
|
||||
showDockIcon()
|
||||
})
|
||||
|
||||
mainWindow.on('close', async (event) => {
|
||||
event.preventDefault()
|
||||
mainWindow?.hide()
|
||||
const { autoQuitWithoutCore = false, autoQuitWithoutCoreDelay = 60 } = await getAppConfig()
|
||||
const {
|
||||
autoQuitWithoutCore = false,
|
||||
autoQuitWithoutCoreDelay = 60,
|
||||
useDockIcon = true
|
||||
} = await getAppConfig()
|
||||
if (!useDockIcon) {
|
||||
hideDockIcon()
|
||||
}
|
||||
if (autoQuitWithoutCore) {
|
||||
if (quitTimeout) {
|
||||
clearTimeout(quitTimeout)
|
||||
@ -221,11 +334,29 @@ export async function createWindow(): Promise<void> {
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('resized', () => {
|
||||
if (mainWindow) mainWindowState.saveState(mainWindow)
|
||||
})
|
||||
|
||||
mainWindow.on('move', () => {
|
||||
if (mainWindow) mainWindowState.saveState(mainWindow)
|
||||
})
|
||||
|
||||
mainWindow.on('session-end', async () => {
|
||||
triggerSysProxy(false)
|
||||
await stopCore()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
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']) {
|
||||
@ -235,6 +366,14 @@ export async function createWindow(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerMainWindow(): void {
|
||||
if (mainWindow?.isVisible()) {
|
||||
closeMainWindow()
|
||||
} else {
|
||||
showMainWindow()
|
||||
}
|
||||
}
|
||||
|
||||
export function showMainWindow(): void {
|
||||
if (mainWindow) {
|
||||
if (quitTimeout) {
|
||||
@ -244,3 +383,9 @@ export function showMainWindow(): void {
|
||||
mainWindow.focusOnWebView()
|
||||
}
|
||||
}
|
||||
|
||||
export function closeMainWindow(): void {
|
||||
if (mainWindow) {
|
||||
mainWindow.close()
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,11 +3,11 @@ import yaml from 'yaml'
|
||||
import { app, shell } from 'electron'
|
||||
import { getControledMihomoConfig } from '../config'
|
||||
import { dataDir, exeDir, exePath, isPortable, resourcesFilesDir } from '../utils/dirs'
|
||||
import { rm, writeFile } from 'fs/promises'
|
||||
import { copyFile, rm, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import os from 'os'
|
||||
import { exec, spawn } from 'child_process'
|
||||
import { exec, execSync, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
export async function checkUpdate(): Promise<IAppVersion | undefined> {
|
||||
@ -20,7 +20,8 @@ export async function checkUpdate(): Promise<IAppVersion | undefined> {
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: mixedPort
|
||||
}
|
||||
},
|
||||
responseType: 'text'
|
||||
}
|
||||
)
|
||||
const latest = yaml.parse(res.data, { merge: true }) as IAppVersion
|
||||
@ -39,8 +40,8 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
|
||||
'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.dmg`,
|
||||
'darwin-arm64': `mihomo-party-macos-${version}-arm64.dmg`
|
||||
'darwin-x64': `mihomo-party-macos-${version}-x64.pkg`,
|
||||
'darwin-arm64': `mihomo-party-macos-${version}-arm64.pkg`
|
||||
}
|
||||
let file = fileMap[`${process.platform}-${process.arch}`]
|
||||
if (isPortable()) {
|
||||
@ -52,6 +53,14 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
|
||||
if (process.platform === 'win32' && parseInt(os.release()) < 10) {
|
||||
file = file.replace('windows', 'win7')
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
const productVersion = execSync('sw_vers -productVersion', { encoding: 'utf8' })
|
||||
.toString()
|
||||
.trim()
|
||||
if (parseInt(productVersion) < 11) {
|
||||
file = file.replace('macos', 'catalina')
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (!existsSync(path.join(dataDir(), file))) {
|
||||
const res = await axios.get(`${baseUrl}${file}`, {
|
||||
@ -74,9 +83,13 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
|
||||
}).unref()
|
||||
}
|
||||
if (file.endsWith('.7z')) {
|
||||
await copyFile(path.join(resourcesFilesDir(), '7za.exe'), path.join(dataDir(), '7za.exe'))
|
||||
spawn(
|
||||
path.join(resourcesFilesDir(), '7za.exe'),
|
||||
['x', `-o"${exeDir()}"`, '-y', path.join(dataDir(), file)],
|
||||
'cmd',
|
||||
[
|
||||
'/C',
|
||||
`"timeout /t 2 /nobreak >nul && "${path.join(dataDir(), '7za.exe')}" x -o"${exeDir()}" -y "${path.join(dataDir(), file)}" & start "" "${exePath()}""`
|
||||
],
|
||||
{
|
||||
shell: true,
|
||||
detached: true
|
||||
@ -84,23 +97,12 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
|
||||
).unref()
|
||||
app.quit()
|
||||
}
|
||||
if (file.endsWith('.dmg')) {
|
||||
if (file.endsWith('.pkg')) {
|
||||
try {
|
||||
const execPromise = promisify(exec)
|
||||
const name = exePath().split('.app')[0].replace('/Applications/', '')
|
||||
await execPromise(
|
||||
`hdiutil attach "${path.join(dataDir(), file)}" -mountpoint "/Volumes/mihomo-party" -nobrowse`
|
||||
)
|
||||
try {
|
||||
await execPromise(`mv /Applications/${name}.app /tmp`)
|
||||
await execPromise('cp -R "/Volumes/mihomo-party/mihomo-party.app" /Applications/')
|
||||
await execPromise(`rm -rf /tmp/${name}.app`)
|
||||
} catch (e) {
|
||||
await execPromise(`mv /tmp/${name}.app /Applications`)
|
||||
throw e
|
||||
} finally {
|
||||
await execPromise('hdiutil detach "/Volumes/mihomo-party"')
|
||||
}
|
||||
const shell = `installer -pkg ${path.join(dataDir(), file).replace(' ', '\\\\ ')} -target /`
|
||||
const command = `do shell script "${shell}" with administrator privileges`
|
||||
await execPromise(`osascript -e '${command}'`)
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
} catch {
|
||||
|
||||
@ -9,12 +9,19 @@ import {
|
||||
overrideDir,
|
||||
profileConfigPath,
|
||||
profilesDir,
|
||||
subStoreDir,
|
||||
themesDir
|
||||
} from '../utils/dirs'
|
||||
|
||||
export async function webdavBackup(): Promise<boolean> {
|
||||
const { createClient } = await import('webdav/dist/node/index.js')
|
||||
const { webdavUrl = '', webdavUsername = '', webdavPassword = '' } = await getAppConfig()
|
||||
const {
|
||||
webdavUrl = '',
|
||||
webdavUsername = '',
|
||||
webdavPassword = '',
|
||||
webdavDir = 'mihomo-party',
|
||||
webdavMaxBackups = 0
|
||||
} = await getAppConfig()
|
||||
const zip = new AdmZip()
|
||||
|
||||
zip.addLocalFile(appConfigPath())
|
||||
@ -24,7 +31,7 @@ export async function webdavBackup(): Promise<boolean> {
|
||||
zip.addLocalFolder(themesDir(), 'themes')
|
||||
zip.addLocalFolder(profilesDir(), 'profiles')
|
||||
zip.addLocalFolder(overrideDir(), 'override')
|
||||
zip.addLocalFolder(overrideDir(), 'substore')
|
||||
zip.addLocalFolder(subStoreDir(), 'substore')
|
||||
const date = new Date()
|
||||
const zipFileName = `${process.platform}_${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip`
|
||||
|
||||
@ -33,36 +40,80 @@ export async function webdavBackup(): Promise<boolean> {
|
||||
password: webdavPassword
|
||||
})
|
||||
try {
|
||||
await client.createDirectory('mihomo-party')
|
||||
await client.createDirectory(webdavDir)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return await client.putFileContents(`mihomo-party/${zipFileName}`, zip.toBuffer())
|
||||
const result = await client.putFileContents(`${webdavDir}/${zipFileName}`, zip.toBuffer())
|
||||
|
||||
if (webdavMaxBackups > 0) {
|
||||
try {
|
||||
const files = await client.getDirectoryContents(webdavDir, { glob: '*.zip' })
|
||||
const fileList = Array.isArray(files) ? files : files.data
|
||||
|
||||
const currentPlatformFiles = fileList.filter((file) => {
|
||||
return file.basename.startsWith(`${process.platform}_`)
|
||||
})
|
||||
|
||||
currentPlatformFiles.sort((a, b) => {
|
||||
const timeA = a.basename.match(/_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.zip$/)?.[1] || ''
|
||||
const timeB = b.basename.match(/_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.zip$/)?.[1] || ''
|
||||
return timeB.localeCompare(timeA)
|
||||
})
|
||||
|
||||
if (currentPlatformFiles.length > webdavMaxBackups) {
|
||||
const filesToDelete = currentPlatformFiles.slice(webdavMaxBackups)
|
||||
|
||||
for (let i = 0; i < filesToDelete.length; i++) {
|
||||
const file = filesToDelete[i]
|
||||
await client.deleteFile(`${webdavDir}/${file.basename}`)
|
||||
|
||||
if (i < filesToDelete.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to clean up old backup files:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function webdavRestore(filename: string): Promise<void> {
|
||||
const { createClient } = await import('webdav/dist/node/index.js')
|
||||
const { webdavUrl = '', webdavUsername = '', webdavPassword = '' } = await getAppConfig()
|
||||
const {
|
||||
webdavUrl = '',
|
||||
webdavUsername = '',
|
||||
webdavPassword = '',
|
||||
webdavDir = 'mihomo-party'
|
||||
} = await getAppConfig()
|
||||
|
||||
const client = createClient(webdavUrl, {
|
||||
username: webdavUsername,
|
||||
password: webdavPassword
|
||||
})
|
||||
const zipData = await client.getFileContents(`mihomo-party/${filename}`)
|
||||
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 = '' } = await getAppConfig()
|
||||
const {
|
||||
webdavUrl = '',
|
||||
webdavUsername = '',
|
||||
webdavPassword = '',
|
||||
webdavDir = 'mihomo-party'
|
||||
} = await getAppConfig()
|
||||
|
||||
const client = createClient(webdavUrl, {
|
||||
username: webdavUsername,
|
||||
password: webdavPassword
|
||||
})
|
||||
const files = await client.getDirectoryContents('mihomo-party', { glob: '*.zip' })
|
||||
const files = await client.getDirectoryContents(webdavDir, { glob: '*.zip' })
|
||||
if (Array.isArray(files)) {
|
||||
return files.map((file) => file.basename)
|
||||
} else {
|
||||
@ -72,11 +123,16 @@ 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 = '' } = await getAppConfig()
|
||||
const {
|
||||
webdavUrl = '',
|
||||
webdavUsername = '',
|
||||
webdavPassword = '',
|
||||
webdavDir = 'mihomo-party'
|
||||
} = await getAppConfig()
|
||||
|
||||
const client = createClient(webdavUrl, {
|
||||
username: webdavUsername,
|
||||
password: webdavPassword
|
||||
})
|
||||
await client.deleteFile(`mihomo-party/${filename}`)
|
||||
await client.deleteFile(`${webdavDir}/${filename}`)
|
||||
}
|
||||
|
||||
90
src/main/resolve/floatingWindow.ts
Normal file
90
src/main/resolve/floatingWindow.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import windowStateKeeper from 'electron-window-state'
|
||||
import { join } from 'path'
|
||||
import { getAppConfig, patchAppConfig } from '../config'
|
||||
import { applyTheme } from './theme'
|
||||
import { buildContextMenu, showTrayIcon } from './tray'
|
||||
|
||||
export let floatingWindow: BrowserWindow | null = null
|
||||
|
||||
async function createFloatingWindow(): Promise<void> {
|
||||
const floatingWindowState = windowStateKeeper({
|
||||
file: 'floating-window-state.json'
|
||||
})
|
||||
const { customTheme = 'default.css' } = await getAppConfig()
|
||||
floatingWindow = new BrowserWindow({
|
||||
width: 120,
|
||||
height: 42,
|
||||
x: floatingWindowState.x,
|
||||
y: floatingWindowState.y,
|
||||
show: false,
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
resizable: false,
|
||||
transparent: true,
|
||||
skipTaskbar: true,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
closable: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
spellcheck: false,
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
floatingWindowState.manage(floatingWindow)
|
||||
floatingWindow.on('ready-to-show', () => {
|
||||
applyTheme(customTheme)
|
||||
floatingWindow?.show()
|
||||
floatingWindow?.setAlwaysOnTop(true, 'screen-saver')
|
||||
})
|
||||
floatingWindow.on('moved', () => {
|
||||
if (floatingWindow) floatingWindowState.saveState(floatingWindow)
|
||||
})
|
||||
ipcMain.on('updateFloatingWindow', () => {
|
||||
if (floatingWindow) {
|
||||
floatingWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
floatingWindow?.webContents.send('appConfigUpdated')
|
||||
}
|
||||
})
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
floatingWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/floating.html`)
|
||||
} else {
|
||||
floatingWindow.loadFile(join(__dirname, '../renderer/floating.html'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function showFloatingWindow(): Promise<void> {
|
||||
if (floatingWindow) {
|
||||
floatingWindow.show()
|
||||
} else {
|
||||
createFloatingWindow()
|
||||
}
|
||||
}
|
||||
|
||||
export async function triggerFloatingWindow(): Promise<void> {
|
||||
if (floatingWindow?.isVisible()) {
|
||||
await patchAppConfig({ showFloatingWindow: false })
|
||||
await closeFloatingWindow()
|
||||
} else {
|
||||
await patchAppConfig({ showFloatingWindow: true })
|
||||
await showFloatingWindow()
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeFloatingWindow(): Promise<void> {
|
||||
if (floatingWindow) {
|
||||
floatingWindow.close()
|
||||
floatingWindow.destroy()
|
||||
floatingWindow = null
|
||||
}
|
||||
await showTrayIcon()
|
||||
await patchAppConfig({ disableTray: false })
|
||||
}
|
||||
|
||||
export async function showContextMenu(): Promise<void> {
|
||||
const menu = await buildContextMenu()
|
||||
menu.popup()
|
||||
}
|
||||
@ -20,7 +20,8 @@ async function listGists(token: string): Promise<GistInfo[]> {
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
port
|
||||
}
|
||||
},
|
||||
responseType: 'json'
|
||||
})
|
||||
return res.data as GistInfo[]
|
||||
}
|
||||
|
||||
@ -1,16 +1,22 @@
|
||||
import { getAppConfig, getControledMihomoConfig } from '../config'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { mihomoWorkDir, resourcesFilesDir, subStoreDir } from '../utils/dirs'
|
||||
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'
|
||||
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'
|
||||
|
||||
export let pacPort: number
|
||||
export let subStorePort: number
|
||||
export let subStoreFrontendPort: number
|
||||
let subStoreFrontendServer: http.Server
|
||||
let subStoreBackendWorker: Worker
|
||||
|
||||
const defaultPacScript = `
|
||||
function FindProxyForURL(url, host) {
|
||||
@ -21,7 +27,6 @@ function FindProxyForURL(url, host) {
|
||||
export function findAvailablePort(startPort: number): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.unref()
|
||||
server.on('error', (err) => {
|
||||
if (startPort <= 65535) {
|
||||
resolve(findAvailablePort(startPort + 1))
|
||||
@ -29,64 +34,165 @@ export function findAvailablePort(startPort: number): Promise<number> {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(startPort, () => {
|
||||
// 端口可用
|
||||
server.on('listening', () => {
|
||||
server.close(() => {
|
||||
resolve(startPort)
|
||||
})
|
||||
})
|
||||
server.listen(startPort, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
let pacServer: http.Server
|
||||
|
||||
export async function startPacServer(): Promise<void> {
|
||||
await stopPacServer()
|
||||
const { sysProxy } = await getAppConfig()
|
||||
const { mode = 'manual', host: cHost, pacScript } = sysProxy
|
||||
if (mode !== 'auto') {
|
||||
return
|
||||
}
|
||||
const host = cHost || '127.0.0.1'
|
||||
let script = pacScript || defaultPacScript
|
||||
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
|
||||
script = script.replaceAll('%mixed-port%', port.toString())
|
||||
pacPort = await findAvailablePort(10000)
|
||||
const server = http
|
||||
pacServer = http
|
||||
.createServer(async (_req, res) => {
|
||||
const {
|
||||
sysProxy: { pacScript }
|
||||
} = await getAppConfig()
|
||||
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
|
||||
let script = pacScript || defaultPacScript
|
||||
script = script.replaceAll('%mixed-port%', port.toString())
|
||||
res.writeHead(200, { 'Content-Type': 'application/x-ns-proxy-autoconfig' })
|
||||
res.end(script)
|
||||
})
|
||||
.listen(pacPort)
|
||||
server.unref()
|
||||
.listen(pacPort, host)
|
||||
}
|
||||
|
||||
export async function startSubStoreServer(): Promise<void> {
|
||||
export async function stopPacServer(): Promise<void> {
|
||||
if (pacServer) {
|
||||
pacServer.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function startSubStoreFrontendServer(): Promise<void> {
|
||||
const { useSubStore = true, subStoreHost = '127.0.0.1' } = await getAppConfig()
|
||||
if (!useSubStore) return
|
||||
await stopSubStoreFrontendServer()
|
||||
subStoreFrontendPort = await findAvailablePort(14122)
|
||||
const app = express()
|
||||
app.use(express.static(path.join(mihomoWorkDir(), 'sub-store-frontend')))
|
||||
subStoreFrontendServer = app.listen(subStoreFrontendPort, subStoreHost)
|
||||
}
|
||||
|
||||
export async function stopSubStoreFrontendServer(): Promise<void> {
|
||||
if (subStoreFrontendServer) {
|
||||
subStoreFrontendServer.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function startSubStoreBackendServer(): Promise<void> {
|
||||
const {
|
||||
useSubStore = true,
|
||||
useCustomSubStore = false,
|
||||
useProxyInSubStore = false,
|
||||
subStoreHost = '127.0.0.1',
|
||||
subStoreBackendSyncCron = '',
|
||||
subStoreBackendDownloadCron = '',
|
||||
subStoreBackendUploadCron = ''
|
||||
} = await getAppConfig()
|
||||
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
|
||||
if (!useSubStore) return
|
||||
if (!subStoreFrontendPort) {
|
||||
subStoreFrontendPort = await findAvailablePort(4000)
|
||||
const app = express()
|
||||
app.use(express.static(path.join(resourcesFilesDir(), 'sub-store-frontend')))
|
||||
app.listen(subStoreFrontendPort)
|
||||
}
|
||||
if (!useCustomSubStore && !subStorePort) {
|
||||
subStorePort = await findAvailablePort(3000)
|
||||
if (!useCustomSubStore) {
|
||||
await stopSubStoreBackendServer()
|
||||
subStorePort = await findAvailablePort(38324)
|
||||
const icon = nativeImage.createFromPath(subStoreIcon)
|
||||
icon.toDataURL()
|
||||
new Worker(path.join(resourcesFilesDir(), 'sub-store.bundle.js'), {
|
||||
env: {
|
||||
SUB_STORE_BACKEND_API_PORT: subStorePort.toString(),
|
||||
SUB_STORE_DATA_BASE_PATH: subStoreDir(),
|
||||
SUB_STORE_BACKEND_CUSTOM_ICON: icon.toDataURL(),
|
||||
SUB_STORE_BACKEND_CUSTOM_NAME: 'Mihomo Party',
|
||||
SUB_STORE_BACKEND_SYNC_CRON: subStoreBackendSyncCron,
|
||||
SUB_STORE_BACKEND_DOWNLOAD_CRON: subStoreBackendDownloadCron,
|
||||
SUB_STORE_BACKEND_UPLOAD_CRON: subStoreBackendUploadCron,
|
||||
SUB_STORE_MMDB_COUNTRY_PATH: path.join(mihomoWorkDir(), 'country.mmdb'),
|
||||
SUB_STORE_MMDB_ASN_PATH: path.join(mihomoWorkDir(), 'ASN.mmdb')
|
||||
}
|
||||
const stdout = createWriteStream(substoreLogPath(), { flags: 'a' })
|
||||
const stderr = createWriteStream(substoreLogPath(), { flags: 'a' })
|
||||
const env = {
|
||||
SUB_STORE_BACKEND_API_PORT: subStorePort.toString(),
|
||||
SUB_STORE_BACKEND_API_HOST: subStoreHost,
|
||||
SUB_STORE_DATA_BASE_PATH: subStoreDir(),
|
||||
SUB_STORE_BACKEND_CUSTOM_ICON: icon.toDataURL(),
|
||||
SUB_STORE_BACKEND_CUSTOM_NAME: 'Mihomo Party',
|
||||
SUB_STORE_BACKEND_SYNC_CRON: subStoreBackendSyncCron,
|
||||
SUB_STORE_BACKEND_DOWNLOAD_CRON: subStoreBackendDownloadCron,
|
||||
SUB_STORE_BACKEND_UPLOAD_CRON: subStoreBackendUploadCron,
|
||||
SUB_STORE_MMDB_COUNTRY_PATH: path.join(mihomoWorkDir(), 'country.mmdb'),
|
||||
SUB_STORE_MMDB_ASN_PATH: path.join(mihomoWorkDir(), 'ASN.mmdb')
|
||||
}
|
||||
subStoreBackendWorker = new Worker(path.join(mihomoWorkDir(), 'sub-store.bundle.js'), {
|
||||
env: useProxyInSubStore
|
||||
? {
|
||||
...env,
|
||||
HTTP_PROXY: `http://127.0.0.1:${port}`,
|
||||
HTTPS_PROXY: `http://127.0.0.1:${port}`,
|
||||
ALL_PROXY: `http://127.0.0.1:${port}`
|
||||
}
|
||||
: env
|
||||
})
|
||||
subStoreBackendWorker.stdout.pipe(stdout)
|
||||
subStoreBackendWorker.stderr.pipe(stderr)
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopSubStoreBackendServer(): Promise<void> {
|
||||
if (subStoreBackendWorker) {
|
||||
subStoreBackendWorker.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadSubStore(): Promise<void> {
|
||||
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
|
||||
const frontendDir = path.join(mihomoWorkDir(), 'sub-store-frontend')
|
||||
const backendPath = path.join(mihomoWorkDir(), 'sub-store.bundle.js')
|
||||
const tempDir = path.join(dataDir(), 'temp')
|
||||
|
||||
try {
|
||||
// 创建临时目录
|
||||
if (existsSync(tempDir)) {
|
||||
await rm(tempDir, { recursive: true })
|
||||
}
|
||||
mkdirSync(tempDir, { recursive: true })
|
||||
|
||||
// 下载后端文件
|
||||
const tempBackendPath = path.join(tempDir, 'sub-store.bundle.js')
|
||||
const backendRes = await axios.get(
|
||||
'https://github.com/sub-store-org/Sub-Store/releases/latest/download/sub-store.bundle.js',
|
||||
{
|
||||
responseType: 'arraybuffer',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
proxy: {
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: mixedPort
|
||||
}
|
||||
}
|
||||
)
|
||||
await writeFile(tempBackendPath, Buffer.from(backendRes.data))
|
||||
// 下载前端文件
|
||||
const tempFrontendDir = path.join(tempDir, 'dist')
|
||||
const frontendRes = await axios.get(
|
||||
'https://github.com/sub-store-org/Sub-Store-Front-End/releases/latest/download/dist.zip',
|
||||
{
|
||||
responseType: 'arraybuffer',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
proxy: {
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
port: mixedPort
|
||||
}
|
||||
}
|
||||
)
|
||||
// 先解压到临时目录
|
||||
const zip = new AdmZip(Buffer.from(frontendRes.data))
|
||||
zip.extractAllTo(tempDir, true)
|
||||
await cp(tempBackendPath, backendPath)
|
||||
if (existsSync(frontendDir)) {
|
||||
await rm(frontendDir, { recursive: true })
|
||||
}
|
||||
mkdirSync(frontendDir, { recursive: true })
|
||||
await cp(path.join(tempDir, 'dist'), frontendDir, { recursive: true })
|
||||
await rm(tempDir, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error('substore.downloadFailed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { app, globalShortcut, ipcMain, Notification } from 'electron'
|
||||
import { mainWindow, showMainWindow } from '..'
|
||||
import { mainWindow, triggerMainWindow } from '..'
|
||||
import {
|
||||
getAppConfig,
|
||||
getControledMihomoConfig,
|
||||
@ -9,6 +9,8 @@ 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'
|
||||
|
||||
export async function registerShortcut(
|
||||
oldShortcut: string,
|
||||
@ -24,11 +26,12 @@ export async function registerShortcut(
|
||||
switch (action) {
|
||||
case 'showWindowShortcut': {
|
||||
return globalShortcut.register(newShortcut, () => {
|
||||
if (mainWindow?.isVisible()) {
|
||||
mainWindow?.close()
|
||||
} else {
|
||||
showMainWindow()
|
||||
}
|
||||
triggerMainWindow()
|
||||
})
|
||||
}
|
||||
case 'showFloatingWindowShortcut': {
|
||||
return globalShortcut.register(newShortcut, async () => {
|
||||
await triggerFloatingWindow()
|
||||
})
|
||||
}
|
||||
case 'triggerSysProxyShortcut': {
|
||||
@ -40,12 +43,13 @@ export async function registerShortcut(
|
||||
await triggerSysProxy(!enable)
|
||||
await patchAppConfig({ sysProxy: { enable: !enable } })
|
||||
new Notification({
|
||||
title: `系统代理已${!enable ? '开启' : '关闭'}`
|
||||
title: i18next.t(!enable ? 'common.notification.systemProxyEnabled' : 'common.notification.systemProxyDisabled')
|
||||
}).show()
|
||||
mainWindow?.webContents.send('appConfigUpdated')
|
||||
floatingWindow?.webContents.send('appConfigUpdated')
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
mainWindow?.webContents.send('appConfigUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
}
|
||||
})
|
||||
@ -62,12 +66,13 @@ export async function registerShortcut(
|
||||
}
|
||||
await restartCore()
|
||||
new Notification({
|
||||
title: `虚拟网卡已${!enable ? '开启' : '关闭'}`
|
||||
title: i18next.t(!enable ? 'common.notification.tunEnabled' : 'common.notification.tunDisabled')
|
||||
}).show()
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
floatingWindow?.webContents.send('appConfigUpdated')
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
}
|
||||
})
|
||||
@ -77,7 +82,7 @@ export async function registerShortcut(
|
||||
await patchControledMihomoConfig({ mode: 'rule' })
|
||||
await patchMihomoConfig({ mode: 'rule' })
|
||||
new Notification({
|
||||
title: '已切换至规则模式'
|
||||
title: i18next.t('common.notification.ruleMode')
|
||||
}).show()
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
@ -88,7 +93,7 @@ export async function registerShortcut(
|
||||
await patchControledMihomoConfig({ mode: 'global' })
|
||||
await patchMihomoConfig({ mode: 'global' })
|
||||
new Notification({
|
||||
title: '已切换至全局模式'
|
||||
title: i18next.t('common.notification.globalMode')
|
||||
}).show()
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
@ -99,7 +104,7 @@ export async function registerShortcut(
|
||||
await patchControledMihomoConfig({ mode: 'direct' })
|
||||
await patchMihomoConfig({ mode: 'direct' })
|
||||
new Notification({
|
||||
title: '已切换至直连模式'
|
||||
title: i18next.t('common.notification.directMode')
|
||||
}).show()
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
@ -122,6 +127,7 @@ export async function registerShortcut(
|
||||
|
||||
export async function initShortcut(): Promise<void> {
|
||||
const {
|
||||
showFloatingWindowShortcut,
|
||||
showWindowShortcut,
|
||||
triggerSysProxyShortcut,
|
||||
triggerTunShortcut,
|
||||
@ -138,6 +144,13 @@ export async function initShortcut(): Promise<void> {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (showFloatingWindowShortcut) {
|
||||
try {
|
||||
await registerShortcut('', showFloatingWindowShortcut, 'showFloatingWindowShortcut')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (triggerSysProxyShortcut) {
|
||||
try {
|
||||
await registerShortcut('', triggerSysProxyShortcut, 'triggerSysProxyShortcut')
|
||||
|
||||
@ -6,8 +6,11 @@ import AdmZip from 'adm-zip'
|
||||
import { getControledMihomoConfig } from '../config'
|
||||
import { existsSync } from 'fs'
|
||||
import { mainWindow } from '..'
|
||||
import { floatingWindow } from './floatingWindow'
|
||||
import { t } from 'i18next'
|
||||
|
||||
let insertedCSSKey: string | undefined = undefined
|
||||
let insertedCSSKeyMain: string | undefined = undefined
|
||||
let insertedCSSKeyFloating: string | undefined = undefined
|
||||
|
||||
export async function resolveThemes(): Promise<{ key: string; label: string }[]> {
|
||||
const files = await readdir(themesDir())
|
||||
@ -26,7 +29,7 @@ export async function resolveThemes(): Promise<{ key: string; label: string }[]>
|
||||
if (themes.find((theme) => theme.key === 'default.css')) {
|
||||
return themes
|
||||
} else {
|
||||
return [{ key: 'default.css', label: '默认' }, ...themes]
|
||||
return [{ key: 'default.css', label: t('common.default') }, ...themes]
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,6 +70,12 @@ export async function writeTheme(theme: string, css: string): Promise<void> {
|
||||
|
||||
export async function applyTheme(theme: string): Promise<void> {
|
||||
const css = await readTheme(theme)
|
||||
await mainWindow?.webContents.removeInsertedCSS(insertedCSSKey || '')
|
||||
insertedCSSKey = await mainWindow?.webContents.insertCSS(css)
|
||||
await mainWindow?.webContents.removeInsertedCSS(insertedCSSKeyMain || '')
|
||||
insertedCSSKeyMain = await mainWindow?.webContents.insertCSS(css)
|
||||
try {
|
||||
await floatingWindow?.webContents.removeInsertedCSS(insertedCSSKeyFloating || '')
|
||||
insertedCSSKeyFloating = await floatingWindow?.webContents.insertCSS(css)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
42
src/main/resolve/trafficMonitor.ts
Normal file
42
src/main/resolve/trafficMonitor.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { ChildProcess, spawn } from 'child_process'
|
||||
import { getAppConfig } from '../config'
|
||||
import { dataDir, resourcesFilesDir } from '../utils/dirs'
|
||||
import path from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { readFile, rm, writeFile } from 'fs/promises'
|
||||
|
||||
let child: ChildProcess
|
||||
|
||||
export async function startMonitor(detached = false): Promise<void> {
|
||||
if (process.platform !== 'win32') return
|
||||
if (existsSync(path.join(dataDir(), 'monitor.pid'))) {
|
||||
const pid = parseInt(await readFile(path.join(dataDir(), 'monitor.pid'), 'utf-8'))
|
||||
try {
|
||||
process.kill(pid, 'SIGINT')
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
await rm(path.join(dataDir(), 'monitor.pid'))
|
||||
}
|
||||
}
|
||||
await stopMonitor()
|
||||
const { showTraffic = false } = await getAppConfig()
|
||||
if (!showTraffic) return
|
||||
child = spawn(path.join(resourcesFilesDir(), 'TrafficMonitor/TrafficMonitor.exe'), [], {
|
||||
cwd: path.join(resourcesFilesDir(), 'TrafficMonitor'),
|
||||
detached: detached,
|
||||
stdio: detached ? 'ignore' : undefined
|
||||
})
|
||||
if (detached) {
|
||||
if (child && child.pid) {
|
||||
await writeFile(path.join(dataDir(), 'monitor.pid'), child.pid.toString())
|
||||
}
|
||||
child.unref()
|
||||
}
|
||||
}
|
||||
|
||||
async function stopMonitor(): Promise<void> {
|
||||
if (child) {
|
||||
child.kill('SIGINT')
|
||||
}
|
||||
}
|
||||
@ -15,15 +15,22 @@ import {
|
||||
mihomoGroups,
|
||||
patchMihomoConfig
|
||||
} from '../core/mihomoApi'
|
||||
import { mainWindow, showMainWindow } from '..'
|
||||
import { mainWindow, showMainWindow, triggerMainWindow } from '..'
|
||||
import { app, clipboard, ipcMain, Menu, nativeImage, shell, Tray } from 'electron'
|
||||
import { dataDir, logDir, mihomoCoreDir, mihomoWorkDir } from '../utils/dirs'
|
||||
import { triggerSysProxy } from '../sys/sysproxy'
|
||||
import { quitWithoutCore, restartCore } from '../core/manager'
|
||||
import { floatingWindow, triggerFloatingWindow } from './floatingWindow'
|
||||
import { t } from 'i18next'
|
||||
|
||||
export let tray: Tray | null = null
|
||||
|
||||
const buildContextMenu = async (): Promise<Menu> => {
|
||||
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'))
|
||||
|
||||
const { mode, tun } = await getControledMihomoConfig()
|
||||
const {
|
||||
sysProxy,
|
||||
@ -31,6 +38,7 @@ const buildContextMenu = async (): Promise<Menu> => {
|
||||
autoCloseConnection,
|
||||
proxyInTray = true,
|
||||
triggerSysProxyShortcut = '',
|
||||
showFloatingWindowShortcut = '',
|
||||
showWindowShortcut = '',
|
||||
triggerTunShortcut = '',
|
||||
ruleModeShortcut = '',
|
||||
@ -84,15 +92,24 @@ const buildContextMenu = async (): Promise<Menu> => {
|
||||
{
|
||||
id: 'show',
|
||||
accelerator: showWindowShortcut,
|
||||
label: '显示窗口',
|
||||
label: t('tray.showWindow'),
|
||||
type: 'normal',
|
||||
click: (): void => {
|
||||
showMainWindow()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'show-floating',
|
||||
accelerator: showFloatingWindowShortcut,
|
||||
label: floatingWindow?.isVisible() ? t('tray.hideFloatingWindow') : t('tray.showFloatingWindow'),
|
||||
type: 'normal',
|
||||
click: async (): Promise<void> => {
|
||||
await triggerFloatingWindow()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'rule',
|
||||
label: '规则模式',
|
||||
label: t('tray.ruleMode'),
|
||||
accelerator: ruleModeShortcut,
|
||||
type: 'radio',
|
||||
checked: mode === 'rule',
|
||||
@ -100,12 +117,13 @@ const buildContextMenu = async (): Promise<Menu> => {
|
||||
await patchControledMihomoConfig({ mode: 'rule' })
|
||||
await patchMihomoConfig({ mode: 'rule' })
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
mainWindow?.webContents.send('groupsUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'global',
|
||||
label: '全局模式',
|
||||
label: t('tray.globalMode'),
|
||||
accelerator: globalModeShortcut,
|
||||
type: 'radio',
|
||||
checked: mode === 'global',
|
||||
@ -113,12 +131,13 @@ const buildContextMenu = async (): Promise<Menu> => {
|
||||
await patchControledMihomoConfig({ mode: 'global' })
|
||||
await patchMihomoConfig({ mode: 'global' })
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
mainWindow?.webContents.send('groupsUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'direct',
|
||||
label: '直连模式',
|
||||
label: t('tray.directMode'),
|
||||
accelerator: directModeShortcut,
|
||||
type: 'radio',
|
||||
checked: mode === 'direct',
|
||||
@ -126,13 +145,14 @@ const buildContextMenu = async (): Promise<Menu> => {
|
||||
await patchControledMihomoConfig({ mode: 'direct' })
|
||||
await patchMihomoConfig({ mode: 'direct' })
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
mainWindow?.webContents.send('groupsUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
type: 'checkbox',
|
||||
label: '系统代理',
|
||||
label: t('tray.systemProxy'),
|
||||
accelerator: triggerSysProxyShortcut,
|
||||
checked: sysProxy.enable,
|
||||
click: async (item): Promise<void> => {
|
||||
@ -140,36 +160,43 @@ const buildContextMenu = async (): Promise<Menu> => {
|
||||
try {
|
||||
await triggerSysProxy(enable)
|
||||
await patchAppConfig({ sysProxy: { enable } })
|
||||
mainWindow?.webContents.send('appConfigUpdated')
|
||||
floatingWindow?.webContents.send('appConfigUpdated')
|
||||
} catch (e) {
|
||||
// ignore
|
||||
} finally {
|
||||
mainWindow?.webContents.send('appConfigUpdated')
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'checkbox',
|
||||
label: '虚拟网卡',
|
||||
label: t('tray.tun'),
|
||||
accelerator: triggerTunShortcut,
|
||||
checked: tun?.enable ?? false,
|
||||
click: async (item): Promise<void> => {
|
||||
const enable = item.checked
|
||||
if (enable) {
|
||||
await patchControledMihomoConfig({ tun: { enable }, dns: { enable: true } })
|
||||
} else {
|
||||
await patchControledMihomoConfig({ tun: { enable } })
|
||||
try {
|
||||
if (enable) {
|
||||
await patchControledMihomoConfig({ tun: { enable }, dns: { enable: true } })
|
||||
} else {
|
||||
await patchControledMihomoConfig({ tun: { enable } })
|
||||
}
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
floatingWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
await restartCore()
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
}
|
||||
mainWindow?.webContents.send('controledMihomoConfigUpdated')
|
||||
await restartCore()
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
}
|
||||
},
|
||||
...groupsMenu,
|
||||
{ type: 'separator' },
|
||||
{
|
||||
type: 'submenu',
|
||||
label: '订阅配置',
|
||||
label: t('tray.profiles'),
|
||||
submenu: items.map((item) => {
|
||||
return {
|
||||
type: 'radio',
|
||||
@ -187,26 +214,26 @@ const buildContextMenu = async (): Promise<Menu> => {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
type: 'submenu',
|
||||
label: '打开目录',
|
||||
label: t('tray.openDirectories.title'),
|
||||
submenu: [
|
||||
{
|
||||
type: 'normal',
|
||||
label: '应用目录',
|
||||
label: t('tray.openDirectories.appDir'),
|
||||
click: (): Promise<string> => shell.openPath(dataDir())
|
||||
},
|
||||
{
|
||||
type: 'normal',
|
||||
label: '工作目录',
|
||||
label: t('tray.openDirectories.workDir'),
|
||||
click: (): Promise<string> => shell.openPath(mihomoWorkDir())
|
||||
},
|
||||
{
|
||||
type: 'normal',
|
||||
label: '内核目录',
|
||||
label: t('tray.openDirectories.coreDir'),
|
||||
click: (): Promise<string> => shell.openPath(mihomoCoreDir())
|
||||
},
|
||||
{
|
||||
type: 'normal',
|
||||
label: '日志目录',
|
||||
label: t('tray.openDirectories.logDir'),
|
||||
click: (): Promise<string> => shell.openPath(logDir())
|
||||
}
|
||||
]
|
||||
@ -214,7 +241,7 @@ const buildContextMenu = async (): Promise<Menu> => {
|
||||
envType.length > 1
|
||||
? {
|
||||
type: 'submenu',
|
||||
label: '复制环境变量',
|
||||
label: t('tray.copyEnv'),
|
||||
submenu: envType.map((type) => {
|
||||
return {
|
||||
id: type,
|
||||
@ -228,7 +255,7 @@ const buildContextMenu = async (): Promise<Menu> => {
|
||||
}
|
||||
: {
|
||||
id: 'copyenv',
|
||||
label: '复制环境变量',
|
||||
label: t('tray.copyEnv'),
|
||||
type: 'normal',
|
||||
click: async (): Promise<void> => {
|
||||
await copyEnv(envType[0])
|
||||
@ -237,14 +264,14 @@ const buildContextMenu = async (): Promise<Menu> => {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
id: 'quitWithoutCore',
|
||||
label: '轻量模式',
|
||||
label: t('actions.lightMode.button'),
|
||||
type: 'normal',
|
||||
accelerator: quitWithoutCoreShortcut,
|
||||
click: quitWithoutCore
|
||||
},
|
||||
{
|
||||
id: 'restart',
|
||||
label: '重启应用',
|
||||
label: t('actions.restartApp'),
|
||||
type: 'normal',
|
||||
accelerator: restartAppShortcut,
|
||||
click: (): void => {
|
||||
@ -254,7 +281,7 @@ const buildContextMenu = async (): Promise<Menu> => {
|
||||
},
|
||||
{
|
||||
id: 'quit',
|
||||
label: '退出应用',
|
||||
label: t('actions.quit.button'),
|
||||
type: 'normal',
|
||||
accelerator: 'CommandOrControl+Q',
|
||||
click: (): void => app.quit()
|
||||
@ -282,7 +309,7 @@ export async function createTray(): Promise<void> {
|
||||
tray?.setIgnoreDoubleClickEvents(true)
|
||||
if (process.platform === 'darwin') {
|
||||
if (!useDockIcon) {
|
||||
app.dock.hide()
|
||||
hideDockIcon()
|
||||
}
|
||||
ipcMain.on('trayIconUpdate', async (_, png: string) => {
|
||||
const image = nativeImage.createFromDataURL(png).resize({ height: 16 })
|
||||
@ -290,11 +317,7 @@ export async function createTray(): Promise<void> {
|
||||
tray?.setImage(image)
|
||||
})
|
||||
tray?.addListener('right-click', async () => {
|
||||
if (mainWindow?.isVisible()) {
|
||||
mainWindow?.close()
|
||||
} else {
|
||||
showMainWindow()
|
||||
}
|
||||
triggerMainWindow()
|
||||
})
|
||||
tray?.addListener('click', async () => {
|
||||
await updateTrayMenu()
|
||||
@ -302,11 +325,7 @@ export async function createTray(): Promise<void> {
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
tray?.addListener('click', () => {
|
||||
if (mainWindow?.isVisible()) {
|
||||
mainWindow?.close()
|
||||
} else {
|
||||
showMainWindow()
|
||||
}
|
||||
triggerMainWindow()
|
||||
})
|
||||
tray?.addListener('right-click', async () => {
|
||||
await updateTrayMenu()
|
||||
@ -314,11 +333,7 @@ export async function createTray(): Promise<void> {
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
tray?.addListener('click', () => {
|
||||
if (mainWindow?.isVisible()) {
|
||||
mainWindow?.close()
|
||||
} else {
|
||||
showMainWindow()
|
||||
}
|
||||
triggerMainWindow()
|
||||
})
|
||||
ipcMain.on('updateTrayMenu', async () => {
|
||||
await updateTrayMenu()
|
||||
@ -359,3 +374,28 @@ export async function copyEnv(type: 'bash' | 'cmd' | 'powershell'): Promise<void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function showTrayIcon(): Promise<void> {
|
||||
if (!tray) {
|
||||
await createTray()
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeTrayIcon(): Promise<void> {
|
||||
if (tray) {
|
||||
tray.destroy()
|
||||
}
|
||||
tray = null
|
||||
}
|
||||
|
||||
export async function showDockIcon(): Promise<void> {
|
||||
if (process.platform === 'darwin' && !app.dock.isVisible()) {
|
||||
await app.dock.show()
|
||||
}
|
||||
}
|
||||
|
||||
export async function hideDockIcon(): Promise<void> {
|
||||
if (process.platform === 'darwin' && app.dock.isVisible()) {
|
||||
app.dock.hide()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { taskDir, exePath, homeDir } from '../utils/dirs'
|
||||
import { exePath, homeDir, taskDir } from '../utils/dirs'
|
||||
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
|
||||
import { exec } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
@ -7,12 +7,9 @@ import path from 'path'
|
||||
|
||||
const appName = 'mihomo-party'
|
||||
|
||||
const taskXml = `<?xml version="1.0" encoding="UTF-16"?>
|
||||
function getTaskXml(): string {
|
||||
return `<?xml version="1.0" encoding="UTF-16"?>
|
||||
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<RegistrationInfo>
|
||||
<Date>${new Date().toISOString()}</Date>
|
||||
<Author>${process.env.USERNAME}</Author>
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<LogonTrigger>
|
||||
<Enabled>true</Enabled>
|
||||
@ -26,12 +23,12 @@ const taskXml = `<?xml version="1.0" encoding="UTF-16"?>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||
<MultipleInstancesPolicy>Parallel</MultipleInstancesPolicy>
|
||||
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<AllowHardTerminate>false</AllowHardTerminate>
|
||||
<StartWhenAvailable>true</StartWhenAvailable>
|
||||
<RunOnlyIfNetworkAvailable>true</RunOnlyIfNetworkAvailable>
|
||||
<StartWhenAvailable>false</StartWhenAvailable>
|
||||
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
||||
<IdleSettings>
|
||||
<StopOnIdleEnd>false</StopOnIdleEnd>
|
||||
<RestartOnIdle>false</RestartOnIdle>
|
||||
@ -42,21 +39,25 @@ const taskXml = `<?xml version="1.0" encoding="UTF-16"?>
|
||||
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
||||
<WakeToRun>false</WakeToRun>
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||
<Priority>7</Priority>
|
||||
<Priority>3</Priority>
|
||||
</Settings>
|
||||
<Actions Context="Author">
|
||||
<Exec>
|
||||
<Command>${exePath()}</Command>
|
||||
<Command>"${path.join(taskDir(), `mihomo-party-run.exe`)}"</Command>
|
||||
<Arguments>"${exePath()}"</Arguments>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>
|
||||
`
|
||||
`
|
||||
}
|
||||
|
||||
export async function checkAutoRun(): Promise<boolean> {
|
||||
if (process.platform === 'win32') {
|
||||
const execPromise = promisify(exec)
|
||||
try {
|
||||
const { stdout } = await execPromise(`schtasks /query /tn "${appName}"`)
|
||||
const { stdout } = await execPromise(
|
||||
`chcp 437 && %SystemRoot%\\System32\\schtasks.exe /query /tn "${appName}"`
|
||||
)
|
||||
return stdout.includes(appName)
|
||||
} catch (e) {
|
||||
return false
|
||||
@ -81,8 +82,10 @@ 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${taskXml}`, 'utf-16le'))
|
||||
await execPromise(`schtasks /create /tn "${appName}" /xml "${taskFilePath}" /f`)
|
||||
await writeFile(taskFilePath, Buffer.from(`\ufeff${getTaskXml()}`, 'utf-16le'))
|
||||
await execPromise(
|
||||
`%SystemRoot%\\System32\\schtasks.exe /create /tn "${appName}" /xml "${taskFilePath}" /f`
|
||||
)
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
const execPromise = promisify(exec)
|
||||
@ -118,7 +121,7 @@ Categories=Utility;
|
||||
export async function disableAutoRun(): Promise<void> {
|
||||
if (process.platform === 'win32') {
|
||||
const execPromise = promisify(exec)
|
||||
await execPromise(`schtasks /delete /tn "${appName}" /f`)
|
||||
await execPromise(`%SystemRoot%\\System32\\schtasks.exe /delete /tn "${appName}" /f`)
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
const execPromise = promisify(exec)
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { exec, execFile, execSync } from 'child_process'
|
||||
import { dialog, nativeTheme, shell } from 'electron'
|
||||
import { exec, execFile, execSync, spawn } from 'child_process'
|
||||
import { app, dialog, nativeTheme, shell } from 'electron'
|
||||
import { readFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { promisify } from 'util'
|
||||
import {
|
||||
dataDir,
|
||||
exePath,
|
||||
mihomoCorePath,
|
||||
overridePath,
|
||||
@ -44,9 +45,12 @@ export async function openUWPTool(): Promise<void> {
|
||||
export async function setupFirewall(): Promise<void> {
|
||||
const execPromise = promisify(exec)
|
||||
const removeCommand = `
|
||||
Remove-NetFirewallRule -DisplayName "mihomo" -ErrorAction SilentlyContinue
|
||||
Remove-NetFirewallRule -DisplayName "mihomo-alpha" -ErrorAction SilentlyContinue
|
||||
Remove-NetFirewallRule -DisplayName "Mihomo Party" -ErrorAction SilentlyContinue
|
||||
$rules = @("mihomo", "mihomo-alpha", "Mihomo Party")
|
||||
foreach ($rule in $rules) {
|
||||
if (Get-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue) {
|
||||
Remove-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
`
|
||||
const createCommand = `
|
||||
New-NetFirewallRule -DisplayName "mihomo" -Direction Inbound -Action Allow -Program "${mihomoCorePath('mihomo')}" -Enabled True -Profile Any -ErrorAction SilentlyContinue
|
||||
@ -64,12 +68,9 @@ export function setNativeTheme(theme: 'system' | 'light' | 'dark'): void {
|
||||
nativeTheme.themeSource = theme
|
||||
}
|
||||
|
||||
const elevateTaskXml = `<?xml version="1.0" encoding="UTF-16"?>
|
||||
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">
|
||||
<RegistrationInfo>
|
||||
<Date>${new Date().toISOString()}</Date>
|
||||
<Author>${process.env.USERNAME}</Author>
|
||||
</RegistrationInfo>
|
||||
<Triggers />
|
||||
<Principals>
|
||||
<Principal id="Author">
|
||||
@ -93,8 +94,8 @@ const elevateTaskXml = `<?xml version="1.0" encoding="UTF-16"?>
|
||||
<Hidden>false</Hidden>
|
||||
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
||||
<WakeToRun>false</WakeToRun>
|
||||
<ExecutionTimeLimit>PT72H</ExecutionTimeLimit>
|
||||
<Priority>7</Priority>
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||
<Priority>3</Priority>
|
||||
</Settings>
|
||||
<Actions Context="Author">
|
||||
<Exec>
|
||||
@ -104,13 +105,46 @@ const elevateTaskXml = `<?xml version="1.0" encoding="UTF-16"?>
|
||||
</Actions>
|
||||
</Task>
|
||||
`
|
||||
}
|
||||
|
||||
export function createElevateTask(): void {
|
||||
const taskFilePath = path.join(taskDir(), `mihomo-party-run.xml`)
|
||||
writeFileSync(taskFilePath, Buffer.from(`\ufeff${elevateTaskXml}`, 'utf-16le'))
|
||||
execSync(`schtasks /create /tn "mihomo-party-run" /xml "${taskFilePath}" /f`)
|
||||
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(
|
||||
'cmd',
|
||||
[
|
||||
'/C',
|
||||
`"timeout /t 2 /nobreak >nul && rmdir /s /q "${dataDir()}" && start "" "${exePath()}""`
|
||||
],
|
||||
{
|
||||
shell: true,
|
||||
detached: true
|
||||
}
|
||||
).unref()
|
||||
} else {
|
||||
const script = `while kill -0 ${process.pid} 2>/dev/null; do
|
||||
sleep 0.1
|
||||
done
|
||||
rm -rf '${dataDir()}'
|
||||
${process.argv.join(' ')} & disown
|
||||
exit
|
||||
`
|
||||
spawn('sh', ['-c', `"${script}"`], {
|
||||
shell: true,
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
}
|
||||
app.quit()
|
||||
}
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import { triggerAutoProxy, triggerManualProxy } from '@mihomo-party/sysproxy'
|
||||
import { getAppConfig, getControledMihomoConfig } from '../config'
|
||||
import { pacPort } from '../resolve/server'
|
||||
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 { net } from 'electron'
|
||||
import axios from 'axios'
|
||||
import fs from 'fs'
|
||||
|
||||
let defaultBypass: string[]
|
||||
let triggerSysProxyTimer: NodeJS.Timeout | null = null
|
||||
const helperSocketPath = '/tmp/mihomo-party-helper.sock'
|
||||
|
||||
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']
|
||||
@ -63,6 +66,7 @@ export async function triggerSysProxy(enable: boolean): Promise<void> {
|
||||
}
|
||||
|
||||
async function enableSysProxy(): Promise<void> {
|
||||
await startPacServer()
|
||||
const { sysProxy } = await getAppConfig()
|
||||
const { mode, host, bypass = defaultBypass } = sysProxy
|
||||
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
|
||||
@ -78,6 +82,16 @@ async function enableSysProxy(): Promise<void> {
|
||||
} 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`)
|
||||
}
|
||||
@ -96,6 +110,16 @@ async function enableSysProxy(): Promise<void> {
|
||||
} catch {
|
||||
triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(','))
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
await helperRequest(() =>
|
||||
axios.post(
|
||||
'http://localhost/global',
|
||||
{ host: host || '127.0.0.1', port: port.toString(), bypass: bypass.join(',') },
|
||||
{
|
||||
socketPath: helperSocketPath
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(','))
|
||||
}
|
||||
@ -105,6 +129,7 @@ async function enableSysProxy(): Promise<void> {
|
||||
}
|
||||
|
||||
async function disableSysProxy(): Promise<void> {
|
||||
await stopPacServer()
|
||||
const execFilePromise = promisify(execFile)
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
@ -113,8 +138,85 @@ async function disableSysProxy(): Promise<void> {
|
||||
triggerAutoProxy(false, '')
|
||||
triggerManualProxy(false, '', 0, '')
|
||||
}
|
||||
} 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)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to send signal to recreate socket
|
||||
async function requestSocketRecreation(): Promise<void> {
|
||||
try {
|
||||
// Send SIGUSR1 signal to helper process to recreate socket
|
||||
const { exec } = require('child_process')
|
||||
const { promisify } = require('util')
|
||||
const execPromise = promisify(exec)
|
||||
|
||||
// Use osascript with administrator privileges (same pattern as manualGrantCorePermition)
|
||||
const shell = `pkill -USR1 -f party.mihomo.helper`
|
||||
const command = `do shell script "${shell}" with administrator privileges`
|
||||
await execPromise(`osascript -e '${command}'`)
|
||||
|
||||
// Wait a bit for socket recreation
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
} catch (error) {
|
||||
console.log('Failed to send signal to helper:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper function for helper requests with auto-retry on socket issues
|
||||
async function helperRequest(requestFn: () => Promise<unknown>, maxRetries = 1): Promise<unknown> {
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await requestFn()
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
|
||||
// Check if it's a connection error and socket file doesn't exist
|
||||
if (attempt < maxRetries &&
|
||||
((error as NodeJS.ErrnoException).code === 'ECONNREFUSED' ||
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT' ||
|
||||
(error as Error).message?.includes('connect ECONNREFUSED') ||
|
||||
(error as Error).message?.includes('ENOENT'))) {
|
||||
|
||||
console.log(`Helper request failed (attempt ${attempt + 1}), checking socket file...`)
|
||||
|
||||
if (!isSocketFileExists()) {
|
||||
console.log('Socket file missing, requesting recreation...')
|
||||
try {
|
||||
await requestSocketRecreation()
|
||||
console.log('Socket recreation requested, retrying...')
|
||||
continue
|
||||
} catch (signalError) {
|
||||
console.log('Failed to request socket recreation:', signalError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not a connection error or we've exhausted retries, throw the error
|
||||
if (attempt === maxRetries) {
|
||||
throw lastError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
@ -18,7 +18,13 @@ export function dataDir(): string {
|
||||
}
|
||||
|
||||
export function taskDir(): string {
|
||||
const dir = path.join(app.getPath('userData'), 'tasks')
|
||||
const userDataDir = app.getPath('userData')
|
||||
// 确保 userData 目录存在
|
||||
if (!existsSync(userDataDir)) {
|
||||
mkdirSync(userDataDir, { recursive: true })
|
||||
}
|
||||
|
||||
const dir = path.join(userDataDir, 'tasks')
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
@ -102,12 +108,20 @@ export function mihomoWorkDir(): string {
|
||||
return path.join(dataDir(), 'work')
|
||||
}
|
||||
|
||||
export function mihomoProfileWorkDir(id: string | undefined): string {
|
||||
return path.join(mihomoWorkDir(), id || 'default')
|
||||
}
|
||||
|
||||
export function mihomoTestDir(): string {
|
||||
return path.join(dataDir(), 'test')
|
||||
}
|
||||
|
||||
export function mihomoWorkConfigPath(): string {
|
||||
return path.join(mihomoWorkDir(), 'config.yaml')
|
||||
export function mihomoWorkConfigPath(id: string | undefined): string {
|
||||
if (id === 'work') {
|
||||
return path.join(mihomoWorkDir(), 'config.yaml')
|
||||
} else {
|
||||
return path.join(mihomoProfileWorkDir(id), 'config.yaml')
|
||||
}
|
||||
}
|
||||
|
||||
export function logDir(): string {
|
||||
@ -119,3 +133,9 @@ export function logPath(): string {
|
||||
const name = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
|
||||
return path.join(logDir(), `${name}.log`)
|
||||
}
|
||||
|
||||
export function substoreLogPath(): string {
|
||||
const date = new Date()
|
||||
const name = `sub-store-${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
|
||||
return path.join(logDir(), `${name}.log`)
|
||||
}
|
||||
|
||||
17
src/main/utils/image.ts
Normal file
17
src/main/utils/image.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import axios from 'axios'
|
||||
import { getControledMihomoConfig } from '../config'
|
||||
|
||||
export async function getImageDataURL(url: string): Promise<string> {
|
||||
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
|
||||
const res = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
proxy: {
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1',
|
||||
port
|
||||
}
|
||||
})
|
||||
const mimeType = res.headers['content-type']
|
||||
const dataURL = `data:${mimeType};base64,${Buffer.from(res.data).toString('base64')}`
|
||||
return dataURL
|
||||
}
|
||||
@ -22,10 +22,16 @@ import {
|
||||
defaultProfileConfig
|
||||
} from './template'
|
||||
import yaml from 'yaml'
|
||||
import { mkdir, writeFile, copyFile, rm, readdir } from 'fs/promises'
|
||||
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, startSubStoreServer } from '../resolve/server'
|
||||
import {
|
||||
startPacServer,
|
||||
startSubStoreBackendServer,
|
||||
startSubStoreFrontendServer
|
||||
} from '../resolve/server'
|
||||
import { triggerSysProxy } from '../sys/sysproxy'
|
||||
import {
|
||||
getAppConfig,
|
||||
@ -36,7 +42,32 @@ import {
|
||||
import { app } from 'electron'
|
||||
import { startSSIDCheck } from '../sys/ssid'
|
||||
|
||||
async function fixDataDirPermissions(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return
|
||||
|
||||
const dataDirPath = dataDir()
|
||||
if (!existsSync(dataDirPath)) return
|
||||
|
||||
try {
|
||||
const stats = await stat(dataDirPath)
|
||||
const currentUid = process.getuid?.() || 0
|
||||
|
||||
if (stats.uid === 0 && currentUid !== 0) {
|
||||
const execPromise = promisify(exec)
|
||||
const username = process.env.USER || process.env.LOGNAME
|
||||
if (username) {
|
||||
await execPromise(`chown -R "${username}:staff" "${dataDirPath}"`)
|
||||
await execPromise(`chmod -R u+rwX "${dataDirPath}"`)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function initDirs(): Promise<void> {
|
||||
await fixDataDirPermissions()
|
||||
|
||||
if (!existsSync(dataDir())) {
|
||||
await mkdir(dataDir())
|
||||
}
|
||||
@ -84,20 +115,23 @@ async function initConfig(): Promise<void> {
|
||||
async function initFiles(): Promise<void> {
|
||||
const copy = async (file: string): Promise<void> => {
|
||||
const targetPath = path.join(mihomoWorkDir(), file)
|
||||
const testTargrtPath = path.join(mihomoTestDir(), file)
|
||||
const testTargetPath = path.join(mihomoTestDir(), file)
|
||||
const sourcePath = path.join(resourcesFilesDir(), file)
|
||||
if (!existsSync(targetPath) && existsSync(sourcePath)) {
|
||||
await copyFile(sourcePath, targetPath)
|
||||
await cp(sourcePath, targetPath, { recursive: true })
|
||||
}
|
||||
if (!existsSync(testTargrtPath) && existsSync(sourcePath)) {
|
||||
await copyFile(sourcePath, testTargrtPath)
|
||||
if (!existsSync(testTargetPath) && existsSync(sourcePath)) {
|
||||
await cp(sourcePath, testTargetPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
copy('country.mmdb'),
|
||||
copy('geoip.metadb'),
|
||||
copy('geoip.dat'),
|
||||
copy('geosite.dat'),
|
||||
copy('ASN.mmdb')
|
||||
copy('ASN.mmdb'),
|
||||
copy('sub-store.bundle.js'),
|
||||
copy('sub-store-frontend')
|
||||
])
|
||||
}
|
||||
|
||||
@ -105,7 +139,7 @@ async function cleanup(): Promise<void> {
|
||||
// update cache
|
||||
const files = await readdir(dataDir())
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.exe') || file.endsWith('.dmg')) {
|
||||
if (file.endsWith('.exe') || file.endsWith('.pkg') || file.endsWith('.7z')) {
|
||||
try {
|
||||
await rm(path.join(dataDir(), file))
|
||||
} catch {
|
||||
@ -148,9 +182,15 @@ async function migration(): Promise<void> {
|
||||
],
|
||||
appTheme = 'system',
|
||||
envType = [process.platform === 'win32' ? 'powershell' : 'bash'],
|
||||
useSubStore = true
|
||||
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,
|
||||
@ -189,6 +229,26 @@ async function migration(): Promise<void> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
function initDeeplink(): void {
|
||||
@ -209,10 +269,17 @@ export async function init(): Promise<void> {
|
||||
await migration()
|
||||
await initFiles()
|
||||
await cleanup()
|
||||
await startPacServer()
|
||||
await startSubStoreServer()
|
||||
await startSubStoreFrontendServer()
|
||||
await startSubStoreBackendServer()
|
||||
const { sysProxy } = await getAppConfig()
|
||||
await triggerSysProxy(sysProxy.enable)
|
||||
try {
|
||||
if (sysProxy.enable) {
|
||||
await startPacServer()
|
||||
}
|
||||
await triggerSysProxy(sysProxy.enable)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await startSSIDCheck()
|
||||
|
||||
initDeeplink()
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { app, dialog, ipcMain, safeStorage } from 'electron'
|
||||
import { app, dialog, ipcMain } from 'electron'
|
||||
import {
|
||||
mihomoChangeProxy,
|
||||
mihomoCloseAllConnections,
|
||||
@ -10,6 +10,7 @@ import {
|
||||
mihomoProxyProviders,
|
||||
mihomoRuleProviders,
|
||||
mihomoRules,
|
||||
mihomoUnfixedProxy,
|
||||
mihomoUpdateProxyProviders,
|
||||
mihomoUpdateRuleProviders,
|
||||
mihomoUpgrade,
|
||||
@ -30,6 +31,8 @@ import {
|
||||
removeProfileItem,
|
||||
changeCurrentProfile,
|
||||
getProfileStr,
|
||||
getFileStr,
|
||||
setFileStr,
|
||||
setProfileStr,
|
||||
updateProfileItem,
|
||||
setProfileConfig,
|
||||
@ -42,13 +45,16 @@ import {
|
||||
setOverride,
|
||||
updateOverrideItem
|
||||
} from '../config'
|
||||
import { startSubStoreServer, subStoreFrontendPort, subStorePort } from '../resolve/server'
|
||||
import {
|
||||
isEncryptionAvailable,
|
||||
manualGrantCorePermition,
|
||||
quitWithoutCore,
|
||||
restartCore
|
||||
} from '../core/manager'
|
||||
startSubStoreFrontendServer,
|
||||
startSubStoreBackendServer,
|
||||
stopSubStoreFrontendServer,
|
||||
stopSubStoreBackendServer,
|
||||
downloadSubStore,
|
||||
subStoreFrontendPort,
|
||||
subStorePort
|
||||
} from '../resolve/server'
|
||||
import { manualGrantCorePermition, quitWithoutCore, restartCore } from '../core/manager'
|
||||
import { triggerSysProxy } from '../sys/sysproxy'
|
||||
import { checkUpdate, downloadAndInstallUpdate } from '../resolve/autoUpdater'
|
||||
import {
|
||||
@ -56,15 +62,16 @@ import {
|
||||
openFile,
|
||||
openUWPTool,
|
||||
readTextFile,
|
||||
resetAppConfig,
|
||||
setNativeTheme,
|
||||
setupFirewall
|
||||
} from '../sys/misc'
|
||||
import { getRuntimeConfig, getRuntimeConfigStr } from '../core/factory'
|
||||
import { listWebdavBackups, webdavBackup, webdavDelete, webdavRestore } from '../resolve/backup'
|
||||
import { getInterfaces } from '../sys/interface'
|
||||
import { copyEnv } from '../resolve/tray'
|
||||
import { closeTrayIcon, copyEnv, showTrayIcon } from '../resolve/tray'
|
||||
import { registerShortcut } from '../resolve/shortcut'
|
||||
import { mainWindow } from '..'
|
||||
import { closeMainWindow, mainWindow, showMainWindow, triggerMainWindow } from '..'
|
||||
import {
|
||||
applyTheme,
|
||||
fetchThemes,
|
||||
@ -78,6 +85,11 @@ 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'
|
||||
|
||||
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
|
||||
@ -119,6 +131,7 @@ export function registerIpcMainHandlers(): void {
|
||||
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) =>
|
||||
@ -144,11 +157,14 @@ export function registerIpcMainHandlers(): void {
|
||||
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))
|
||||
@ -158,12 +174,9 @@ export function registerIpcMainHandlers(): void {
|
||||
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('isEncryptionAvailable', isEncryptionAvailable)
|
||||
ipcMain.handle('encryptString', (_e, str) => encryptString(str))
|
||||
ipcMain.handle('manualGrantCorePermition', (_e, password) =>
|
||||
ipcErrorWrapper(manualGrantCorePermition)(password)
|
||||
)
|
||||
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))
|
||||
@ -184,7 +197,13 @@ export function registerIpcMainHandlers(): void {
|
||||
ipcMain.handle('registerShortcut', (_e, oldShortcut, newShortcut, action) =>
|
||||
ipcErrorWrapper(registerShortcut)(oldShortcut, newShortcut, action)
|
||||
)
|
||||
ipcMain.handle('startSubStoreServer', () => ipcErrorWrapper(startSubStoreServer)())
|
||||
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)())
|
||||
@ -195,7 +214,9 @@ export function registerIpcMainHandlers(): void {
|
||||
})
|
||||
ipcMain.handle('setTitleBarOverlay', (_e, overlay) =>
|
||||
ipcErrorWrapper(async (overlay): Promise<void> => {
|
||||
mainWindow?.setTitleBarOverlay(overlay)
|
||||
if (mainWindow && typeof mainWindow.setTitleBarOverlay === 'function') {
|
||||
mainWindow.setTitleBarOverlay(overlay)
|
||||
}
|
||||
})(overlay)
|
||||
)
|
||||
ipcMain.handle('setAlwaysOnTop', (_e, alwaysOnTop) => {
|
||||
@ -204,6 +225,14 @@ export function registerIpcMainHandlers(): void {
|
||||
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()
|
||||
@ -211,6 +240,7 @@ export function registerIpcMainHandlers(): void {
|
||||
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))
|
||||
@ -221,14 +251,18 @@ export function registerIpcMainHandlers(): void {
|
||||
ipcMain.handle('alert', (_e, msg) => {
|
||||
dialog.showErrorBox('Mihomo Party', msg)
|
||||
})
|
||||
ipcMain.handle('resetAppConfig', resetAppConfig)
|
||||
ipcMain.handle('relaunchApp', () => {
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
})
|
||||
ipcMain.handle('quitWithoutCore', ipcErrorWrapper(quitWithoutCore))
|
||||
ipcMain.handle('quitApp', () => app.quit())
|
||||
}
|
||||
|
||||
function encryptString(str: string): number[] {
|
||||
return safeStorage.encryptString(str).toJSON().data
|
||||
// Add language change handler
|
||||
ipcMain.handle('changeLanguage', async (_e, lng) => {
|
||||
await i18next.changeLanguage(lng)
|
||||
// 触发托盘菜单更新
|
||||
ipcMain.emit('updateTrayMenu')
|
||||
})
|
||||
}
|
||||
|
||||
@ -3,27 +3,36 @@ function isObject(item: any): boolean {
|
||||
return item && typeof item === 'object' && !Array.isArray(item)
|
||||
}
|
||||
|
||||
function trimWrap(str: string): string {
|
||||
if (str.startsWith('<') && str.endsWith('>')) {
|
||||
return str.slice(1, -1)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
export function deepMerge<T extends object>(target: T, other: Partial<T>): T {
|
||||
for (const key in other) {
|
||||
if (isObject(other[key])) {
|
||||
if (key.endsWith('!')) {
|
||||
const k = key.slice(0, -1)
|
||||
const k = trimWrap(key.slice(0, -1))
|
||||
target[k] = other[key]
|
||||
} else {
|
||||
if (!target[key]) Object.assign(target, { [key]: {} })
|
||||
deepMerge(target[key] as object, other[key] as object)
|
||||
const k = trimWrap(key)
|
||||
if (!target[k]) Object.assign(target, { [k]: {} })
|
||||
deepMerge(target[k] as object, other[k] as object)
|
||||
}
|
||||
} else if (Array.isArray(other[key])) {
|
||||
if (key.startsWith('+')) {
|
||||
const k = key.slice(1)
|
||||
const k = trimWrap(key.slice(1))
|
||||
if (!target[k]) Object.assign(target, { [k]: [] })
|
||||
target[k] = [...other[key], ...(target[k] as never[])]
|
||||
} else if (key.endsWith('+')) {
|
||||
const k = key.slice(0, -1)
|
||||
const k = trimWrap(key.slice(0, -1))
|
||||
if (!target[k]) Object.assign(target, { [k]: [] })
|
||||
target[k] = [...(target[k] as never[]), ...other[key]]
|
||||
} else {
|
||||
Object.assign(target, { [key]: other[key] })
|
||||
const k = trimWrap(key)
|
||||
Object.assign(target, { [k]: other[key] })
|
||||
}
|
||||
} else {
|
||||
Object.assign(target, { [key]: other[key] })
|
||||
|
||||
@ -32,12 +32,12 @@ export const defaultConfig: IAppConfig = {
|
||||
'log',
|
||||
'substore'
|
||||
],
|
||||
siderWidth: 250,
|
||||
sysProxy: { enable: false, mode: 'manual' }
|
||||
}
|
||||
|
||||
export const defaultControledMihomoConfig: Partial<IMihomoConfig> = {
|
||||
'external-controller': '127.0.0.1:9090',
|
||||
secret: '',
|
||||
'external-controller': '',
|
||||
ipv6: true,
|
||||
mode: 'rule',
|
||||
'mixed-port': 7890,
|
||||
@ -46,7 +46,7 @@ export const defaultControledMihomoConfig: Partial<IMihomoConfig> = {
|
||||
'redir-port': 0,
|
||||
'tproxy-port': 0,
|
||||
'allow-lan': false,
|
||||
'unified-delay': false,
|
||||
'unified-delay': true,
|
||||
'tcp-concurrent': false,
|
||||
'log-level': 'info',
|
||||
'find-process-mode': 'strict',
|
||||
@ -63,6 +63,7 @@ export const defaultControledMihomoConfig: Partial<IMihomoConfig> = {
|
||||
'auto-redirect': false,
|
||||
'auto-detect-interface': true,
|
||||
'dns-hijack': ['any:53'],
|
||||
'route-exclude-address': [],
|
||||
mtu: 1500
|
||||
},
|
||||
dns: {
|
||||
@ -73,8 +74,9 @@ export const defaultControledMihomoConfig: Partial<IMihomoConfig> = {
|
||||
'fake-ip-filter': ['*', '+.lan', '+.local', 'time.*.com', 'ntp.*.com', '+.market.xiaomi.com'],
|
||||
'use-hosts': false,
|
||||
'use-system-hosts': false,
|
||||
nameserver: ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query'],
|
||||
'proxy-server-nameserver': ['https://doh.pub/dns-query', 'https://dns.alidns.com/dns-query']
|
||||
nameserver: ['https://120.53.53.53/dns-query', 'https://223.5.5.5/dns-query'],
|
||||
'proxy-server-nameserver': ['https://120.53.53.53/dns-query', 'https://223.5.5.5/dns-query'],
|
||||
'direct-nameserver': []
|
||||
},
|
||||
sniffer: {
|
||||
enable: true,
|
||||
@ -88,12 +90,23 @@ export const defaultControledMihomoConfig: Partial<IMihomoConfig> = {
|
||||
},
|
||||
TLS: {
|
||||
ports: [443]
|
||||
},
|
||||
QUIC: {
|
||||
ports: [443]
|
||||
}
|
||||
},
|
||||
'skip-domain': ['+.push.apple.com']
|
||||
'skip-domain': ['+.push.apple.com'],
|
||||
'skip-dst-address': [
|
||||
'91.105.192.0/23',
|
||||
'91.108.4.0/22',
|
||||
'91.108.8.0/21',
|
||||
'91.108.16.0/21',
|
||||
'91.108.56.0/22',
|
||||
'95.161.64.0/20',
|
||||
'149.154.160.0/20',
|
||||
'185.76.151.0/24',
|
||||
'2001:67c:4e8::/48',
|
||||
'2001:b28:f23c::/47',
|
||||
'2001:b28:f23f::/48',
|
||||
'2a0a:f280:203::/48'
|
||||
]
|
||||
},
|
||||
profile: {
|
||||
'store-selected': true,
|
||||
@ -105,7 +118,7 @@ export const defaultControledMihomoConfig: Partial<IMihomoConfig> = {
|
||||
'geox-url': {
|
||||
geoip: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip-lite.dat',
|
||||
geosite: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat',
|
||||
mmdb: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country-lite.mmdb',
|
||||
mmdb: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb',
|
||||
asn: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb'
|
||||
}
|
||||
}
|
||||
|
||||
17
src/renderer/floating.html
Normal file
17
src/renderer/floating.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" lang="zh" />
|
||||
<title>Mihomo Party Floating</title>
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data:; frame-src http://127.0.0.1:*;"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/floating.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,10 +1,10 @@
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { NavigateFunction, useLocation, useNavigate, useRoutes } from 'react-router-dom'
|
||||
import OutboundModeSwitcher from '@renderer/components/sider/outbound-mode-switcher'
|
||||
import SysproxySwitcher from '@renderer/components/sider/sysproxy-switcher'
|
||||
import TunSwitcher from '@renderer/components/sider/tun-switcher'
|
||||
import { Button, Divider } from '@nextui-org/react'
|
||||
import { Button, Divider } from '@heroui/react'
|
||||
import { IoSettings } from 'react-icons/io5'
|
||||
import routes from '@renderer/routes'
|
||||
import {
|
||||
@ -35,15 +35,23 @@ import SubStoreCard from '@renderer/components/sider/substore-card'
|
||||
import MihomoIcon from './components/base/mihomo-icon'
|
||||
import { driver } from 'driver.js'
|
||||
import 'driver.js/dist/driver.css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
let navigate: NavigateFunction
|
||||
let driverInstance: ReturnType<typeof driver> | null = null
|
||||
|
||||
export function getDriver(): ReturnType<typeof driver> | null {
|
||||
return driverInstance
|
||||
}
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { appConfig, patchAppConfig } = useAppConfig()
|
||||
const {
|
||||
appTheme = 'system',
|
||||
customTheme,
|
||||
useWindowFrame = false,
|
||||
siderWidth = 250,
|
||||
siderOrder = [
|
||||
'sysproxy',
|
||||
'tun',
|
||||
@ -60,37 +68,223 @@ const App: React.FC = () => {
|
||||
'substore'
|
||||
]
|
||||
} = appConfig || {}
|
||||
const narrowWidth = platform === 'darwin' ? 70 : 60
|
||||
const [order, setOrder] = useState(siderOrder)
|
||||
const [siderWidthValue, setSiderWidthValue] = useState(siderWidth)
|
||||
const siderWidthValueRef = useRef(siderWidthValue)
|
||||
const [resizing, setResizing] = useState(false)
|
||||
const resizingRef = useRef(resizing)
|
||||
const sensors = useSensors(useSensor(PointerSensor))
|
||||
const { setTheme, systemTheme } = useTheme()
|
||||
navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const page = useRoutes(routes)
|
||||
const setTitlebar = (): void => {
|
||||
if (!useWindowFrame) {
|
||||
if (!useWindowFrame && platform !== 'darwin') {
|
||||
const options = { height: 48 } as TitleBarOverlayOptions
|
||||
try {
|
||||
if (platform !== 'darwin') {
|
||||
options.color = window.getComputedStyle(document.documentElement).backgroundColor
|
||||
options.symbolColor = window.getComputedStyle(document.documentElement).color
|
||||
}
|
||||
options.color = window.getComputedStyle(document.documentElement).backgroundColor
|
||||
options.symbolColor = window.getComputedStyle(document.documentElement).color
|
||||
setTitleBarOverlay(options)
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
setOrder(siderOrder)
|
||||
}, [siderOrder])
|
||||
|
||||
useEffect(() => {
|
||||
setOrder(siderOrder)
|
||||
setSiderWidthValue(siderWidth)
|
||||
}, [siderOrder, siderWidth])
|
||||
|
||||
useEffect(() => {
|
||||
siderWidthValueRef.current = siderWidthValue
|
||||
resizingRef.current = resizing
|
||||
}, [siderWidthValue, resizing])
|
||||
|
||||
useEffect(() => {
|
||||
driverInstance = driver({
|
||||
showProgress: true,
|
||||
nextBtnText: t('common.next'),
|
||||
prevBtnText: t('common.prev'),
|
||||
doneBtnText: t('common.done'),
|
||||
progressText: '{{current}} / {{total}}',
|
||||
overlayOpacity: 0.9,
|
||||
steps: [
|
||||
{
|
||||
element: 'none',
|
||||
popover: {
|
||||
title: t('guide.welcome.title'),
|
||||
description: t('guide.welcome.description'),
|
||||
side: 'over',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.side',
|
||||
popover: {
|
||||
title: t('guide.sider.title'),
|
||||
description: t('guide.sider.description'),
|
||||
side: 'right',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.sysproxy-card',
|
||||
popover: {
|
||||
title: t('guide.card.title'),
|
||||
description: t('guide.card.description'),
|
||||
side: 'right',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.main',
|
||||
popover: {
|
||||
title: t('guide.main.title'),
|
||||
description: t('guide.main.description'),
|
||||
side: 'left',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.profile-card',
|
||||
popover: {
|
||||
title: t('guide.profile.title'),
|
||||
description: t('guide.profile.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
onNextClick: async (): Promise<void> => {
|
||||
navigate('/profiles')
|
||||
setTimeout(() => {
|
||||
driverInstance?.moveNext()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.profiles-sticky',
|
||||
popover: {
|
||||
title: t('guide.import.title'),
|
||||
description: t('guide.import.description'),
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.substore-import',
|
||||
popover: {
|
||||
title: t('guide.substore.title'),
|
||||
description: t('guide.substore.description'),
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.new-profile',
|
||||
popover: {
|
||||
title: t('guide.localProfile.title'),
|
||||
description: t('guide.localProfile.description'),
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.sysproxy-card',
|
||||
popover: {
|
||||
title: t('guide.sysproxy.title'),
|
||||
description: t('guide.sysproxy.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
onNextClick: async (): Promise<void> => {
|
||||
navigate('/sysproxy')
|
||||
setTimeout(() => {
|
||||
driverInstance?.moveNext()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.sysproxy-settings',
|
||||
popover: {
|
||||
title: t('guide.sysproxySetting.title'),
|
||||
description: t('guide.sysproxySetting.description'),
|
||||
side: 'top',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.tun-card',
|
||||
popover: {
|
||||
title: t('guide.tun.title'),
|
||||
description: t('guide.tun.description'),
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
onNextClick: async (): Promise<void> => {
|
||||
navigate('/tun')
|
||||
setTimeout(() => {
|
||||
driverInstance?.moveNext()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.tun-settings',
|
||||
popover: {
|
||||
title: t('guide.tunSetting.title'),
|
||||
description: t('guide.tunSetting.description'),
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.override-card',
|
||||
popover: {
|
||||
title: t('guide.override.title'),
|
||||
description: t('guide.override.description'),
|
||||
side: 'right',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.dns-card',
|
||||
popover: {
|
||||
title: t('guide.dns.title'),
|
||||
description: t('guide.dns.description'),
|
||||
side: 'right',
|
||||
align: 'center',
|
||||
onNextClick: async (): Promise<void> => {
|
||||
navigate('/profiles')
|
||||
setTimeout(() => {
|
||||
driverInstance?.moveNext()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: 'none',
|
||||
popover: {
|
||||
title: t('guide.end.title'),
|
||||
description: t('guide.end.description'),
|
||||
side: 'top',
|
||||
align: 'center',
|
||||
onNextClick: async (): Promise<void> => {
|
||||
navigate('/profiles')
|
||||
setTimeout(() => {
|
||||
driverInstance?.destroy()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const tourShown = window.localStorage.getItem('tourShown')
|
||||
if (!tourShown) {
|
||||
window.localStorage.setItem('tourShown', 'true')
|
||||
firstDriver.drive()
|
||||
driverInstance.drive()
|
||||
}
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
setNativeTheme(appTheme)
|
||||
@ -104,6 +298,18 @@ const App: React.FC = () => {
|
||||
})
|
||||
}, [customTheme])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mouseup', onResizeEnd)
|
||||
return (): void => window.removeEventListener('mouseup', onResizeEnd)
|
||||
}, [])
|
||||
|
||||
const onResizeEnd = (): void => {
|
||||
if (resizingRef.current) {
|
||||
setResizing(false)
|
||||
patchAppConfig({ siderWidth: siderWidthValueRef.current })
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnd = async (event: DragEndEvent): Promise<void> => {
|
||||
const { active, over } = event
|
||||
if (over) {
|
||||
@ -138,33 +344,55 @@ const App: React.FC = () => {
|
||||
}
|
||||
|
||||
const componentMap = {
|
||||
sysproxy: <SysproxySwitcher key="sysproxy" />,
|
||||
tun: <TunSwitcher key="tun" />,
|
||||
profile: <ProfileCard key="profile" />,
|
||||
proxy: <ProxyCard key="proxy" />,
|
||||
mihomo: <MihomoCoreCard key="mihomo" />,
|
||||
connection: <ConnCard key="connection" />,
|
||||
dns: <DNSCard key="dns" />,
|
||||
sniff: <SniffCard key="sniff" />,
|
||||
log: <LogCard key="log" />,
|
||||
rule: <RuleCard key="rule" />,
|
||||
resource: <ResourceCard key="resource" />,
|
||||
override: <OverrideCard key="override" />,
|
||||
substore: <SubStoreCard key="substore" />
|
||||
sysproxy: SysproxySwitcher,
|
||||
tun: TunSwitcher,
|
||||
profile: ProfileCard,
|
||||
proxy: ProxyCard,
|
||||
mihomo: MihomoCoreCard,
|
||||
connection: ConnCard,
|
||||
dns: DNSCard,
|
||||
sniff: SniffCard,
|
||||
log: LogCard,
|
||||
rule: RuleCard,
|
||||
resource: ResourceCard,
|
||||
override: OverrideCard,
|
||||
substore: SubStoreCard
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-[100vh] flex">
|
||||
<div className="side w-[250px] h-full overflow-y-auto no-scrollbar">
|
||||
<div className="app-drag sticky top-0 z-40 backdrop-blur bg-transparent h-[49px]">
|
||||
<div
|
||||
className={`flex justify-between p-2 ${!useWindowFrame && platform === 'darwin' ? 'ml-[60px]' : ''}`}
|
||||
>
|
||||
<div className="flex ml-1">
|
||||
<div
|
||||
onMouseMove={(e) => {
|
||||
if (!resizing) return
|
||||
if (e.clientX <= 150) {
|
||||
setSiderWidthValue(narrowWidth)
|
||||
} else if (e.clientX <= 250) {
|
||||
setSiderWidthValue(250)
|
||||
} else if (e.clientX >= 400) {
|
||||
setSiderWidthValue(400)
|
||||
} else {
|
||||
setSiderWidthValue(e.clientX)
|
||||
}
|
||||
}}
|
||||
className={`w-full h-[100vh] flex ${resizing ? 'cursor-ew-resize' : ''}`}
|
||||
>
|
||||
{siderWidthValue === narrowWidth ? (
|
||||
<div style={{ width: `${narrowWidth}px` }} className="side h-full">
|
||||
<div className="app-drag flex justify-center items-center z-40 bg-transparent h-[49px]">
|
||||
{platform !== 'darwin' && (
|
||||
<MihomoIcon className="h-[32px] leading-[32px] text-lg mx-[1px]" />
|
||||
<h3 className="text-lg font-bold leading-[32px]">ihomo Party</h3>
|
||||
)}
|
||||
<UpdaterButton iconOnly={true} />
|
||||
</div>
|
||||
<div className="h-[calc(100%-110px)] overflow-y-auto no-scrollbar">
|
||||
<div className="h-full w-full flex flex-col gap-2">
|
||||
{order.map((key: string) => {
|
||||
const Component = componentMap[key]
|
||||
if (!Component) return null
|
||||
return <Component key={key} iconOnly={true} />
|
||||
})}
|
||||
</div>
|
||||
<UpdaterButton />
|
||||
</div>
|
||||
<div className="mt-2 flex justify-center items-center h-[48px]">
|
||||
<Button
|
||||
size="sm"
|
||||
className="app-nodrag"
|
||||
@ -174,215 +402,79 @@ const App: React.FC = () => {
|
||||
onPress={() => {
|
||||
navigate('/settings')
|
||||
}}
|
||||
startContent={<IoSettings className="text-[20px]" />}
|
||||
/>
|
||||
>
|
||||
<IoSettings className="text-[20px]" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 mx-2">
|
||||
<OutboundModeSwitcher />
|
||||
</div>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragEnd={onDragEnd}>
|
||||
<div className="grid grid-cols-2 gap-2 m-2">
|
||||
<SortableContext items={order}>
|
||||
{order.map((key: string) => {
|
||||
return componentMap[key]
|
||||
})}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<div
|
||||
style={{ width: `${siderWidthValue}px` }}
|
||||
className="side h-full overflow-y-auto no-scrollbar"
|
||||
>
|
||||
<div className="app-drag sticky top-0 z-40 backdrop-blur bg-transparent h-[49px]">
|
||||
<div
|
||||
className={`flex justify-between p-2 ${!useWindowFrame && platform === 'darwin' ? 'ml-[60px]' : ''}`}
|
||||
>
|
||||
<div className="flex ml-1">
|
||||
<MihomoIcon className="h-[32px] leading-[32px] text-lg mx-[1px]" />
|
||||
<h3 className="text-lg font-bold leading-[32px]">ihomo Party</h3>
|
||||
</div>
|
||||
<UpdaterButton />
|
||||
<Button
|
||||
size="sm"
|
||||
className="app-nodrag"
|
||||
isIconOnly
|
||||
color={location.pathname.includes('/settings') ? 'primary' : 'default'}
|
||||
variant={location.pathname.includes('/settings') ? 'solid' : 'light'}
|
||||
onPress={() => {
|
||||
navigate('/settings')
|
||||
}}
|
||||
>
|
||||
<IoSettings className="text-[20px]" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
<div className="mt-2 mx-2">
|
||||
<OutboundModeSwitcher />
|
||||
</div>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragEnd={onDragEnd}>
|
||||
<div className="grid grid-cols-2 gap-2 m-2">
|
||||
<SortableContext items={order}>
|
||||
{order.map((key: string) => {
|
||||
const Component = componentMap[key]
|
||||
if (!Component) return null
|
||||
return <Component key={key} />
|
||||
})}
|
||||
</SortableContext>
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
onMouseDown={() => {
|
||||
setResizing(true)
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
zIndex: 50,
|
||||
left: `${siderWidthValue - 2}px`,
|
||||
width: '5px',
|
||||
height: '100vh',
|
||||
cursor: 'ew-resize'
|
||||
}}
|
||||
className={resizing ? 'bg-primary' : ''}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
<div className="main w-[calc(100%-251px)] h-full overflow-y-auto">{page}</div>
|
||||
<div
|
||||
style={{ width: `calc(100% - ${siderWidthValue + 1}px)` }}
|
||||
className="main grow h-full overflow-y-auto"
|
||||
>
|
||||
{page}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
export const firstDriver = driver({
|
||||
showProgress: true,
|
||||
nextBtnText: '下一步',
|
||||
prevBtnText: '上一步',
|
||||
doneBtnText: '完成',
|
||||
progressText: '{{current}} / {{total}}',
|
||||
overlayOpacity: 0.9,
|
||||
steps: [
|
||||
{
|
||||
element: 'none',
|
||||
popover: {
|
||||
title: '欢迎使用 Mihomo Party',
|
||||
description:
|
||||
'这是一份交互式使用教程,如果您已经完全熟悉本软件的操作,可以直接点击右上角关闭按钮,后续您可以随时从设置中打开本教程',
|
||||
side: 'over',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.side',
|
||||
popover: {
|
||||
title: '导航栏',
|
||||
description:
|
||||
'左侧是应用的导航栏,兼顾仪表盘功能,在这里可以切换不同页面,也可以概览常用的状态信息',
|
||||
side: 'right',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.sysproxy-card',
|
||||
popover: {
|
||||
title: '卡片',
|
||||
description: '点击导航栏卡片可以跳转到对应页面,拖动导航栏卡片可以自由排列卡片顺序',
|
||||
side: 'right',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.main',
|
||||
popover: {
|
||||
title: '主要区域',
|
||||
description: '右侧是应用的主要区域,展示了导航栏所选页面的内容',
|
||||
side: 'left',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.profile-card',
|
||||
popover: {
|
||||
title: '订阅管理',
|
||||
description:
|
||||
'订阅管理卡片展示当前运行的订阅配置信息,点击进入订阅管理页面可以在这里管理订阅配置',
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
onNextClick: async (): Promise<void> => {
|
||||
navigate('/profiles')
|
||||
setTimeout(() => {
|
||||
firstDriver.moveNext()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.profiles-sticky',
|
||||
popover: {
|
||||
title: '订阅导入',
|
||||
description:
|
||||
'Mihomo Party 支持多种订阅导入方式,在此输入订阅链接,点击导入即可导入您的订阅配置,如果您的订阅需要代理才能更新,请勾选“代理”再点击导入,当然这需要已经有一个可以正常使用的订阅才可以',
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.substore-import',
|
||||
popover: {
|
||||
title: 'Sub-Store',
|
||||
description:
|
||||
'Mihomo Party 深度集成了 Sub-Store,您可以点击该按钮进入 Sub-Store 或直接导入您通过 Sub-Store 管理的订阅,Mihomo Party 默认使用内置的 Sub-Store 后端,如果您有自建的 Sub-Store 后端,可以在设置页面中配置,如果您不使用 Sub-Store 也可以在设置页面中关闭',
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.new-profile',
|
||||
popover: {
|
||||
title: '本地订阅',
|
||||
description: '点击“+”可以选择本地文件进行导入或者直接新建空白配置进行编辑',
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.sysproxy-card',
|
||||
popover: {
|
||||
title: '系统代理',
|
||||
description:
|
||||
'导入订阅之后,内核已经开始运行并监听指定端口,此时您已经可以通过指定代理端口来使用代理了,如果您要使大部分应用自动使用该端口的代理,您还需要打开系统代理开关',
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
onNextClick: async (): Promise<void> => {
|
||||
navigate('/sysproxy')
|
||||
setTimeout(() => {
|
||||
firstDriver.moveNext()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.sysproxy-settings',
|
||||
popover: {
|
||||
title: '系统代理设置',
|
||||
description:
|
||||
'在此您可以进行系统代理相关设置,选择代理模式,如果某些 Windows 应用不遵循系统代理,还可以使用“UWP 工具”解除本地回环限制,对于“手动代理模式”和“PAC 代理模式”的区别,请自行百度',
|
||||
side: 'top',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.tun-card',
|
||||
popover: {
|
||||
title: '虚拟网卡',
|
||||
description:
|
||||
'虚拟网卡,即同类软件中常见的“Tun 模式”,对于某些不遵循系统代理的应用,您可以打开虚拟网卡以让内核接管所有流量',
|
||||
side: 'right',
|
||||
align: 'start',
|
||||
onNextClick: async (): Promise<void> => {
|
||||
navigate('/tun')
|
||||
setTimeout(() => {
|
||||
firstDriver.moveNext()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.tun-settings',
|
||||
popover: {
|
||||
title: '虚拟网卡设置',
|
||||
description:
|
||||
'这里可以更改虚拟网卡相关设置,Mihomo Party 理论上已经完全解决权限问题,如果您的虚拟网卡仍然不可用,可以尝试重设防火墙(Windows)或手动授权内核(MacOS/Linux)后重启内核',
|
||||
side: 'bottom',
|
||||
align: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.override-card',
|
||||
popover: {
|
||||
title: '覆写',
|
||||
description:
|
||||
'Mihomo Party 提供强大的覆写功能,可以对您导入的订阅配置进行个性化修改,如添加规则、自定义代理组等,您可以直接导入别人写好的覆写文件,也可以自己动手编写,<b>编辑好覆写文件一定要记得在需要覆写的订阅上启用</b>,覆写文件的语法请参考 <a href="https://mihomo.party/docs/guide/override" target="_blank">官方文档</a>',
|
||||
side: 'right',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
element: '.dns-card',
|
||||
popover: {
|
||||
title: 'DNS',
|
||||
description:
|
||||
'软件默认接管了内核的 DNS 设置,如果您需要使用订阅配置中的 DNS 设置,可以到应用设置中关闭“接管 DNS 设置”,域名嗅探同理',
|
||||
side: 'right',
|
||||
align: 'center',
|
||||
onNextClick: async (): Promise<void> => {
|
||||
navigate('/profiles')
|
||||
setTimeout(() => {
|
||||
firstDriver.moveNext()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
element: 'none',
|
||||
popover: {
|
||||
title: '教程结束',
|
||||
description:
|
||||
'现在您已经了解了软件的基本用法,导入您的订阅开始使用吧,祝您使用愉快!\n您还可以加入我们的官方 <a href="https://t.me/mihomo_party_channel" target="_blank">Telegram 频道</a> 获取最新资讯',
|
||||
side: 'top',
|
||||
align: 'center',
|
||||
onNextClick: async (): Promise<void> => {
|
||||
navigate('/profiles')
|
||||
setTimeout(() => {
|
||||
firstDriver.destroy()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
101
src/renderer/src/FloatingApp.tsx
Normal file
101
src/renderer/src/FloatingApp.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import MihomoIcon from './components/base/mihomo-icon'
|
||||
import { calcTraffic } from './utils/calc'
|
||||
import { showContextMenu, triggerMainWindow } from './utils/ipc'
|
||||
import { useAppConfig } from './hooks/use-app-config'
|
||||
import { useControledMihomoConfig } from './hooks/use-controled-mihomo-config'
|
||||
|
||||
const FloatingApp: React.FC = () => {
|
||||
const { appConfig } = useAppConfig()
|
||||
const { controledMihomoConfig } = useControledMihomoConfig()
|
||||
const { sysProxy, spinFloatingIcon = true } = appConfig || {}
|
||||
const { tun } = controledMihomoConfig || {}
|
||||
const sysProxyEnabled = sysProxy?.enable
|
||||
const tunEnabled = tun?.enable
|
||||
|
||||
const [upload, setUpload] = useState(0)
|
||||
const [download, setDownload] = useState(0)
|
||||
|
||||
// 根据总速率计算旋转速度
|
||||
const spinSpeed = useMemo(() => {
|
||||
const total = upload + download
|
||||
if (total === 0) return 0
|
||||
if (total < 1024) return 2
|
||||
if (total < 1024 * 1024) return 3
|
||||
if (total < 1024 * 1024 * 1024) return 4
|
||||
return 5
|
||||
}, [upload, download])
|
||||
|
||||
const [rotation, setRotation] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!spinFloatingIcon) return
|
||||
|
||||
let animationFrameId: number
|
||||
const animate = (): void => {
|
||||
setRotation((prev) => {
|
||||
if (prev === 360) {
|
||||
return 0
|
||||
}
|
||||
return prev + spinSpeed
|
||||
})
|
||||
animationFrameId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate)
|
||||
return (): void => {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
}, [spinSpeed, spinFloatingIcon])
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.on('mihomoTraffic', async (_e, info: IMihomoTrafficInfo) => {
|
||||
setUpload(info.up)
|
||||
setDownload(info.down)
|
||||
})
|
||||
return (): void => {
|
||||
window.electron.ipcRenderer.removeAllListeners('mihomoTraffic')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="app-drag h-[100vh] w-[100vw] overflow-hidden">
|
||||
<div className="floating-bg border-1 border-divider flex rounded-full bg-content1 h-[calc(100%-2px)] w-[calc(100%-2px)]">
|
||||
<div className="flex justify-center items-center h-[100%] aspect-square">
|
||||
<div
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
showContextMenu()
|
||||
}}
|
||||
onClick={() => {
|
||||
triggerMainWindow()
|
||||
}}
|
||||
style={
|
||||
spinFloatingIcon
|
||||
? {
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transition: 'transform 0.1s linear'
|
||||
}
|
||||
: {}
|
||||
}
|
||||
className={`app-nodrag cursor-pointer floating-thumb ${tunEnabled ? 'bg-secondary' : sysProxyEnabled ? 'bg-primary' : 'bg-default'} hover:opacity-hover rounded-full h-[calc(100%-4px)] aspect-square`}
|
||||
>
|
||||
<MihomoIcon className="floating-icon text-primary-foreground h-full leading-full text-[22px] mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full overflow-hidden">
|
||||
<div className="flex flex-col justify-center h-full w-full">
|
||||
<h2 className="text-end floating-text whitespace-nowrap text-[12px] mr-2 font-bold">
|
||||
{calcTraffic(upload)}/s
|
||||
</h2>
|
||||
<h2 className="text-end floating-text whitespace-nowrap text-[12px] mr-2 font-bold">
|
||||
{calcTraffic(download)}/s
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FloatingApp
|
||||
28
src/renderer/src/assets/floating.css
Normal file
28
src/renderer/src/assets/floating.css
Normal file
@ -0,0 +1,28 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.floating-text {
|
||||
font-family:
|
||||
'Microsoft YaHei',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
}
|
||||
|
||||
html {
|
||||
background: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.app-nodrag {
|
||||
-webkit-app-region: none;
|
||||
}
|
||||
|
||||
.app-drag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
@ -8,22 +8,22 @@
|
||||
}
|
||||
|
||||
.driver-popover {
|
||||
background-color: hsl(var(--nextui-content2)) !important;
|
||||
background-color: hsl(var(--heroui-content2)) !important;
|
||||
border-radius: 8px !important;
|
||||
color: hsl(var(--nextui-foreground)) !important;
|
||||
color: hsl(var(--heroui-foreground)) !important;
|
||||
}
|
||||
|
||||
.driver-popover a {
|
||||
color: hsl(var(--nextui-primary)) !important;
|
||||
color: hsl(var(--heroui-primary)) !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.driver-popover-close-btn {
|
||||
color: hsl(var(--nextui-foreground)) !important;
|
||||
color: hsl(var(--heroui-foreground)) !important;
|
||||
}
|
||||
|
||||
.driver-popover-progress-text {
|
||||
color: hsl(var(--nextui-default-500)) !important;
|
||||
color: hsl(var(--heroui-default-500)) !important;
|
||||
}
|
||||
|
||||
.driver-popover-prev-btn {
|
||||
@ -33,7 +33,7 @@
|
||||
padding: 8px !important;
|
||||
border-radius: 5px !important;
|
||||
font-size: 12px !important;
|
||||
background-color: hsl(var(--nextui-primary)) !important;
|
||||
background-color: hsl(var(--heroui-primary)) !important;
|
||||
}
|
||||
|
||||
.driver-popover-next-btn {
|
||||
@ -43,23 +43,23 @@
|
||||
padding: 8px !important;
|
||||
border-radius: 5px !important;
|
||||
font-size: 12px !important;
|
||||
background-color: hsl(var(--nextui-primary)) !important;
|
||||
background-color: hsl(var(--heroui-primary)) !important;
|
||||
}
|
||||
|
||||
.driver-popover-arrow-side-bottom {
|
||||
border-bottom-color: hsl(var(--nextui-content2)) !important;
|
||||
border-bottom-color: hsl(var(--heroui-content2)) !important;
|
||||
}
|
||||
|
||||
.driver-popover-arrow-side-top {
|
||||
border-top-color: hsl(var(--nextui-content2)) !important;
|
||||
border-top-color: hsl(var(--heroui-content2)) !important;
|
||||
}
|
||||
|
||||
.driver-popover-arrow-side-left {
|
||||
border-left-color: hsl(var(--nextui-content2)) !important;
|
||||
border-left-color: hsl(var(--heroui-content2)) !important;
|
||||
}
|
||||
|
||||
.driver-popover-arrow-side-right {
|
||||
border-right-color: hsl(var(--nextui-content2)) !important;
|
||||
border-right-color: hsl(var(--heroui-content2)) !important;
|
||||
}
|
||||
|
||||
.app-nodrag {
|
||||
|
||||
37
src/renderer/src/components/base/base-confirm-modal.tsx
Normal file
37
src/renderer/src/components/base/base-confirm-modal.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
content: string
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
const BaseConfirmModal: React.FC<Props> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const { title, content, onCancel, onConfirm, isOpen } = props
|
||||
|
||||
return (
|
||||
<Modal backdrop="blur" classNames={{ backdrop: 'top-[48px]' }} hideCloseButton isOpen={isOpen}>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex app-drag">{title}</ModalHeader>
|
||||
<ModalBody>
|
||||
<p>{content}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button size="sm" variant="light" onPress={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" color="danger" onPress={onConfirm}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default BaseConfirmModal
|
||||
@ -7,7 +7,7 @@ import pac from 'types-pac/pac.d.ts?raw'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { nanoid } from 'nanoid'
|
||||
import React from 'react'
|
||||
type Language = 'yaml' | 'javascript' | 'css'
|
||||
type Language = 'yaml' | 'javascript' | 'css' | 'json' | 'text'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
@ -89,7 +89,7 @@ export const BaseEditor: React.FC<Props> = (props) => {
|
||||
const trueTheme = theme === 'system' ? systemTheme : theme
|
||||
const { value, readOnly = false, language, onChange } = props
|
||||
|
||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>()
|
||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>(undefined)
|
||||
|
||||
const editorWillMount = (): void => {
|
||||
monacoInitialization()
|
||||
@ -105,7 +105,9 @@ export const BaseEditor: React.FC<Props> = (props) => {
|
||||
|
||||
useEffect(() => {
|
||||
window.onresize = (): void => {
|
||||
editorRef.current?.layout()
|
||||
setTimeout(() => {
|
||||
editorRef.current?.layout()
|
||||
}, 0)
|
||||
}
|
||||
return (): void => {
|
||||
window.onresize = null
|
||||
@ -123,9 +125,9 @@ export const BaseEditor: React.FC<Props> = (props) => {
|
||||
options={{
|
||||
tabSize: ['yaml', 'javascript', 'json'].includes(language) ? 2 : 4, // 根据语言类型设置缩进大小
|
||||
minimap: {
|
||||
enabled: document.documentElement.clientWidth >= 1500 // 超过一定宽度显示minimap滚动条
|
||||
enabled: document.documentElement.clientWidth >= 1500 // 超过一定宽度显示 minimap 滚动条
|
||||
},
|
||||
mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
|
||||
mouseWheelZoom: true, // 按住 Ctrl 滚轮调节缩放比例
|
||||
readOnly: readOnly, // 只读模式
|
||||
renderValidationDecorations: 'on', // 只读模式下显示校验信息
|
||||
quickSuggestions: {
|
||||
@ -139,6 +141,7 @@ export const BaseEditor: React.FC<Props> = (props) => {
|
||||
}}
|
||||
editorWillMount={editorWillMount}
|
||||
editorDidMount={editorDidMount}
|
||||
editorWillUnmount={(): void => { }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import { Button } from '@nextui-org/react'
|
||||
import { Button } from '@heroui/react'
|
||||
import { ReactNode } from 'react'
|
||||
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ErrorFallback = ({ error }: FallbackProps): JSX.Element => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="my-2 text-lg font-bold">
|
||||
{'应用崩溃了 :( 请将以下信息提交给开发者以排查错误'}
|
||||
{t('common.error.appCrash')}
|
||||
</h2>
|
||||
|
||||
<Button
|
||||
@ -22,7 +25,7 @@ const ErrorFallback = ({ error }: FallbackProps): JSX.Element => {
|
||||
color="primary"
|
||||
variant="flat"
|
||||
className="ml-2"
|
||||
onPress={() => open('https://t.me/mihomo_party')}
|
||||
onPress={() => open('https://t.me/mihomo_party_group')}
|
||||
>
|
||||
Telegram
|
||||
</Button>
|
||||
@ -35,7 +38,7 @@ const ErrorFallback = ({ error }: FallbackProps): JSX.Element => {
|
||||
navigator.clipboard.writeText('```\n' + error.message + '\n' + error.stack + '\n```')
|
||||
}
|
||||
>
|
||||
复制报错信息
|
||||
{t('common.error.copyErrorMessage')}
|
||||
</Button>
|
||||
|
||||
<p className="my-2">{error.message}</p>
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { Button, Divider } from '@nextui-org/react'
|
||||
import { Button, Divider } from '@heroui/react'
|
||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||
import { platform } from '@renderer/utils/init'
|
||||
import { isAlwaysOnTop, setAlwaysOnTop } from '@renderer/utils/ipc'
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { RiPushpin2Fill, RiPushpin2Line } from 'react-icons/ri'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
title?: React.ReactNode
|
||||
header?: React.ReactNode
|
||||
@ -13,6 +15,7 @@ interface Props {
|
||||
let saveOnTop = false
|
||||
|
||||
const BasePage = forwardRef<HTMLDivElement, Props>((props, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { appConfig } = useAppConfig()
|
||||
const { useWindowFrame = false } = appConfig || {}
|
||||
const [overlayWidth, setOverlayWidth] = React.useState(0)
|
||||
@ -51,7 +54,7 @@ const BasePage = forwardRef<HTMLDivElement, Props>((props, ref) => {
|
||||
size="sm"
|
||||
className="app-nodrag"
|
||||
isIconOnly
|
||||
title="窗口置顶"
|
||||
title={t('common.pinWindow')}
|
||||
variant="light"
|
||||
color={onTop ? 'primary' : 'default'}
|
||||
onPress={async () => {
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Button,
|
||||
Input
|
||||
} from '@nextui-org/react'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
onCancel: () => void
|
||||
onConfirm: (script: string) => void
|
||||
}
|
||||
|
||||
const BasePasswordModal: React.FC<Props> = (props) => {
|
||||
const { onCancel, onConfirm } = props
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
return (
|
||||
<Modal backdrop="blur" classNames={{ backdrop: 'top-[48px]' }} hideCloseButton isOpen={true}>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex">请输入root密码</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input fullWidth type="password" value={password} onValueChange={setPassword} />
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="primary" onPress={() => onConfirm(password)}>
|
||||
确认
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default BasePasswordModal
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Accordion, AccordionItem, Card, CardBody } from '@nextui-org/react'
|
||||
import { Accordion, AccordionItem, Card, CardBody } from '@heroui/react'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Divider } from '@nextui-org/react'
|
||||
import { Divider } from '@heroui/react'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
@ -14,7 +14,7 @@ const SettingItem: React.FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-[32px] w-full flex justify-between">
|
||||
<div className="select-text h-[32px] w-full flex justify-between">
|
||||
<div className="h-full flex items-center">
|
||||
<h4 className="h-full text-md leading-[32px] whitespace-nowrap">{title}</h4>
|
||||
<div>{actions}</div>
|
||||
|
||||
5
src/renderer/src/components/base/border-switch.css
Normal file
5
src/renderer/src/components/base/border-switch.css
Normal file
@ -0,0 +1,5 @@
|
||||
.border-switch {
|
||||
input[type='checkbox'] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@ -1,24 +1,28 @@
|
||||
import React from 'react'
|
||||
import { cn, Switch, SwitchProps } from '@nextui-org/react'
|
||||
import { cn, Switch, SwitchProps } from '@heroui/react'
|
||||
import './border-switch.css'
|
||||
|
||||
interface SiderSwitchProps extends SwitchProps {
|
||||
interface BorderSwitchProps extends Omit<SwitchProps, 'isSelected'> {
|
||||
isShowBorder?: boolean
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
const BorderSwitch: React.FC<SiderSwitchProps> = (props) => {
|
||||
const { isShowBorder = false, classNames, ...switchProps } = props
|
||||
const BorderSwitch: React.FC<BorderSwitchProps> = (props) => {
|
||||
const { isShowBorder = false, isSelected = false, classNames, ...switchProps } = props
|
||||
|
||||
return (
|
||||
<Switch
|
||||
className="border-switch px-[8px]"
|
||||
classNames={{
|
||||
wrapper: cn('border-2', {
|
||||
'border-transparent': !isShowBorder,
|
||||
'border-white': isShowBorder
|
||||
'border-primary-foreground': isShowBorder
|
||||
}),
|
||||
thumb: cn('absolute z-4', 'transform -translate-x-[2px]'),
|
||||
...classNames
|
||||
}}
|
||||
size="sm"
|
||||
isSelected={isSelected}
|
||||
{...switchProps}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { Input, InputProps } from '@nextui-org/react'
|
||||
import { Input, InputProps } from '@heroui/react'
|
||||
import { FaSearch } from 'react-icons/fa'
|
||||
|
||||
interface CollapseInputProps extends InputProps {
|
||||
@ -22,7 +22,7 @@ const CollapseInput: React.FC<CollapseInputProps> = (props) => {
|
||||
}}
|
||||
endContent={
|
||||
<div
|
||||
className="cursor-pointer p-2 text-lg text-default-500"
|
||||
className="cursor-pointer p-2 text-lg text-foreground-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
inputRef.current?.focus()
|
||||
@ -31,7 +31,7 @@ const CollapseInput: React.FC<CollapseInputProps> = (props) => {
|
||||
<FaSearch title={title} />
|
||||
</div>
|
||||
}
|
||||
onClick={(e) => {
|
||||
onPress={(e) => {
|
||||
e.stopPropagation()
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
|
||||
@ -1,15 +1,129 @@
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@nextui-org/react'
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownTrigger,
|
||||
DropdownMenu,
|
||||
DropdownItem
|
||||
} from '@heroui/react'
|
||||
import React from 'react'
|
||||
import SettingItem from '../base/base-setting-item'
|
||||
import { calcTraffic } from '@renderer/utils/calc'
|
||||
import dayjs from 'dayjs'
|
||||
import dayjs from '@renderer/utils/dayjs'
|
||||
import { BiCopy } from 'react-icons/bi'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
connection: IMihomoConnectionDetail
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const CopyableSettingItem: React.FC<{
|
||||
title: string
|
||||
value: string | string[]
|
||||
displayName?: string
|
||||
prefix?: string[]
|
||||
}> = ({ title, value, displayName, prefix = [] }) => {
|
||||
const { t } = useTranslation()
|
||||
const getSubDomains = (domain: string): string[] =>
|
||||
domain.split('.').length <= 2
|
||||
? [domain]
|
||||
: domain
|
||||
.split('.')
|
||||
.map((_, i, parts) => parts.slice(i).join('.'))
|
||||
.slice(0, -1)
|
||||
const isIPv6 = (ip: string) => ip.includes(':')
|
||||
|
||||
const menuItems = [
|
||||
{ key: 'raw', text: displayName || (Array.isArray(value) ? value.join(', ') : value) },
|
||||
...(Array.isArray(value)
|
||||
? value.map((v, i) => {
|
||||
const p = prefix[i]
|
||||
if (!p || !v) return null
|
||||
|
||||
if (p === 'DOMAIN-SUFFIX') {
|
||||
return getSubDomains(v).map((subV) => ({
|
||||
key: `${p},${subV}`,
|
||||
text: `${p},${subV}`
|
||||
}))
|
||||
}
|
||||
|
||||
if (p === 'IP-ASN' || p === 'SRC-IP-ASN') {
|
||||
return {
|
||||
key: `${p},${v.split(' ')[0]}`,
|
||||
text: `${p},${v.split(' ')[0]}`
|
||||
}
|
||||
}
|
||||
|
||||
const suffix = (p === 'IP-CIDR' || p === 'SRC-IP-CIDR') ? (isIPv6(v) ? '/128' : '/32') : ''
|
||||
return {
|
||||
key: `${p},${v}${suffix}`,
|
||||
text: `${p},${v}${suffix}`
|
||||
}
|
||||
}).filter(Boolean).flat()
|
||||
: prefix.map(p => {
|
||||
const v = value as string
|
||||
if (p === 'DOMAIN-SUFFIX') {
|
||||
return getSubDomains(v).map((subV) => ({
|
||||
key: `${p},${subV}`,
|
||||
text: `${p},${subV}`
|
||||
}))
|
||||
}
|
||||
|
||||
if (p === 'IP-ASN' || p === 'SRC-IP-ASN') {
|
||||
return {
|
||||
key: `${p},${v.split(' ')[0]}`,
|
||||
text: `${p},${v.split(' ')[0]}`
|
||||
}
|
||||
}
|
||||
|
||||
const suffix = (p === 'IP-CIDR' || p === 'SRC-IP-CIDR') ? (isIPv6(v) ? '/128' : '/32') : ''
|
||||
return {
|
||||
key: `${p},${v}${suffix}`,
|
||||
text: `${p},${v}${suffix}`
|
||||
}
|
||||
}).flat())
|
||||
]
|
||||
|
||||
return (
|
||||
<SettingItem
|
||||
title={title}
|
||||
actions={
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button title={t('connections.detail.copyRule')} isIconOnly size="sm" variant="light">
|
||||
<BiCopy className="text-lg" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
onAction={(key) =>
|
||||
navigator.clipboard.writeText(
|
||||
key === 'raw' ? (Array.isArray(value) ? value.join(', ') : value) : (key as string)
|
||||
)
|
||||
}
|
||||
>
|
||||
{menuItems
|
||||
.filter((item) => item !== null)
|
||||
.map(({ key, text }) => (
|
||||
<DropdownItem key={key}>{text}</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
>
|
||||
{displayName || (Array.isArray(value) ? value.join(', ') : value)}
|
||||
</SettingItem>
|
||||
)
|
||||
}
|
||||
|
||||
const ConnectionDetailModal: React.FC<Props> = (props) => {
|
||||
const { connection, onClose } = props
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
@ -21,83 +135,164 @@ const ConnectionDetailModal: React.FC<Props> = (props) => {
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent className="flag-emoji break-all">
|
||||
<ModalHeader className="flex">连接详情</ModalHeader>
|
||||
<ModalHeader className="flex app-drag">{t('connections.detail.title')}</ModalHeader>
|
||||
<ModalBody>
|
||||
<SettingItem title="连接类型">
|
||||
{connection.metadata.type}({connection.metadata.network})
|
||||
</SettingItem>
|
||||
<SettingItem title="连接建立时间">{dayjs(connection.start).fromNow()}</SettingItem>
|
||||
<SettingItem title="规则">
|
||||
<SettingItem title={t('connections.detail.establishTime')}>{dayjs(connection.start).fromNow()}</SettingItem>
|
||||
<SettingItem title={t('connections.detail.rule')}>
|
||||
{connection.rule}
|
||||
{connection.rulePayload ? `(${connection.rulePayload})` : ''}
|
||||
</SettingItem>
|
||||
<SettingItem title="代理链">{[...connection.chains].reverse().join('>>')}</SettingItem>
|
||||
<SettingItem title="上传速度">{calcTraffic(connection.uploadSpeed || 0)}/s</SettingItem>
|
||||
<SettingItem title="下载速度">{calcTraffic(connection.downloadSpeed || 0)}/s</SettingItem>
|
||||
<SettingItem title="上传量">{calcTraffic(connection.upload)}</SettingItem>
|
||||
<SettingItem title="下载量">{calcTraffic(connection.download)}</SettingItem>
|
||||
<SettingItem title={t('connections.detail.proxyChain')}>{[...connection.chains].reverse().join('>>')}</SettingItem>
|
||||
<SettingItem title={t('connections.uploadSpeed')}>{calcTraffic(connection.uploadSpeed || 0)}/s</SettingItem>
|
||||
<SettingItem title={t('connections.downloadSpeed')}>{calcTraffic(connection.downloadSpeed || 0)}/s</SettingItem>
|
||||
<SettingItem title={t('connections.uploadAmount')}>{calcTraffic(connection.upload)}</SettingItem>
|
||||
<SettingItem title={t('connections.downloadAmount')}>{calcTraffic(connection.download)}</SettingItem>
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.connectionType')}
|
||||
value={[connection.metadata.type, connection.metadata.network]}
|
||||
displayName={`${connection.metadata.type}(${connection.metadata.network})`}
|
||||
prefix={['IN-TYPE', 'NETWORK']}
|
||||
/>
|
||||
{connection.metadata.host && (
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.host')}
|
||||
value={connection.metadata.host}
|
||||
prefix={['DOMAIN', 'DOMAIN-SUFFIX']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.sniffHost && (
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.sniffHost')}
|
||||
value={connection.metadata.sniffHost}
|
||||
prefix={['DOMAIN', 'DOMAIN-SUFFIX']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.process && (
|
||||
<SettingItem title="进程名">
|
||||
{connection.metadata.process}
|
||||
{connection.metadata.uid ? `(${connection.metadata.uid})` : ''}
|
||||
</SettingItem>
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.processName')}
|
||||
value={[
|
||||
connection.metadata.process,
|
||||
...(connection.metadata.uid ? [connection.metadata.uid.toString()] : [])
|
||||
]}
|
||||
displayName={`${connection.metadata.process}${connection.metadata.uid ? `(${connection.metadata.uid})` : ''}`}
|
||||
prefix={['PROCESS-NAME', ...(connection.metadata.uid ? ['UID'] : [])]}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.processPath && (
|
||||
<SettingItem title="进程路径">{connection.metadata.processPath}</SettingItem>
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.processPath')}
|
||||
value={connection.metadata.processPath}
|
||||
prefix={['PROCESS-PATH']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.sourceIP && (
|
||||
<SettingItem title="源IP">{connection.metadata.sourceIP}</SettingItem>
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.sourceIP')}
|
||||
value={connection.metadata.sourceIP}
|
||||
prefix={['SRC-IP-CIDR']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.sourceGeoIP && connection.metadata.sourceGeoIP.length > 0 && (
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.sourceGeoIP')}
|
||||
value={connection.metadata.sourceGeoIP}
|
||||
prefix={['SRC-GEOIP']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.sourceIPASN && (
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.sourceASN')}
|
||||
value={connection.metadata.sourceIPASN}
|
||||
prefix={['SRC-IP-ASN']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.destinationIP && (
|
||||
<SettingItem title="目标IP">{connection.metadata.destinationIP}</SettingItem>
|
||||
)}
|
||||
{connection.metadata.destinationGeoIP && (
|
||||
<SettingItem title="目标GeoIP">{connection.metadata.destinationGeoIP}</SettingItem>
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.destinationIP')}
|
||||
value={connection.metadata.destinationIP}
|
||||
prefix={['IP-CIDR']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.destinationGeoIP &&
|
||||
connection.metadata.destinationGeoIP.length > 0 && (
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.destinationGeoIP')}
|
||||
value={connection.metadata.destinationGeoIP}
|
||||
prefix={['GEOIP']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.destinationIPASN && (
|
||||
<SettingItem title="目标ASN">{connection.metadata.destinationIPASN}</SettingItem>
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.destinationASN')}
|
||||
value={connection.metadata.destinationIPASN}
|
||||
prefix={['IP-ASN']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.sourcePort && (
|
||||
<SettingItem title="源端口">{connection.metadata.sourcePort}</SettingItem>
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.sourcePort')}
|
||||
value={connection.metadata.sourcePort}
|
||||
prefix={['SRC-PORT']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.destinationPort && (
|
||||
<SettingItem title="目标端口">{connection.metadata.destinationPort}</SettingItem>
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.destinationPort')}
|
||||
value={connection.metadata.destinationPort}
|
||||
prefix={['DST-PORT']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.inboundIP && (
|
||||
<SettingItem title="入站IP">{connection.metadata.inboundIP}</SettingItem>
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.inboundIP')}
|
||||
value={connection.metadata.inboundIP}
|
||||
prefix={['SRC-IP-CIDR']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.inboundPort && (
|
||||
<SettingItem title="入站端口">{connection.metadata.inboundPort}</SettingItem>
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.inboundPort')}
|
||||
value={connection.metadata.inboundPort}
|
||||
prefix={['IN-PORT']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.inboundName && (
|
||||
<SettingItem title="入站名称">{connection.metadata.inboundName}</SettingItem>
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.inboundName')}
|
||||
value={connection.metadata.inboundName}
|
||||
prefix={['IN-NAME']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.inboundUser && (
|
||||
<SettingItem title="入站用户">{connection.metadata.inboundUser}</SettingItem>
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.inboundUser')}
|
||||
value={connection.metadata.inboundUser}
|
||||
prefix={['IN-USER']}
|
||||
/>
|
||||
)}
|
||||
{connection.metadata.host && (
|
||||
<SettingItem title="主机">{connection.metadata.host}</SettingItem>
|
||||
|
||||
<CopyableSettingItem
|
||||
title={t('connections.detail.dscp')}
|
||||
value={connection.metadata.dscp.toString()}
|
||||
prefix={['DSCP']}
|
||||
/>
|
||||
|
||||
{connection.metadata.remoteDestination && (
|
||||
<SettingItem title={t('connections.detail.remoteDestination')}>{connection.metadata.remoteDestination}</SettingItem>
|
||||
)}
|
||||
{connection.metadata.dnsMode && (
|
||||
<SettingItem title="DNS模式">{connection.metadata.dnsMode}</SettingItem>
|
||||
<SettingItem title={t('connections.detail.dnsMode')}>{connection.metadata.dnsMode}</SettingItem>
|
||||
)}
|
||||
{connection.metadata.specialProxy && (
|
||||
<SettingItem title="特殊代理">{connection.metadata.specialProxy}</SettingItem>
|
||||
<SettingItem title={t('connections.detail.specialProxy')}>{connection.metadata.specialProxy}</SettingItem>
|
||||
)}
|
||||
{connection.metadata.specialRules && (
|
||||
<SettingItem title="特殊规则">{connection.metadata.specialRules}</SettingItem>
|
||||
)}
|
||||
{connection.metadata.remoteDestination && (
|
||||
<SettingItem title="远程目标">{connection.metadata.remoteDestination}</SettingItem>
|
||||
)}
|
||||
<SettingItem title="DSCP">{connection.metadata.dscp}</SettingItem>
|
||||
{connection.metadata.sniffHost && (
|
||||
<SettingItem title="嗅探主机">{connection.metadata.sniffHost}</SettingItem>
|
||||
<SettingItem title={t('connections.detail.specialRules')}>{connection.metadata.specialRules}</SettingItem>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
关闭
|
||||
<Button size="sm" variant="light" onPress={onClose}>
|
||||
{t('connections.detail.close')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Button, Card, CardFooter, CardHeader, Chip } from '@nextui-org/react'
|
||||
import { Button, Card, CardFooter, CardHeader, Chip } from '@heroui/react'
|
||||
import { calcTraffic } from '@renderer/utils/calc'
|
||||
import dayjs from 'dayjs'
|
||||
import dayjs from '@renderer/utils/dayjs'
|
||||
import React, { useEffect } from 'react'
|
||||
import { CgClose, CgTrash } from 'react-icons/cg'
|
||||
|
||||
@ -24,70 +24,77 @@ const ConnectionItem: React.FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<div className={`px-2 pb-2 ${index === 0 ? 'pt-2' : ''}`}>
|
||||
<Card
|
||||
isPressable
|
||||
className="w-full"
|
||||
onPress={() => {
|
||||
setSelected(info)
|
||||
setIsDetailModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<div className="w-full flex justify-between">
|
||||
<div className="w-[calc(100%-48px)]">
|
||||
<CardHeader className="pb-0 gap-1">
|
||||
<Chip color={`${info.isActive ? "primary": "danger"}`} size="sm" radius="sm" variant="dot">
|
||||
{info.metadata.type}({info.metadata.network.toUpperCase()})
|
||||
</Chip>
|
||||
<div className="text-ellipsis whitespace-nowrap overflow-hidden">
|
||||
{info.metadata.process || info.metadata.sourceIP}
|
||||
{' -> '}
|
||||
{info.metadata.host ||
|
||||
info.metadata.sniffHost ||
|
||||
info.metadata.destinationIP ||
|
||||
info.metadata.remoteDestination}
|
||||
</div>
|
||||
<small className="whitespace-nowrap text-default-500">
|
||||
{dayjs(info.start).fromNow()}
|
||||
</small>
|
||||
</CardHeader>
|
||||
<CardFooter
|
||||
onWheel={(e) => {
|
||||
e.currentTarget.scrollLeft += e.deltaY
|
||||
}}
|
||||
className="overscroll-contain pt-2 flex justify-start gap-1 overflow-x-auto no-scrollbar"
|
||||
>
|
||||
<Chip
|
||||
className="flag-emoji text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="bordered"
|
||||
>
|
||||
{info.chains[0]}
|
||||
</Chip>
|
||||
<Chip size="sm" radius="sm" variant="bordered">
|
||||
↑ {calcTraffic(info.upload)} ↓ {calcTraffic(info.download)}
|
||||
</Chip>
|
||||
{info.uploadSpeed !== 0 || info.downloadSpeed !== 0 ? (
|
||||
<Chip color="primary" size="sm" radius="sm" variant="bordered">
|
||||
↑ {calcTraffic(info.uploadSpeed || 0)}/s ↓ {calcTraffic(info.downloadSpeed || 0)}
|
||||
/s
|
||||
<div className="relative">
|
||||
<Card
|
||||
isPressable
|
||||
className="w-full"
|
||||
onPress={() => {
|
||||
setSelected(info)
|
||||
setIsDetailModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="w-full pr-12">
|
||||
<CardHeader className="pb-0 gap-1">
|
||||
<Chip
|
||||
color={`${info.isActive ? 'primary' : 'danger'}`}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="dot"
|
||||
>
|
||||
{info.metadata.type}({info.metadata.network.toUpperCase()})
|
||||
</Chip>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
<div className="text-ellipsis whitespace-nowrap overflow-hidden">
|
||||
{info.metadata.process || info.metadata.sourceIP}
|
||||
{' -> '}
|
||||
{info.metadata.host ||
|
||||
info.metadata.sniffHost ||
|
||||
info.metadata.destinationIP ||
|
||||
info.metadata.remoteDestination}
|
||||
</div>
|
||||
<small className="whitespace-nowrap text-foreground-500">
|
||||
{dayjs(info.start).fromNow()}
|
||||
</small>
|
||||
</CardHeader>
|
||||
<CardFooter
|
||||
onWheel={(e) => {
|
||||
e.currentTarget.scrollLeft += e.deltaY
|
||||
}}
|
||||
className="overscroll-contain pt-2 flex justify-start gap-1 overflow-x-auto no-scrollbar"
|
||||
>
|
||||
<Chip
|
||||
className="flag-emoji text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="bordered"
|
||||
>
|
||||
{info.chains[0]}
|
||||
</Chip>
|
||||
<Chip size="sm" radius="sm" variant="bordered">
|
||||
↑ {calcTraffic(info.upload)} ↓ {calcTraffic(info.download)}
|
||||
</Chip>
|
||||
{info.uploadSpeed !== 0 || info.downloadSpeed !== 0 ? (
|
||||
<Chip color="primary" size="sm" radius="sm" variant="bordered">
|
||||
↑ {calcTraffic(info.uploadSpeed || 0)}/s ↓ {calcTraffic(info.downloadSpeed || 0)}
|
||||
/s
|
||||
</Chip>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
color={`${info.isActive ? "warning" : "danger"}`}
|
||||
variant="light"
|
||||
isIconOnly
|
||||
className="mr-2 my-auto"
|
||||
onPress={() => {
|
||||
close(info.id)
|
||||
}}
|
||||
>
|
||||
{info.isActive ? (<CgClose className="text-lg"/>) : (<CgTrash className="text-lg"/>)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
<Button
|
||||
color={`${info.isActive ? 'warning' : 'danger'}`}
|
||||
variant="light"
|
||||
isIconOnly
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onPress={() => {
|
||||
close(info.id)
|
||||
}}
|
||||
>
|
||||
{info.isActive ? <CgClose className="text-lg" /> : <CgTrash className="text-lg" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Card, CardBody, CardHeader } from '@nextui-org/react'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/react'
|
||||
import React from 'react'
|
||||
|
||||
const colorMap = {
|
||||
@ -16,9 +16,9 @@ const LogItem: React.FC<IMihomoLogInfo & { index: number }> = (props) => {
|
||||
<div className={`mr-2 text-lg font-bold text-${colorMap[type]}`}>
|
||||
{props.type.toUpperCase()}
|
||||
</div>
|
||||
<small className="text-default-500">{time}</small>
|
||||
<small className="text-foreground-500">{time}</small>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0 text-sm">{payload}</CardBody>
|
||||
<CardBody className="select-text pt-0 text-sm">{payload}</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -6,14 +6,18 @@ import {
|
||||
ModalFooter,
|
||||
Button,
|
||||
Snippet
|
||||
} from '@nextui-org/react'
|
||||
} from '@heroui/react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { getInterfaces } from '@renderer/utils/ipc'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const InterfaceModal: React.FC<Props> = (props) => {
|
||||
const { onClose } = props
|
||||
const { t } = useTranslation()
|
||||
const [info, setInfo] = useState<Record<string, NetworkInterfaceInfo[]>>({})
|
||||
const getInfo = async (): Promise<void> => {
|
||||
setInfo(await getInterfaces())
|
||||
@ -33,7 +37,7 @@ const InterfaceModal: React.FC<Props> = (props) => {
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex">网络信息</ModalHeader>
|
||||
<ModalHeader className="flex app-drag">{t('mihomo.interface.title')}</ModalHeader>
|
||||
<ModalBody>
|
||||
{Object.entries(info).map(([key, value]) => {
|
||||
return (
|
||||
@ -56,8 +60,8 @@ const InterfaceModal: React.FC<Props> = (props) => {
|
||||
})}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
关闭
|
||||
<Button size="sm" variant="light" onPress={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
@ -65,4 +69,4 @@ const InterfaceModal: React.FC<Props> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default InterfaceModal
|
||||
export default InterfaceModal
|
||||
@ -1,7 +1,9 @@
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@nextui-org/react'
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { BaseEditor } from '../base/base-editor'
|
||||
import { getOverride, restartCore, setOverride } from '@renderer/utils/ipc'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
language: 'javascript' | 'yaml'
|
||||
@ -10,6 +12,7 @@ interface Props {
|
||||
const EditFileModal: React.FC<Props> = (props) => {
|
||||
const { id, language, onClose } = props
|
||||
const [currData, setCurrData] = useState('')
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getContent = async (): Promise<void> => {
|
||||
setCurrData(await getOverride(id, language === 'javascript' ? 'js' : 'yaml'))
|
||||
@ -30,8 +33,10 @@ const EditFileModal: React.FC<Props> = (props) => {
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent className="h-full w-[calc(100%-100px)]">
|
||||
<ModalHeader className="flex pb-0">
|
||||
编辑覆写{language === 'javascript' ? '脚本' : '配置'}
|
||||
<ModalHeader className="flex pb-0 app-drag">
|
||||
{t('override.editFile.title', {
|
||||
type: language === 'javascript' ? t('override.editFile.script') : t('override.editFile.config')
|
||||
})}
|
||||
</ModalHeader>
|
||||
<ModalBody className="h-full">
|
||||
<BaseEditor
|
||||
@ -42,7 +47,7 @@ const EditFileModal: React.FC<Props> = (props) => {
|
||||
</ModalBody>
|
||||
<ModalFooter className="pt-0">
|
||||
<Button size="sm" variant="light" onPress={onClose}>
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -57,7 +62,7 @@ const EditFileModal: React.FC<Props> = (props) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@ -7,10 +7,12 @@ import {
|
||||
Button,
|
||||
Input,
|
||||
Switch
|
||||
} from '@nextui-org/react'
|
||||
} from '@heroui/react'
|
||||
import React, { useState } from 'react'
|
||||
import SettingItem from '../base/base-setting-item'
|
||||
import { restartCore } from '@renderer/utils/ipc'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
item: IOverrideItem
|
||||
updateOverrideItem: (item: IOverrideItem) => Promise<void>
|
||||
@ -19,6 +21,7 @@ interface Props {
|
||||
const EditInfoModal: React.FC<Props> = (props) => {
|
||||
const { item, updateOverrideItem, onClose } = props
|
||||
const [values, setValues] = useState(item)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onSave = async (): Promise<void> => {
|
||||
await updateOverrideItem(values)
|
||||
@ -36,9 +39,9 @@ const EditInfoModal: React.FC<Props> = (props) => {
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex">编辑信息</ModalHeader>
|
||||
<ModalHeader className="flex app-drag">{t('override.editInfo.title')}</ModalHeader>
|
||||
<ModalBody>
|
||||
<SettingItem title="名称">
|
||||
<SettingItem title={t('override.editInfo.name')}>
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-[200px]"
|
||||
@ -49,7 +52,7 @@ const EditInfoModal: React.FC<Props> = (props) => {
|
||||
/>
|
||||
</SettingItem>
|
||||
{values.type === 'remote' && (
|
||||
<SettingItem title="地址">
|
||||
<SettingItem title={t('override.editInfo.url')}>
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-[200px]"
|
||||
@ -60,7 +63,7 @@ const EditInfoModal: React.FC<Props> = (props) => {
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
<SettingItem title="全局启用">
|
||||
<SettingItem title={t('override.editInfo.global')}>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={values.global}
|
||||
@ -71,11 +74,11 @@ const EditInfoModal: React.FC<Props> = (props) => {
|
||||
</SettingItem>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
<Button size="sm" variant="light" onPress={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button color="primary" onPress={onSave}>
|
||||
保存
|
||||
<Button size="sm" color="primary" onPress={onSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@ -6,9 +6,11 @@ import {
|
||||
ModalFooter,
|
||||
Button,
|
||||
Divider
|
||||
} from '@nextui-org/react'
|
||||
} from '@heroui/react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { getOverride } from '@renderer/utils/ipc'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
onClose: () => void
|
||||
@ -16,6 +18,7 @@ interface Props {
|
||||
const ExecLogModal: React.FC<Props> = (props) => {
|
||||
const { id, onClose } = props
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getLog = async (): Promise<void> => {
|
||||
setLogs((await getOverride(id, 'log')).split('\n').filter(Boolean))
|
||||
@ -35,7 +38,7 @@ const ExecLogModal: React.FC<Props> = (props) => {
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex">执行日志</ModalHeader>
|
||||
<ModalHeader className="flex app-drag">{t('override.execLog.title')}</ModalHeader>
|
||||
<ModalBody>
|
||||
{logs.map((log) => {
|
||||
return (
|
||||
@ -47,8 +50,8 @@ const ExecLogModal: React.FC<Props> = (props) => {
|
||||
})}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
关闭
|
||||
<Button size="sm" variant="light" onPress={onClose}>
|
||||
{t('override.execLog.close')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@ -7,9 +7,9 @@ import {
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger
|
||||
} from '@nextui-org/react'
|
||||
} from '@heroui/react'
|
||||
import { IoMdMore, IoMdRefresh } from 'react-icons/io'
|
||||
import dayjs from 'dayjs'
|
||||
import dayjs from '@renderer/utils/dayjs'
|
||||
import React, { Key, useEffect, useMemo, useState } from 'react'
|
||||
import EditFileModal from './edit-file-modal'
|
||||
import EditInfoModal from './edit-info-modal'
|
||||
@ -17,6 +17,7 @@ import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import ExecLogModal from './exec-log-modal'
|
||||
import { openFile, restartCore } from '@renderer/utils/ipc'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
info: IOverrideItem
|
||||
@ -35,6 +36,7 @@ interface MenuItem {
|
||||
}
|
||||
|
||||
const OverrideItem: React.FC<Props> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const { info, addOverrideItem, removeOverrideItem, mutateOverrideConfig, updateOverrideItem } =
|
||||
props
|
||||
const [updating, setUpdating] = useState(false)
|
||||
@ -57,35 +59,35 @@ const OverrideItem: React.FC<Props> = (props) => {
|
||||
const list = [
|
||||
{
|
||||
key: 'edit-info',
|
||||
label: '编辑信息',
|
||||
label: t('override.menuItems.editInfo'),
|
||||
showDivider: false,
|
||||
color: 'default',
|
||||
className: ''
|
||||
} as MenuItem,
|
||||
{
|
||||
key: 'edit-file',
|
||||
label: '编辑文件',
|
||||
label: t('override.menuItems.editFile'),
|
||||
showDivider: false,
|
||||
color: 'default',
|
||||
className: ''
|
||||
} as MenuItem,
|
||||
{
|
||||
key: 'open-file',
|
||||
label: '打开文件',
|
||||
label: t('override.menuItems.openFile'),
|
||||
showDivider: false,
|
||||
color: 'default',
|
||||
className: ''
|
||||
} as MenuItem,
|
||||
{
|
||||
key: 'exec-log',
|
||||
label: '执行日志',
|
||||
label: t('override.menuItems.execLog'),
|
||||
showDivider: true,
|
||||
color: 'default',
|
||||
className: ''
|
||||
} as MenuItem,
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
label: t('override.menuItems.delete'),
|
||||
showDivider: false,
|
||||
color: 'danger',
|
||||
className: 'text-danger'
|
||||
@ -95,7 +97,7 @@ const OverrideItem: React.FC<Props> = (props) => {
|
||||
list.splice(3, 1)
|
||||
}
|
||||
return list
|
||||
}, [info])
|
||||
}, [info, t])
|
||||
const onMenuAction = (key: Key): void => {
|
||||
switch (key) {
|
||||
case 'edit-info': {
|
||||
@ -160,6 +162,7 @@ const OverrideItem: React.FC<Props> = (props) => {
|
||||
)}
|
||||
{openLog && <ExecLogModal id={info.id} onClose={() => setOpenLog(false)} />}
|
||||
<Card
|
||||
as="div"
|
||||
fullWidth
|
||||
isPressable
|
||||
onPress={() => {
|
||||
@ -167,86 +170,82 @@ const OverrideItem: React.FC<Props> = (props) => {
|
||||
setOpenFileEditor(true)
|
||||
}}
|
||||
>
|
||||
<CardBody>
|
||||
<div className="flex justify-between h-[32px]">
|
||||
<h3
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title={info?.name}
|
||||
className={`text-ellipsis whitespace-nowrap overflow-hidden text-md font-bold leading-[32px] text-foreground`}
|
||||
>
|
||||
{info?.name}
|
||||
</h3>
|
||||
<div className="flex">
|
||||
{info.type === 'remote' && (
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="default"
|
||||
disabled={updating}
|
||||
onPress={async () => {
|
||||
setUpdating(true)
|
||||
try {
|
||||
await addOverrideItem(info)
|
||||
await restartCore()
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IoMdRefresh
|
||||
<div ref={setNodeRef} {...attributes} {...listeners} className="h-full w-full">
|
||||
<CardBody>
|
||||
<div className="flex justify-between h-[32px]">
|
||||
<h3
|
||||
title={info?.name}
|
||||
className={`text-ellipsis whitespace-nowrap overflow-hidden text-md font-bold leading-[32px] text-foreground`}
|
||||
>
|
||||
{info?.name}
|
||||
</h3>
|
||||
<div className="flex">
|
||||
{info.type === 'remote' && (
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="default"
|
||||
className={`text-[24px] ${updating ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly size="sm" variant="light" color="default">
|
||||
<IoMdMore color="default" className={`text-[24px]`} />
|
||||
disabled={updating}
|
||||
onPress={async () => {
|
||||
setUpdating(true)
|
||||
try {
|
||||
await addOverrideItem(info)
|
||||
await restartCore()
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IoMdRefresh
|
||||
color="default"
|
||||
className={`text-[24px] ${updating ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu onAction={onMenuAction}>
|
||||
{menuItems.map((item) => (
|
||||
<DropdownItem
|
||||
showDivider={item.showDivider}
|
||||
key={item.key}
|
||||
color={item.color}
|
||||
className={item.className}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div className={`mt-2 flex justify-start`}>
|
||||
{info.global && (
|
||||
<Chip size="sm" variant="dot" color="primary" className="mr-2">
|
||||
全局
|
||||
</Chip>
|
||||
)}
|
||||
<Chip size="sm" variant="bordered" className="mr-2">
|
||||
{info.type === 'local' ? '本地' : '远程'}
|
||||
</Chip>
|
||||
<Chip size="sm" variant="bordered">
|
||||
{info.ext === 'yaml' ? 'YAML' : 'JavaScript'}
|
||||
</Chip>
|
||||
</div>
|
||||
{info.type === 'remote' && (
|
||||
<div className={`mt-2 flex justify-end`}>
|
||||
<small>{dayjs(info.updated).fromNow()}</small>
|
||||
)}
|
||||
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly size="sm" variant="light" color="default">
|
||||
<IoMdMore color="default" className={`text-[24px]`} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu onAction={onMenuAction}>
|
||||
{menuItems.map((item) => (
|
||||
<DropdownItem
|
||||
showDivider={item.showDivider}
|
||||
key={item.key}
|
||||
color={item.color}
|
||||
className={item.className}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div className={`mt-2 flex justify-start`}>
|
||||
{info.global && (
|
||||
<Chip size="sm" variant="dot" color="primary" className="mr-2">
|
||||
{t('override.labels.global')}
|
||||
</Chip>
|
||||
)}
|
||||
<Chip size="sm" variant="bordered">
|
||||
{info.ext === 'yaml' ? 'YAML' : 'JavaScript'}
|
||||
</Chip>
|
||||
</div>
|
||||
{info.type === 'remote' && (
|
||||
<div className={`mt-2 flex justify-end`}>
|
||||
<small>{dayjs(info.updated).fromNow()}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@nextui-org/react'
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { BaseEditor } from '../base/base-editor'
|
||||
import { getProfileStr, setProfileStr } from '@renderer/utils/ipc'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const EditFileModal: React.FC<Props> = (props) => {
|
||||
const { id, onClose } = props
|
||||
const [currData, setCurrData] = useState('')
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getContent = async (): Promise<void> => {
|
||||
setCurrData(await getProfileStr(id))
|
||||
@ -31,22 +35,23 @@ const EditFileModal: React.FC<Props> = (props) => {
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent className="h-full w-[calc(100%-100px)]">
|
||||
<ModalHeader className="flex pb-0">
|
||||
<ModalHeader className="flex pb-0 app-drag">
|
||||
<div className="flex justify-start">
|
||||
<div className="flex items-center">编辑订阅</div>
|
||||
<small className="ml-2 text-default-500">
|
||||
注意:此处编辑配置更新订阅后会还原,如需要自定义配置请使用
|
||||
<div className="flex items-center">{t('profiles.editFile.title')}</div>
|
||||
<small className="ml-2 text-foreground-500">
|
||||
{t('profiles.editFile.notice')}
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
variant="light"
|
||||
className="app-nodrag"
|
||||
onPress={() => {
|
||||
navigate('/override')
|
||||
}}
|
||||
>
|
||||
覆写
|
||||
{t('profiles.editFile.override')}
|
||||
</Button>
|
||||
功能
|
||||
{t('profiles.editFile.feature')}
|
||||
</small>
|
||||
</div>
|
||||
</ModalHeader>
|
||||
@ -55,7 +60,7 @@ const EditFileModal: React.FC<Props> = (props) => {
|
||||
</ModalBody>
|
||||
<ModalFooter className="pt-0">
|
||||
<Button size="sm" variant="light" onPress={onClose}>
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -65,7 +70,7 @@ const EditFileModal: React.FC<Props> = (props) => {
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
确认
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import {
|
||||
cn,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
@ -11,13 +12,15 @@ import {
|
||||
DropdownTrigger,
|
||||
DropdownMenu,
|
||||
DropdownItem
|
||||
} from '@nextui-org/react'
|
||||
} from '@heroui/react'
|
||||
import React, { useState } from 'react'
|
||||
import SettingItem from '../base/base-setting-item'
|
||||
import { useOverrideConfig } from '@renderer/hooks/use-override-config'
|
||||
import { restartCore } from '@renderer/utils/ipc'
|
||||
import { restartCore, addProfileUpdater } from '@renderer/utils/ipc'
|
||||
import { MdDeleteForever } from 'react-icons/md'
|
||||
import { FaPlus } from 'react-icons/fa6'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
item: IProfileItem
|
||||
updateProfileItem: (item: IProfileItem) => Promise<void>
|
||||
@ -28,16 +31,20 @@ const EditInfoModal: React.FC<Props> = (props) => {
|
||||
const { overrideConfig } = useOverrideConfig()
|
||||
const { items: overrideItems = [] } = overrideConfig || {}
|
||||
const [values, setValues] = useState(item)
|
||||
const inputWidth = 'w-[400px] md:w-[400px] lg:w-[600px] xl:w-[800px]'
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onSave = async (): Promise<void> => {
|
||||
try {
|
||||
await updateProfileItem({
|
||||
const updatedItem = {
|
||||
...values,
|
||||
override: values.override?.filter(
|
||||
(i) =>
|
||||
overrideItems.find((t) => t.id === i) && !overrideItems.find((t) => t.id === i)?.global
|
||||
)
|
||||
})
|
||||
};
|
||||
await updateProfileItem(updatedItem)
|
||||
await addProfileUpdater(updatedItem)
|
||||
await restartCore()
|
||||
onClose()
|
||||
} catch (e) {
|
||||
@ -48,19 +55,23 @@ const EditInfoModal: React.FC<Props> = (props) => {
|
||||
return (
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
classNames={{ backdrop: 'top-[48px]' }}
|
||||
size="5xl"
|
||||
classNames={{
|
||||
backdrop: 'top-[48px]',
|
||||
base: 'w-[600px] md:w-[600px] lg:w-[800px] xl:w-[1024px]'
|
||||
}}
|
||||
hideCloseButton
|
||||
isOpen={true}
|
||||
onOpenChange={onClose}
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex">编辑信息</ModalHeader>
|
||||
<ModalHeader className="flex app-drag">{t('profiles.editInfo.title')}</ModalHeader>
|
||||
<ModalBody>
|
||||
<SettingItem title="名称">
|
||||
<SettingItem title={t('profiles.editInfo.name')}>
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-[200px]"
|
||||
className={cn(inputWidth)}
|
||||
value={values.name}
|
||||
onValueChange={(v) => {
|
||||
setValues({ ...values, name: v })
|
||||
@ -69,17 +80,17 @@ const EditInfoModal: React.FC<Props> = (props) => {
|
||||
</SettingItem>
|
||||
{values.type === 'remote' && (
|
||||
<>
|
||||
<SettingItem title="订阅地址">
|
||||
<SettingItem title={t('profiles.editInfo.url')}>
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-[200px]"
|
||||
className={cn(inputWidth)}
|
||||
value={values.url}
|
||||
onValueChange={(v) => {
|
||||
setValues({ ...values, url: v })
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="使用代理更新">
|
||||
<SettingItem title={t('profiles.editInfo.useProxy')}>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={values.useProxy ?? false}
|
||||
@ -88,20 +99,29 @@ const EditInfoModal: React.FC<Props> = (props) => {
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="更新间隔(分钟)">
|
||||
<SettingItem title={t('profiles.editInfo.interval')}>
|
||||
<Input
|
||||
size="sm"
|
||||
type="number"
|
||||
className="w-[200px]"
|
||||
className={cn(inputWidth)}
|
||||
value={values.interval?.toString() ?? ''}
|
||||
onValueChange={(v) => {
|
||||
setValues({ ...values, interval: parseInt(v) })
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title={t('profiles.editInfo.fixedInterval')}>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={values.allowFixedInterval ?? false}
|
||||
onValueChange={(v) => {
|
||||
setValues({ ...values, allowFixedInterval: v })
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
</>
|
||||
)}
|
||||
<SettingItem title="覆写">
|
||||
<SettingItem title={t('profiles.editInfo.override.title')}>
|
||||
<div>
|
||||
{overrideItems
|
||||
.filter((i) => i.global)
|
||||
@ -109,7 +129,7 @@ const EditInfoModal: React.FC<Props> = (props) => {
|
||||
return (
|
||||
<div className="flex mb-2" key={i.id}>
|
||||
<Button disabled fullWidth variant="flat" size="sm">
|
||||
{i.name} (全局)
|
||||
{i.name} ({t('profiles.editInfo.override.global')})
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@ -146,7 +166,7 @@ const EditInfoModal: React.FC<Props> = (props) => {
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
emptyContent="没有可用的覆写"
|
||||
emptyContent={t('profiles.editInfo.override.noAvailable')}
|
||||
onAction={(key) => {
|
||||
setValues({
|
||||
...values,
|
||||
@ -165,11 +185,11 @@ const EditInfoModal: React.FC<Props> = (props) => {
|
||||
</SettingItem>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
<Button size="sm" variant="light" onPress={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button color="primary" onPress={onSave}>
|
||||
保存
|
||||
<Button size="sm" color="primary" onPress={onSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@ -8,17 +8,20 @@ import {
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger,
|
||||
Progress
|
||||
} from '@nextui-org/react'
|
||||
Progress,
|
||||
Tooltip
|
||||
} from '@heroui/react'
|
||||
import { calcPercent, calcTraffic } from '@renderer/utils/calc'
|
||||
import { IoMdMore, IoMdRefresh } from 'react-icons/io'
|
||||
import dayjs from 'dayjs'
|
||||
import dayjs from '@renderer/utils/dayjs'
|
||||
import React, { Key, useEffect, useMemo, useState } from 'react'
|
||||
import EditFileModal from './edit-file-modal'
|
||||
import EditInfoModal from './edit-info-modal'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { openFile } from '@renderer/utils/ipc'
|
||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
info: IProfileItem
|
||||
@ -27,7 +30,7 @@ interface Props {
|
||||
updateProfileItem: (item: IProfileItem) => Promise<void>
|
||||
removeProfileItem: (id: string) => Promise<void>
|
||||
mutateProfileConfig: () => void
|
||||
onClick: () => Promise<void>
|
||||
onPress: () => Promise<void>
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
@ -38,22 +41,26 @@ interface MenuItem {
|
||||
className: string
|
||||
}
|
||||
const ProfileItem: React.FC<Props> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
info,
|
||||
addProfileItem,
|
||||
removeProfileItem,
|
||||
mutateProfileConfig,
|
||||
updateProfileItem,
|
||||
onClick,
|
||||
onPress,
|
||||
isCurrent
|
||||
} = props
|
||||
const extra = info?.extra
|
||||
const usage = (extra?.upload ?? 0) + (extra?.download ?? 0)
|
||||
const total = extra?.total ?? 0
|
||||
const { appConfig, patchAppConfig } = useAppConfig()
|
||||
const { profileDisplayDate = 'expire' } = appConfig || {}
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [selecting, setSelecting] = useState(false)
|
||||
const [openInfoEditor, setOpenInfoEditor] = useState(false)
|
||||
const [openFileEditor, setOpenFileEditor] = useState(false)
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@ -71,28 +78,28 @@ const ProfileItem: React.FC<Props> = (props) => {
|
||||
const list = [
|
||||
{
|
||||
key: 'edit-info',
|
||||
label: '编辑信息',
|
||||
label: t('profiles.editInfo.title'),
|
||||
showDivider: false,
|
||||
color: 'default',
|
||||
className: ''
|
||||
} as MenuItem,
|
||||
{
|
||||
key: 'edit-file',
|
||||
label: '编辑文件',
|
||||
label: t('profiles.editFile.title'),
|
||||
showDivider: false,
|
||||
color: 'default',
|
||||
className: ''
|
||||
} as MenuItem,
|
||||
{
|
||||
key: 'open-file',
|
||||
label: '打开文件',
|
||||
label: t('profiles.openFile'),
|
||||
showDivider: true,
|
||||
color: 'default',
|
||||
className: ''
|
||||
} as MenuItem,
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
label: t('common.delete'),
|
||||
showDivider: false,
|
||||
color: 'danger',
|
||||
className: 'text-danger'
|
||||
@ -101,14 +108,14 @@ const ProfileItem: React.FC<Props> = (props) => {
|
||||
if (info.home) {
|
||||
list.unshift({
|
||||
key: 'home',
|
||||
label: '主页',
|
||||
label: t('profiles.home'),
|
||||
showDivider: false,
|
||||
color: 'default',
|
||||
className: ''
|
||||
} as MenuItem)
|
||||
}
|
||||
return list
|
||||
}, [info])
|
||||
}, [info, t])
|
||||
|
||||
const onMenuAction = async (key: Key): Promise<void> => {
|
||||
switch (key) {
|
||||
@ -137,6 +144,12 @@ const ProfileItem: React.FC<Props> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDropdownOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
setTimeout(() => {
|
||||
@ -149,6 +162,8 @@ const ProfileItem: React.FC<Props> = (props) => {
|
||||
}
|
||||
}, [isDragging])
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid col-span-1"
|
||||
@ -167,113 +182,154 @@ const ProfileItem: React.FC<Props> = (props) => {
|
||||
updateProfileItem={updateProfileItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card
|
||||
as="div"
|
||||
fullWidth
|
||||
isPressable
|
||||
onPress={() => {
|
||||
if (disableSelect) return
|
||||
setSelecting(true)
|
||||
onClick().finally(() => {
|
||||
onPress().finally(() => {
|
||||
setSelecting(false)
|
||||
})
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={`${isCurrent ? 'bg-primary' : ''} ${selecting ? 'blur-sm' : ''}`}
|
||||
>
|
||||
<CardBody className="pb-1">
|
||||
<div className="flex justify-between h-[32px]">
|
||||
<h3
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title={info?.name}
|
||||
className={`text-ellipsis whitespace-nowrap overflow-hidden text-md font-bold leading-[32px] ${isCurrent ? 'text-white' : 'text-foreground'}`}
|
||||
>
|
||||
{info?.name}
|
||||
</h3>
|
||||
<div className="flex">
|
||||
{info.type === 'remote' && (
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="default"
|
||||
title={dayjs(info.updated).fromNow()}
|
||||
disabled={updating}
|
||||
onPress={async () => {
|
||||
setUpdating(true)
|
||||
await addProfileItem(info)
|
||||
setUpdating(false)
|
||||
}}
|
||||
>
|
||||
<IoMdRefresh
|
||||
color="default"
|
||||
className={`${isCurrent ? 'text-white' : 'text-foreground'} text-[24px] ${updating ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly size="sm" variant="light" color="default">
|
||||
<IoMdMore
|
||||
color="default"
|
||||
className={`text-[24px] ${isCurrent ? 'text-white' : 'text-foreground'}`}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu onAction={onMenuAction}>
|
||||
{menuItems.map((item) => (
|
||||
<DropdownItem
|
||||
showDivider={item.showDivider}
|
||||
key={item.key}
|
||||
color={item.color}
|
||||
className={item.className}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
{info.type === 'remote' && extra && (
|
||||
<div
|
||||
className={`mt-2 flex justify-between ${isCurrent ? 'text-white' : 'text-foreground'}`}
|
||||
>
|
||||
<small>{`${calcTraffic(usage)}/${calcTraffic(total)}`}</small>
|
||||
<small>
|
||||
{extra.expire ? dayjs.unix(extra.expire).format('YYYY-MM-DD') : '长期有效'}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{info.type === 'local' && (
|
||||
<div
|
||||
className={`mt-2 flex justify-between ${isCurrent ? 'text-white' : 'text-foreground'}`}
|
||||
>
|
||||
<Chip
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
className={`${isCurrent ? 'text-white border-white' : 'border-primary text-primary'}`}
|
||||
<div ref={setNodeRef} {...attributes} {...listeners} className="w-full h-full">
|
||||
<CardBody className="pb-1">
|
||||
<div className="flex justify-between h-[32px]">
|
||||
<h3
|
||||
title={info?.name}
|
||||
className={`text-ellipsis whitespace-nowrap overflow-hidden text-md font-bold leading-[32px] ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
|
||||
>
|
||||
本地
|
||||
</Chip>
|
||||
{info?.name}
|
||||
</h3>
|
||||
<div className="flex">
|
||||
{info.type === 'remote' && (
|
||||
<Tooltip placement="left" content={dayjs(info.updated).fromNow()}>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="default"
|
||||
disabled={updating}
|
||||
onPress={async () => {
|
||||
setUpdating(true)
|
||||
await addProfileItem(info)
|
||||
setUpdating(false)
|
||||
}}
|
||||
>
|
||||
<IoMdRefresh
|
||||
color="default"
|
||||
className={`${isCurrent ? 'text-primary-foreground' : 'text-foreground'} text-[24px] ${updating ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Dropdown
|
||||
isOpen={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly size="sm" variant="light" color="default">
|
||||
<IoMdMore
|
||||
color="default"
|
||||
className={`text-[24px] ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu onAction={onMenuAction}>
|
||||
{menuItems.map((item) => (
|
||||
<DropdownItem
|
||||
showDivider={item.showDivider}
|
||||
key={item.key}
|
||||
color={item.color}
|
||||
className={item.className}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
<CardFooter className="pt-0">
|
||||
{extra && (
|
||||
<Progress
|
||||
className="w-full"
|
||||
classNames={{
|
||||
indicator: isCurrent ? 'bg-white' : 'bg-foreground'
|
||||
}}
|
||||
value={calcPercent(extra?.upload, extra?.download, extra?.total)}
|
||||
/>
|
||||
)}
|
||||
</CardFooter>
|
||||
{info.type === 'remote' && extra && (
|
||||
<div
|
||||
className={`mt-2 flex justify-between ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
|
||||
>
|
||||
<small>{`${calcTraffic(usage)}/${calcTraffic(total)}`}</small>
|
||||
{profileDisplayDate === 'expire' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
className={`h-[20px] p-1 m-0 ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
|
||||
onPress={async () => {
|
||||
await patchAppConfig({ profileDisplayDate: 'update' })
|
||||
}}
|
||||
>
|
||||
{extra.expire ? dayjs.unix(extra.expire).format('YYYY-MM-DD') : t('profiles.neverExpire')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
className={`h-[20px] p-1 m-0 ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
|
||||
onPress={async () => {
|
||||
await patchAppConfig({ profileDisplayDate: 'expire' })
|
||||
}}
|
||||
>
|
||||
{dayjs(info.updated).fromNow()}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
<CardFooter className="pt-0">
|
||||
{info.type === 'remote' && !extra && (
|
||||
<div
|
||||
className={`w-full mt-2 flex justify-between ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
|
||||
>
|
||||
<Chip
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
className={`${isCurrent ? 'text-primary-foreground border-primary-foreground' : 'border-primary text-primary'}`}
|
||||
>
|
||||
{t('profiles.remote')}
|
||||
</Chip>
|
||||
<small>{dayjs(info.updated).fromNow()}</small>
|
||||
</div>
|
||||
)}
|
||||
{info.type === 'local' && (
|
||||
<div
|
||||
className={`mt-2 flex justify-between ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
|
||||
>
|
||||
<Chip
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
className={`${isCurrent ? 'text-primary-foreground border-primary-foreground' : 'border-primary text-primary'}`}
|
||||
>
|
||||
{t('profiles.local')}
|
||||
</Chip>
|
||||
</div>
|
||||
)}
|
||||
{extra && (
|
||||
<Progress
|
||||
className="w-full"
|
||||
aria-label={t('profiles.trafficUsage')}
|
||||
classNames={{
|
||||
indicator: isCurrent ? 'bg-primary-foreground' : 'bg-foreground'
|
||||
}}
|
||||
value={calcPercent(extra?.upload, extra?.download, extra?.total)}
|
||||
/>
|
||||
)}
|
||||
</CardFooter>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileItem
|
||||
export default ProfileItem
|
||||
@ -1,5 +1,9 @@
|
||||
import { Button, Card, CardBody } from '@nextui-org/react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Button, Card, CardBody } from '@heroui/react'
|
||||
import { mihomoUnfixedProxy } from '@renderer/utils/ipc'
|
||||
import React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { FaMapPin } from 'react-icons/fa6'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
mutateProxies: () => void
|
||||
@ -9,10 +13,12 @@ interface Props {
|
||||
group: IMihomoMixedGroup
|
||||
onSelect: (group: string, proxy: string) => void
|
||||
selected: boolean
|
||||
isGroupTesting?: boolean
|
||||
}
|
||||
|
||||
const ProxyItem: React.FC<Props> = (props) => {
|
||||
const { mutateProxies, proxyDisplayMode, group, proxy, selected, onSelect, onProxyDelay } = props
|
||||
const { t } = useTranslation()
|
||||
const { mutateProxies, proxyDisplayMode, group, proxy, selected, onSelect, onProxyDelay, isGroupTesting = false } = props
|
||||
|
||||
const delay = useMemo(() => {
|
||||
if (proxy.history.length > 0) {
|
||||
@ -23,6 +29,8 @@ const ProxyItem: React.FC<Props> = (props) => {
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const isLoading = loading || isGroupTesting
|
||||
|
||||
function delayColor(delay: number): 'primary' | 'success' | 'warning' | 'danger' {
|
||||
if (delay === -1) return 'primary'
|
||||
if (delay === 0) return 'danger'
|
||||
@ -31,8 +39,8 @@ const ProxyItem: React.FC<Props> = (props) => {
|
||||
}
|
||||
|
||||
function delayText(delay: number): string {
|
||||
if (delay === -1) return '测试'
|
||||
if (delay === 0) return '超时'
|
||||
if (delay === -1) return t('proxies.delay.test')
|
||||
if (delay === 0) return t('proxies.delay.timeout')
|
||||
return delay.toString()
|
||||
}
|
||||
|
||||
@ -48,39 +56,115 @@ const ProxyItem: React.FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<Card
|
||||
as="div"
|
||||
onPress={() => onSelect(group.name, proxy.name)}
|
||||
isPressable
|
||||
fullWidth
|
||||
shadow="sm"
|
||||
className={`${fixed ? 'bg-secondary/30' : selected ? 'bg-primary/30' : 'bg-content2'}`}
|
||||
className={`${
|
||||
fixed
|
||||
? 'bg-secondary/30 border-r-2 border-r-secondary border-l-2 border-l-secondary'
|
||||
: selected
|
||||
? 'bg-primary/30 border-r-2 border-r-primary border-l-2 border-l-primary'
|
||||
: 'bg-content2'
|
||||
}`}
|
||||
radius="sm"
|
||||
>
|
||||
<CardBody className="p-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<CardBody className="p-1">
|
||||
{proxyDisplayMode === 'full' ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex justify-between items-center pl-1">
|
||||
<div className="text-ellipsis overflow-hidden whitespace-nowrap">
|
||||
<div className="flag-emoji inline" title={proxy.name}>
|
||||
{proxy.name}
|
||||
</div>
|
||||
</div>
|
||||
{fixed && (
|
||||
<Button
|
||||
isIconOnly
|
||||
title={t('proxies.unpin')}
|
||||
color="danger"
|
||||
onPress={async () => {
|
||||
await mihomoUnfixedProxy(group.name)
|
||||
mutateProxies()
|
||||
}}
|
||||
variant="light"
|
||||
className="h-[20px] p-0 text-sm"
|
||||
>
|
||||
<FaMapPin className="text-md le" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center pl-1">
|
||||
<div className="flex gap-1 items-center">
|
||||
<div className="text-foreground-400 text-xs bg-default-100 px-1 rounded-md">
|
||||
{proxy.type}
|
||||
</div>
|
||||
{['tfo', 'udp', 'xudp', 'mptcp', 'smux'].map(protocol =>
|
||||
proxy[protocol as keyof IMihomoProxy] && (
|
||||
<div key={protocol} className="text-foreground-400 text-xs bg-default-100 px-1 rounded-md">
|
||||
{protocol}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
isIconOnly
|
||||
title={proxy.type}
|
||||
isLoading={isLoading}
|
||||
color={delayColor(delay)}
|
||||
onPress={onDelay}
|
||||
variant="light"
|
||||
className="h-full text-sm ml-auto -mt-0.5 px-2 relative w-min whitespace-nowrap"
|
||||
>
|
||||
<div className="w-full h-full flex items-center justify-end">
|
||||
{delayText(delay)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-between items-center pl-1">
|
||||
<div className="text-ellipsis overflow-hidden whitespace-nowrap">
|
||||
<div className="flag-emoji inline" title={proxy.name}>
|
||||
{proxy.name}
|
||||
</div>
|
||||
{proxyDisplayMode === 'full' && (
|
||||
<div className="inline ml-2 text-default-500" title={proxy.type}>
|
||||
{proxy.type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
title={proxy.type}
|
||||
isLoading={loading}
|
||||
color={delayColor(delay)}
|
||||
onPress={onDelay}
|
||||
variant="light"
|
||||
className="h-full min-w-[50px] p-0 mx-2 text-sm hover:bg-content"
|
||||
>
|
||||
{delayText(delay)}
|
||||
</Button>
|
||||
<div className="flex justify-end">
|
||||
{fixed && (
|
||||
<Button
|
||||
isIconOnly
|
||||
title={t('proxies.unpin')}
|
||||
color="danger"
|
||||
onPress={async () => {
|
||||
await mihomoUnfixedProxy(group.name)
|
||||
mutateProxies()
|
||||
}}
|
||||
variant="light"
|
||||
className="h-[20px] p-0 text-sm"
|
||||
>
|
||||
<FaMapPin className="text-md le" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isIconOnly
|
||||
title={proxy.type}
|
||||
isLoading={isLoading}
|
||||
color={delayColor(delay)}
|
||||
onPress={onDelay}
|
||||
variant="light"
|
||||
className="h-full text-sm px-2 relative w-min whitespace-nowrap"
|
||||
>
|
||||
<div className="w-full h-full flex items-center justify-end">
|
||||
{delayText(delay)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProxyItem
|
||||
export default ProxyItem
|
||||
@ -1,18 +1,20 @@
|
||||
import { Button, Input, Switch, Tab, Tabs } from '@nextui-org/react'
|
||||
import { Button, Input, Switch, Tab, Tabs } from '@heroui/react'
|
||||
import SettingCard from '@renderer/components/base/base-setting-card'
|
||||
import SettingItem from '@renderer/components/base/base-setting-item'
|
||||
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
|
||||
import { mihomoUpgradeGeo } from '@renderer/utils/ipc'
|
||||
import { useState } from 'react'
|
||||
import { IoMdRefresh } from 'react-icons/io'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const GeoData: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { controledMihomoConfig, patchControledMihomoConfig } = useControledMihomoConfig()
|
||||
const {
|
||||
'geox-url': geoxUrl = {
|
||||
geoip: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip-lite.dat',
|
||||
geosite: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat',
|
||||
mmdb: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country-lite.mmdb',
|
||||
mmdb: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb',
|
||||
asn: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb'
|
||||
},
|
||||
'geodata-mode': geoMode = false,
|
||||
@ -27,7 +29,7 @@ const GeoData: React.FC = () => {
|
||||
|
||||
return (
|
||||
<SettingCard>
|
||||
<SettingItem title="GeoIP 数据库" divider>
|
||||
<SettingItem title={t('resources.geoData.geoip')} divider>
|
||||
<div className="flex w-[70%]">
|
||||
{geoipInput !== geoxUrl.geoip && (
|
||||
<Button
|
||||
@ -38,13 +40,13 @@ const GeoData: React.FC = () => {
|
||||
patchControledMihomoConfig({ 'geox-url': { ...geoxUrl, geoip: geoipInput } })
|
||||
}}
|
||||
>
|
||||
确认
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
)}
|
||||
<Input size="sm" value={geoipInput} onValueChange={setGeoIpInput} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title="GeoSite 数据库" divider>
|
||||
<SettingItem title={t('resources.geoData.geosite')} divider>
|
||||
<div className="flex w-[70%]">
|
||||
{geositeInput !== geoxUrl.geosite && (
|
||||
<Button
|
||||
@ -55,13 +57,13 @@ const GeoData: React.FC = () => {
|
||||
patchControledMihomoConfig({ 'geox-url': { ...geoxUrl, geosite: geositeInput } })
|
||||
}}
|
||||
>
|
||||
确认
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
)}
|
||||
<Input size="sm" value={geositeInput} onValueChange={setGeositeInput} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title="MMDB 数据库" divider>
|
||||
<SettingItem title={t('resources.geoData.mmdb')} divider>
|
||||
<div className="flex w-[70%]">
|
||||
{mmdbInput !== geoxUrl.mmdb && (
|
||||
<Button
|
||||
@ -72,13 +74,13 @@ const GeoData: React.FC = () => {
|
||||
patchControledMihomoConfig({ 'geox-url': { ...geoxUrl, mmdb: mmdbInput } })
|
||||
}}
|
||||
>
|
||||
确认
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
)}
|
||||
<Input size="sm" value={mmdbInput} onValueChange={setMmdbInput} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title="ASN 数据库" divider>
|
||||
<SettingItem title={t('resources.geoData.asn')} divider>
|
||||
<div className="flex w-[70%]">
|
||||
{asnInput !== geoxUrl.asn && (
|
||||
<Button
|
||||
@ -89,13 +91,13 @@ const GeoData: React.FC = () => {
|
||||
patchControledMihomoConfig({ 'geox-url': { ...geoxUrl, asn: asnInput } })
|
||||
}}
|
||||
>
|
||||
确认
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
)}
|
||||
<Input size="sm" value={asnInput} onValueChange={setAsnInput} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title="GeoIP 数据模式" divider>
|
||||
<SettingItem title={t('resources.geoData.mode')} divider>
|
||||
<Tabs
|
||||
size="sm"
|
||||
color="primary"
|
||||
@ -109,7 +111,7 @@ const GeoData: React.FC = () => {
|
||||
</Tabs>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
title="自动更新 Geo 数据库"
|
||||
title={t('resources.geoData.autoUpdate')}
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
@ -119,7 +121,7 @@ const GeoData: React.FC = () => {
|
||||
setUpdating(true)
|
||||
try {
|
||||
await mihomoUpgradeGeo()
|
||||
new Notification('Geo 数据库更新成功')
|
||||
new Notification(t('resources.geoData.updateSuccess'))
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
} finally {
|
||||
@ -141,7 +143,7 @@ const GeoData: React.FC = () => {
|
||||
/>
|
||||
</SettingItem>
|
||||
{geoAutoUpdate && (
|
||||
<SettingItem title="更新间隔(小时)">
|
||||
<SettingItem title={t('resources.geoData.updateInterval')}>
|
||||
<Input
|
||||
size="sm"
|
||||
type="number"
|
||||
|
||||
@ -1,22 +1,59 @@
|
||||
import { mihomoProxyProviders, mihomoUpdateProxyProviders } from '@renderer/utils/ipc'
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import {
|
||||
mihomoProxyProviders,
|
||||
mihomoUpdateProxyProviders,
|
||||
getRuntimeConfig
|
||||
} from '@renderer/utils/ipc'
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import Viewer from './viewer'
|
||||
import useSWR from 'swr'
|
||||
import SettingCard from '../base/base-setting-card'
|
||||
import SettingItem from '../base/base-setting-item'
|
||||
import { Button, Chip } from '@nextui-org/react'
|
||||
import { Button, Chip } from '@heroui/react'
|
||||
import { IoMdRefresh } from 'react-icons/io'
|
||||
import dayjs from 'dayjs'
|
||||
import { CgLoadbarDoc } from 'react-icons/cg'
|
||||
import { MdEditDocument } from 'react-icons/md'
|
||||
import dayjs from '@renderer/utils/dayjs'
|
||||
import { calcTraffic } from '@renderer/utils/calc'
|
||||
|
||||
import { getHash } from '@renderer/utils/hash'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
const ProxyProvider: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [showDetails, setShowDetails] = useState({
|
||||
show: false,
|
||||
path: '',
|
||||
type: '',
|
||||
title: '',
|
||||
privderType: ''
|
||||
})
|
||||
useEffect(() => {
|
||||
if (showDetails.title) {
|
||||
const fetchProviderPath = async (name: string): Promise<void> => {
|
||||
try {
|
||||
const providers= await getRuntimeConfig()
|
||||
const provider = providers['proxy-providers'][name]
|
||||
if (provider) {
|
||||
setShowDetails((prev) => ({
|
||||
...prev,
|
||||
show: true,
|
||||
path: provider?.path || `proxies/${getHash(provider?.url)}`
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
setShowDetails((prev) => ({ ...prev, path: '' }))
|
||||
}
|
||||
}
|
||||
fetchProviderPath(showDetails.title)
|
||||
}
|
||||
}, [showDetails.title])
|
||||
|
||||
const { data, mutate } = useSWR('mihomoProxyProviders', mihomoProxyProviders)
|
||||
const providers = useMemo(() => {
|
||||
if (!data) return []
|
||||
if (!data.providers) return []
|
||||
return Object.keys(data.providers)
|
||||
.map((key) => data.providers[key])
|
||||
.filter((provider) => {
|
||||
return 'subscriptionInfo' in provider
|
||||
return Object.values(data.providers)
|
||||
.filter((provider) => provider.vehicleType !== 'Compatible')
|
||||
.sort((a, b) => {
|
||||
const order = { File: 1, Inline: 2, HTTP: 3 }
|
||||
return (order[a.vehicleType] || 4) - (order[b.vehicleType] || 4)
|
||||
})
|
||||
}, [data])
|
||||
const [updating, setUpdating] = useState(Array(providers.length).fill(false))
|
||||
@ -45,7 +82,16 @@ const ProxyProvider: React.FC = () => {
|
||||
|
||||
return (
|
||||
<SettingCard>
|
||||
<SettingItem title="代理集合" divider>
|
||||
{showDetails.show && (
|
||||
<Viewer
|
||||
path={showDetails.path}
|
||||
type={showDetails.type}
|
||||
title={showDetails.title}
|
||||
privderType={showDetails.privderType}
|
||||
onClose={() => setShowDetails({ show: false, path: '', type: '', title: '', privderType: '' })}
|
||||
/>
|
||||
)}
|
||||
<SettingItem title={t('resources.proxyProviders.title')} divider>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
@ -55,59 +101,79 @@ const ProxyProvider: React.FC = () => {
|
||||
})
|
||||
}}
|
||||
>
|
||||
更新全部
|
||||
{t('resources.proxyProviders.updateAll')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
{providers.map((provider, index) => {
|
||||
return (
|
||||
<Fragment key={provider.name}>
|
||||
{providers.map((provider, index) => (
|
||||
<Fragment key={provider.name}>
|
||||
<SettingItem
|
||||
title={provider.name}
|
||||
actions={
|
||||
<Chip className="ml-2" size="sm">
|
||||
{provider.proxies?.length || 0}
|
||||
</Chip>
|
||||
}
|
||||
divider={!provider.subscriptionInfo && index !== providers.length - 1}
|
||||
>
|
||||
<div className="flex h-[32px] leading-[32px] text-foreground-500">
|
||||
<div>{dayjs(provider.updatedAt).fromNow()}</div>
|
||||
{/* <Button isIconOnly className="ml-2" size="sm">
|
||||
<IoMdEye className="text-lg" />
|
||||
</Button> */}
|
||||
<Button
|
||||
isIconOnly
|
||||
title={provider.vehicleType == 'File' ? t('common.editor.edit') : t('common.viewer.view')}
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
setShowDetails({
|
||||
show: false,
|
||||
privderType: 'proxy-providers',
|
||||
path: provider.name,
|
||||
type: provider.vehicleType,
|
||||
title: provider.name
|
||||
})
|
||||
}}
|
||||
>
|
||||
{provider.vehicleType == 'File' ? (
|
||||
<MdEditDocument className={`text-lg`} />
|
||||
) : (
|
||||
<CgLoadbarDoc className={`text-lg`} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
title={t('common.updater.update')}
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
onUpdate(provider.name, index)
|
||||
}}
|
||||
>
|
||||
<IoMdRefresh className={`text-lg ${updating[index] ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
{provider.subscriptionInfo && (
|
||||
<SettingItem
|
||||
title={provider.name}
|
||||
actions={
|
||||
<Chip className="ml-2" size="sm">
|
||||
{provider.proxies?.length || 0}
|
||||
</Chip>
|
||||
}
|
||||
divider={!provider.subscriptionInfo && index !== providers.length - 1}
|
||||
>
|
||||
{
|
||||
<div className="flex h-[32px] leading-[32px] text-default-500">
|
||||
<div>{dayjs(provider.updatedAt).fromNow()}</div>
|
||||
<Button
|
||||
isIconOnly
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
onUpdate(provider.name, index)
|
||||
}}
|
||||
>
|
||||
<IoMdRefresh className={`text-lg ${updating[index] ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
divider={index !== providers.length - 1}
|
||||
title={
|
||||
<div className="text-foreground-500">
|
||||
{`${calcTraffic(
|
||||
provider.subscriptionInfo.Upload + provider.subscriptionInfo.Download
|
||||
)} / ${calcTraffic(provider.subscriptionInfo.Total)}`}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-[32px] leading-[32px] text-foreground-500">
|
||||
{provider.subscriptionInfo.Expire
|
||||
? dayjs.unix(provider.subscriptionInfo.Expire).format('YYYY-MM-DD')
|
||||
: t('profiles.neverExpire')}
|
||||
</div>
|
||||
</SettingItem>
|
||||
{provider.subscriptionInfo && (
|
||||
<SettingItem
|
||||
divider={index !== providers.length - 1}
|
||||
title={
|
||||
<div className="text-default-500">{`${calcTraffic(
|
||||
provider.subscriptionInfo.Upload + provider.subscriptionInfo.Download
|
||||
)}
|
||||
/${calcTraffic(provider.subscriptionInfo.Total)}`}</div>
|
||||
}
|
||||
>
|
||||
{provider.subscriptionInfo && (
|
||||
<div className="h-[32px] leading-[32px] text-default-500">
|
||||
{provider.subscriptionInfo.Expire
|
||||
? dayjs.unix(provider.subscriptionInfo.Expire).format('YYYY-MM-DD')
|
||||
: '长期有效'}
|
||||
</div>
|
||||
)}
|
||||
</SettingItem>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</SettingCard>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,18 +1,64 @@
|
||||
import { mihomoRuleProviders, mihomoUpdateRuleProviders } from '@renderer/utils/ipc'
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import {
|
||||
mihomoRuleProviders,
|
||||
mihomoUpdateRuleProviders,
|
||||
getRuntimeConfig
|
||||
} from '@renderer/utils/ipc'
|
||||
import { getHash } from '@renderer/utils/hash'
|
||||
import Viewer from './viewer'
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import SettingCard from '../base/base-setting-card'
|
||||
import SettingItem from '../base/base-setting-item'
|
||||
import { Button, Chip } from '@nextui-org/react'
|
||||
import { Button, Chip } from '@heroui/react'
|
||||
import { IoMdRefresh } from 'react-icons/io'
|
||||
import dayjs from 'dayjs'
|
||||
import { CgLoadbarDoc } from 'react-icons/cg'
|
||||
import { MdEditDocument } from 'react-icons/md'
|
||||
import dayjs from '@renderer/utils/dayjs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const RuleProvider: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [showDetails, setShowDetails] = useState({
|
||||
show: false,
|
||||
path: '',
|
||||
type: '',
|
||||
title: '',
|
||||
format: '',
|
||||
privderType: ''
|
||||
})
|
||||
useEffect(() => {
|
||||
if (showDetails.title) {
|
||||
const fetchProviderPath = async (name: string): Promise<void> => {
|
||||
try {
|
||||
const providers= await getRuntimeConfig()
|
||||
const provider = providers['rule-providers'][name]
|
||||
if (provider) {
|
||||
setShowDetails((prev) => ({
|
||||
...prev,
|
||||
show: true,
|
||||
path: provider?.path || `rules/${getHash(provider?.url)}`
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
setShowDetails((prev) => ({ ...prev, path: '' }))
|
||||
}
|
||||
}
|
||||
fetchProviderPath(showDetails.title)
|
||||
}
|
||||
}, [showDetails.title])
|
||||
|
||||
const { data, mutate } = useSWR('mihomoRuleProviders', mihomoRuleProviders)
|
||||
const providers = useMemo(() => {
|
||||
if (!data) return []
|
||||
if (!data.providers) return []
|
||||
return Object.keys(data.providers).map((key) => data.providers[key])
|
||||
return Object.values(data.providers).sort((a, b) => {
|
||||
if (a.vehicleType === 'File' && b.vehicleType !== 'File') {
|
||||
return -1
|
||||
}
|
||||
if (a.vehicleType !== 'File' && b.vehicleType === 'File') {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}, [data])
|
||||
const [updating, setUpdating] = useState(Array(providers.length).fill(false))
|
||||
|
||||
@ -40,7 +86,17 @@ const RuleProvider: React.FC = () => {
|
||||
|
||||
return (
|
||||
<SettingCard>
|
||||
<SettingItem title="规则集合" divider>
|
||||
{showDetails.show && (
|
||||
<Viewer
|
||||
path={showDetails.path}
|
||||
type={showDetails.type}
|
||||
title={showDetails.title}
|
||||
format={showDetails.format}
|
||||
privderType={showDetails.privderType}
|
||||
onClose={() => setShowDetails({ show: false, path: '', type: '', title: '', format: '', privderType: '' })}
|
||||
/>
|
||||
)}
|
||||
<SettingItem title={t('resources.ruleProviders.title')} divider>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
@ -50,47 +106,68 @@ const RuleProvider: React.FC = () => {
|
||||
})
|
||||
}}
|
||||
>
|
||||
更新全部
|
||||
{t('resources.ruleProviders.updateAll')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
{providers.map((provider, index) => {
|
||||
return (
|
||||
<Fragment key={provider.name}>
|
||||
<SettingItem
|
||||
title={provider.name}
|
||||
actions={
|
||||
<Chip className="ml-2" size="sm">
|
||||
{provider.ruleCount}
|
||||
</Chip>
|
||||
}
|
||||
>
|
||||
{
|
||||
<div className="flex h-[32px] leading-[32px] text-default-500">
|
||||
<div>{dayjs(provider.updatedAt).fromNow()}</div>
|
||||
<Button
|
||||
isIconOnly
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
onUpdate(provider.name, index)
|
||||
}}
|
||||
>
|
||||
<IoMdRefresh className={`text-lg ${updating[index] ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
title={<div className="text-default-500">{provider.format}</div>}
|
||||
divider={index !== providers.length - 1}
|
||||
>
|
||||
<div className="h-[32px] leading-[32px] text-default-500">
|
||||
{provider.vehicleType}::{provider.behavior}
|
||||
</div>
|
||||
</SettingItem>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{providers.map((provider, index) => (
|
||||
<Fragment key={provider.name}>
|
||||
<SettingItem
|
||||
title={provider.name}
|
||||
actions={
|
||||
<Chip className="ml-2" size="sm">
|
||||
{provider.ruleCount}
|
||||
</Chip>
|
||||
}
|
||||
>
|
||||
<div className="flex h-[32px] leading-[32px] text-foreground-500">
|
||||
<div>{dayjs(provider.updatedAt).fromNow()}</div>
|
||||
{provider.format !== 'MrsRule' && (
|
||||
<Button
|
||||
isIconOnly
|
||||
title={provider.vehicleType == 'File' ? t('common.editor.edit') : t('common.viewer.view')}
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
setShowDetails({
|
||||
show: false,
|
||||
privderType: 'rule-providers',
|
||||
path: provider.name,
|
||||
type: provider.vehicleType,
|
||||
title: provider.name,
|
||||
format: provider.format
|
||||
})
|
||||
}}
|
||||
>
|
||||
{provider.vehicleType == 'File' ? (
|
||||
<MdEditDocument className={`text-lg`} />
|
||||
) : (
|
||||
<CgLoadbarDoc className={`text-lg`} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isIconOnly
|
||||
title={t('common.updater.update')}
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
onUpdate(provider.name, index)
|
||||
}}
|
||||
>
|
||||
<IoMdRefresh className={`text-lg ${updating[index] ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
title={<div className="text-foreground-500">{provider.format}</div>}
|
||||
divider={index !== providers.length - 1}
|
||||
>
|
||||
<div className="h-[32px] leading-[32px] text-foreground-500">
|
||||
{provider.vehicleType}::{provider.behavior}
|
||||
</div>
|
||||
</SettingItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</SettingCard>
|
||||
)
|
||||
}
|
||||
|
||||
93
src/renderer/src/components/resources/viewer.tsx
Normal file
93
src/renderer/src/components/resources/viewer.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { BaseEditor } from '../base/base-editor'
|
||||
import { getFileStr, setFileStr } from '@renderer/utils/ipc'
|
||||
import yaml from 'js-yaml'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
type Language = 'yaml' | 'javascript' | 'css' | 'json' | 'text'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
path: string
|
||||
type: string
|
||||
title: string
|
||||
privderType: string
|
||||
format?: string
|
||||
}
|
||||
const Viewer: React.FC<Props> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const { type, path, title, format, privderType, onClose } = props
|
||||
const [currData, setCurrData] = useState('')
|
||||
let language: Language = !format || format === 'YamlRule' ? 'yaml' : 'text'
|
||||
|
||||
const getContent = async (): Promise<void> => {
|
||||
let fileContent: React.SetStateAction<string>
|
||||
if (type === 'Inline') {
|
||||
fileContent = await getFileStr('config.yaml')
|
||||
language = 'yaml'
|
||||
} else {
|
||||
fileContent = await getFileStr(path)
|
||||
}
|
||||
try {
|
||||
const parsedYaml = yaml.load(fileContent)
|
||||
if (privderType === 'proxy-providers') {
|
||||
setCurrData(yaml.dump({
|
||||
'proxies': parsedYaml[privderType][title].payload
|
||||
}))
|
||||
} else {
|
||||
setCurrData(yaml.dump({
|
||||
'rules': parsedYaml[privderType][title].payload
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
setCurrData(fileContent)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getContent()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
classNames={{ backdrop: 'top-[48px]' }}
|
||||
size="5xl"
|
||||
hideCloseButton
|
||||
isOpen={true}
|
||||
onOpenChange={onClose}
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent className="h-full w-[calc(100%-100px)]">
|
||||
<ModalHeader className="flex pb-0 app-drag">{title}</ModalHeader>
|
||||
<ModalBody className="h-full">
|
||||
<BaseEditor
|
||||
language={language}
|
||||
value={currData}
|
||||
readOnly={type != 'File'}
|
||||
onChange={(value) => setCurrData(value)}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter className="pt-0">
|
||||
<Button size="sm" variant="light" onPress={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
{type == 'File' && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={async () => {
|
||||
await setFileStr(path, currData)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default Viewer
|
||||
@ -1,4 +1,4 @@
|
||||
import { Card, CardBody } from '@nextui-org/react'
|
||||
import { Card, CardBody } from '@heroui/react'
|
||||
import React from 'react'
|
||||
|
||||
const RuleItem: React.FC<IMihomoRulesDetail & { index: number }> = (props) => {
|
||||
@ -10,7 +10,7 @@ const RuleItem: React.FC<IMihomoRulesDetail & { index: number }> = (props) => {
|
||||
<div title={payload} className="text-ellipsis whitespace-nowrap overflow-hidden">
|
||||
{payload}
|
||||
</div>
|
||||
<div className="flex justify-start text-default-500">
|
||||
<div className="flex justify-start text-foreground-500">
|
||||
<div>{type}</div>
|
||||
<div className="ml-2">{proxy}</div>
|
||||
</div>
|
||||
|
||||
@ -1,18 +1,28 @@
|
||||
import { Button, Tooltip } from '@nextui-org/react'
|
||||
import { Button, Tooltip } from '@heroui/react'
|
||||
import SettingCard from '../base/base-setting-card'
|
||||
import SettingItem from '../base/base-setting-item'
|
||||
import { checkUpdate, createHeapSnapshot, quitApp, quitWithoutCore } from '@renderer/utils/ipc'
|
||||
import {
|
||||
checkUpdate,
|
||||
createHeapSnapshot,
|
||||
quitApp,
|
||||
quitWithoutCore,
|
||||
resetAppConfig
|
||||
} from '@renderer/utils/ipc'
|
||||
import { useState } from 'react'
|
||||
import UpdaterModal from '../updater/updater-modal'
|
||||
import { version } from '@renderer/utils/init'
|
||||
import { IoIosHelpCircle } from 'react-icons/io'
|
||||
import { firstDriver } from '@renderer/App'
|
||||
import { getDriver } from '@renderer/App'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BaseConfirmModal from '../base/base-confirm-modal'
|
||||
|
||||
const Actions: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [newVersion, setNewVersion] = useState('')
|
||||
const [changelog, setChangelog] = useState('')
|
||||
const [openUpdate, setOpenUpdate] = useState(false)
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false)
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -23,13 +33,25 @@ const Actions: React.FC = () => {
|
||||
changelog={changelog}
|
||||
/>
|
||||
)}
|
||||
{showResetConfirm && (
|
||||
<BaseConfirmModal
|
||||
isOpen={showResetConfirm}
|
||||
title={t('actions.reset.confirm.title')}
|
||||
content={t('actions.reset.confirm.content')}
|
||||
onCancel={() => setShowResetConfirm(false)}
|
||||
onConfirm={() => {
|
||||
resetAppConfig()
|
||||
setShowResetConfirm(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SettingCard>
|
||||
<SettingItem title="打开引导页面" divider>
|
||||
<Button size="sm" onPress={() => firstDriver.drive()}>
|
||||
打开引导页面
|
||||
<SettingItem title={t('actions.guide.title')} divider>
|
||||
<Button size="sm" onPress={() => getDriver()?.drive()}>
|
||||
{t('actions.guide.button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
<SettingItem title="检查更新" divider>
|
||||
<SettingItem title={t('actions.update.title')} divider>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={checkingUpdate}
|
||||
@ -42,7 +64,9 @@ const Actions: React.FC = () => {
|
||||
setChangelog(version.changelog)
|
||||
setOpenUpdate(true)
|
||||
} else {
|
||||
new window.Notification('当前已是最新版本', { body: '无需更新' })
|
||||
new window.Notification(t('actions.update.upToDate.title'), {
|
||||
body: t('actions.update.upToDate.body')
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
@ -51,13 +75,28 @@ const Actions: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
检查更新
|
||||
{t('actions.update.button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
title="创建堆快照"
|
||||
title={t('actions.reset.title')}
|
||||
actions={
|
||||
<Tooltip content="创建主进程堆快照,用于排查内存问题">
|
||||
<Tooltip content={t('actions.reset.tooltip')}>
|
||||
<Button isIconOnly size="sm" variant="light">
|
||||
<IoIosHelpCircle className="text-lg" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
divider
|
||||
>
|
||||
<Button size="sm" onPress={() => setShowResetConfirm(true)}>
|
||||
{t('actions.reset.button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
title={t('actions.heapSnapshot.title')}
|
||||
actions={
|
||||
<Tooltip content={t('actions.heapSnapshot.tooltip')}>
|
||||
<Button isIconOnly size="sm" variant="light">
|
||||
<IoIosHelpCircle className="text-lg" />
|
||||
</Button>
|
||||
@ -66,13 +105,13 @@ const Actions: React.FC = () => {
|
||||
divider
|
||||
>
|
||||
<Button size="sm" onPress={createHeapSnapshot}>
|
||||
创建堆快照
|
||||
{t('actions.heapSnapshot.button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
title="轻量模式"
|
||||
title={t('actions.lightMode.title')}
|
||||
actions={
|
||||
<Tooltip content="完全退出软件,只保留内核进程">
|
||||
<Tooltip content={t('actions.lightMode.tooltip')}>
|
||||
<Button isIconOnly size="sm" variant="light">
|
||||
<IoIosHelpCircle className="text-lg" />
|
||||
</Button>
|
||||
@ -81,15 +120,15 @@ const Actions: React.FC = () => {
|
||||
divider
|
||||
>
|
||||
<Button size="sm" onPress={quitWithoutCore}>
|
||||
轻量模式
|
||||
{t('actions.lightMode.button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
<SettingItem title="退出应用" divider>
|
||||
<SettingItem title={t('actions.quit.title')} divider>
|
||||
<Button size="sm" onPress={quitApp}>
|
||||
退出应用
|
||||
{t('actions.quit.button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
<SettingItem title="应用版本">
|
||||
<SettingItem title={t('actions.version.title')}>
|
||||
<div>v{version}</div>
|
||||
</SettingItem>
|
||||
</SettingCard>
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@nextui-org/react'
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react'
|
||||
import { BaseEditor } from '@renderer/components/base/base-editor'
|
||||
import { readTheme } from '@renderer/utils/ipc'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
theme: string
|
||||
onCancel: () => void
|
||||
onConfirm: (script: string) => void
|
||||
}
|
||||
|
||||
const CSSEditorModal: React.FC<Props> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const { theme, onCancel, onConfirm } = props
|
||||
const [currData, setCurrData] = useState('')
|
||||
|
||||
@ -30,7 +34,7 @@ const CSSEditorModal: React.FC<Props> = (props) => {
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent className="h-full w-[calc(100%-100px)]">
|
||||
<ModalHeader className="flex pb-0">编辑主题</ModalHeader>
|
||||
<ModalHeader className="flex pb-0 app-drag">{t('theme.editor.title')}</ModalHeader>
|
||||
<ModalBody className="h-full">
|
||||
<BaseEditor
|
||||
language="css"
|
||||
@ -40,10 +44,10 @@ const CSSEditorModal: React.FC<Props> = (props) => {
|
||||
</ModalBody>
|
||||
<ModalFooter className="pt-0">
|
||||
<Button size="sm" variant="light" onPress={onCancel}>
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" color="primary" onPress={() => onConfirm(currData)}>
|
||||
确认
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import SettingCard from '../base/base-setting-card'
|
||||
import SettingItem from '../base/base-setting-item'
|
||||
import { Button, Input, Select, SelectItem, Switch, Tab, Tabs, Tooltip } from '@nextui-org/react'
|
||||
import { Button, Input, Select, SelectItem, Switch, Tab, Tabs, Tooltip } from '@heroui/react'
|
||||
import { BiCopy, BiSolidFileImport } from 'react-icons/bi'
|
||||
import useSWR from 'swr'
|
||||
import {
|
||||
applyTheme,
|
||||
checkAutoRun,
|
||||
closeFloatingWindow,
|
||||
closeTrayIcon,
|
||||
copyEnv,
|
||||
disableAutoRun,
|
||||
enableAutoRun,
|
||||
@ -15,35 +17,45 @@ import {
|
||||
importThemes,
|
||||
relaunchApp,
|
||||
resolveThemes,
|
||||
restartCore,
|
||||
showFloatingWindow,
|
||||
showTrayIcon,
|
||||
startMonitor,
|
||||
writeTheme
|
||||
} from '@renderer/utils/ipc'
|
||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||
import debounce from '@renderer/utils/debounce'
|
||||
import { platform } from '@renderer/utils/init'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { IoIosHelpCircle, IoMdCloudDownload } from 'react-icons/io'
|
||||
import { MdEditDocument } from 'react-icons/md'
|
||||
import CSSEditorModal from './css-editor-modal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const GeneralConfig: React.FC = () => {
|
||||
const { data: enable, mutate: mutateEnable } = useSWR('checkAutoRun', checkAutoRun)
|
||||
const { t, i18n } = useTranslation()
|
||||
const { data: enable = false, mutate: mutateEnable } = useSWR('checkAutoRun', checkAutoRun)
|
||||
const { appConfig, patchAppConfig } = useAppConfig()
|
||||
const [customThemes, setCustomThemes] = useState<{ key: string; label: string }[]>()
|
||||
const [openCSSEditor, setOpenCSSEditor] = useState(false)
|
||||
const [fetching, setFetching] = useState(false)
|
||||
const [isRelaunching, setIsRelaunching] = useState(false)
|
||||
const { setTheme } = useTheme()
|
||||
const {
|
||||
silentStart = false,
|
||||
useDockIcon = true,
|
||||
showTraffic = true,
|
||||
showTraffic = false,
|
||||
proxyInTray = true,
|
||||
disableTray = false,
|
||||
showFloatingWindow: showFloating = false,
|
||||
spinFloatingIcon = true,
|
||||
useWindowFrame = false,
|
||||
autoQuitWithoutCore = false,
|
||||
autoQuitWithoutCoreDelay = 60,
|
||||
customTheme = 'default.css',
|
||||
envType = [platform === 'win32' ? 'powershell' : 'bash'],
|
||||
autoCheckUpdate,
|
||||
appTheme = 'system'
|
||||
appTheme = 'system',
|
||||
language = 'zh-CN'
|
||||
} = appConfig || {}
|
||||
|
||||
useEffect(() => {
|
||||
@ -66,7 +78,26 @@ const GeneralConfig: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
<SettingCard>
|
||||
<SettingItem title="开机自启" divider>
|
||||
<SettingItem title={t('settings.language')} divider>
|
||||
<Select
|
||||
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
|
||||
className="w-[150px]"
|
||||
size="sm"
|
||||
selectedKeys={[language]}
|
||||
aria-label={t('settings.language')}
|
||||
onSelectionChange={async (v) => {
|
||||
const newLang = Array.from(v)[0] as 'zh-CN' | 'en-US' | 'ru-RU' | 'fa-IR'
|
||||
await patchAppConfig({ language: newLang })
|
||||
i18n.changeLanguage(newLang)
|
||||
}}
|
||||
>
|
||||
<SelectItem key="zh-CN">中文简体</SelectItem>
|
||||
<SelectItem key="en-US">English</SelectItem>
|
||||
<SelectItem key="ru-RU">Русский</SelectItem>
|
||||
<SelectItem key="fa-IR">فارسی</SelectItem>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
<SettingItem title={t('settings.autoStart')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={enable}
|
||||
@ -85,7 +116,7 @@ const GeneralConfig: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="自动检查更新" divider>
|
||||
<SettingItem title={t('settings.autoCheckUpdate')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={autoCheckUpdate}
|
||||
@ -94,7 +125,7 @@ const GeneralConfig: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="静默启动" divider>
|
||||
<SettingItem title={t('settings.silentStart')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={silentStart}
|
||||
@ -104,9 +135,9 @@ const GeneralConfig: React.FC = () => {
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
title="自动开启轻量模式"
|
||||
title={t('settings.autoQuitWithoutCore')}
|
||||
actions={
|
||||
<Tooltip content="关闭窗口指定时间后自动进入轻量模式">
|
||||
<Tooltip content={t('settings.autoQuitWithoutCoreTooltip')}>
|
||||
<Button isIconOnly size="sm" variant="light">
|
||||
<IoIosHelpCircle className="text-lg" />
|
||||
</Button>
|
||||
@ -123,12 +154,12 @@ const GeneralConfig: React.FC = () => {
|
||||
/>
|
||||
</SettingItem>
|
||||
{autoQuitWithoutCore && (
|
||||
<SettingItem title="自动开启轻量模式延时" divider>
|
||||
<SettingItem title={t('settings.autoQuitWithoutCoreDelay')} divider>
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-[100px]"
|
||||
type="number"
|
||||
endContent="秒"
|
||||
endContent={t('common.seconds')}
|
||||
value={autoQuitWithoutCoreDelay.toString()}
|
||||
onValueChange={async (v: string) => {
|
||||
let num = parseInt(v)
|
||||
@ -140,7 +171,7 @@ const GeneralConfig: React.FC = () => {
|
||||
</SettingItem>
|
||||
)}
|
||||
<SettingItem
|
||||
title="复制环境变量类型"
|
||||
title={t('settings.envType')}
|
||||
actions={envType.map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
@ -156,10 +187,13 @@ const GeneralConfig: React.FC = () => {
|
||||
divider
|
||||
>
|
||||
<Select
|
||||
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
|
||||
className="w-[150px]"
|
||||
size="sm"
|
||||
selectionMode="multiple"
|
||||
selectedKeys={new Set(envType)}
|
||||
aria-label={t('settings.envType')}
|
||||
disallowEmptySelection={true}
|
||||
onSelectionChange={async (v) => {
|
||||
try {
|
||||
await patchAppConfig({
|
||||
@ -175,20 +209,80 @@ const GeneralConfig: React.FC = () => {
|
||||
<SelectItem key="powershell">PowerShell</SelectItem>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
<SettingItem title={t('settings.showFloatingWindow')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={showFloating}
|
||||
onValueChange={async (v) => {
|
||||
await patchAppConfig({ showFloatingWindow: v })
|
||||
if (v) {
|
||||
showFloatingWindow()
|
||||
} else {
|
||||
closeFloatingWindow()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{showFloating && (
|
||||
<>
|
||||
<SettingItem title={t('settings.spinFloatingIcon')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={spinFloatingIcon}
|
||||
onValueChange={async (v) => {
|
||||
await patchAppConfig({ spinFloatingIcon: v })
|
||||
window.electron.ipcRenderer.send('updateFloatingWindow')
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title={t('settings.disableTray')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={disableTray}
|
||||
onValueChange={async (v) => {
|
||||
await patchAppConfig({ disableTray: v })
|
||||
if (v) {
|
||||
closeTrayIcon()
|
||||
} else {
|
||||
showTrayIcon()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
</>
|
||||
)}
|
||||
{platform !== 'linux' && (
|
||||
<SettingItem title="托盘菜单显示节点信息" divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={proxyInTray}
|
||||
onValueChange={async (v) => {
|
||||
await patchAppConfig({ proxyInTray: v })
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<>
|
||||
<SettingItem title={t('settings.proxyInTray')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={proxyInTray}
|
||||
onValueChange={async (v) => {
|
||||
await patchAppConfig({ proxyInTray: v })
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
title={t('settings.showTraffic', {
|
||||
context: platform === 'win32' ? 'windows' : 'mac'
|
||||
})}
|
||||
divider
|
||||
>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={showTraffic}
|
||||
onValueChange={async (v) => {
|
||||
await patchAppConfig({ showTraffic: v })
|
||||
await startMonitor()
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
</>
|
||||
)}
|
||||
{platform === 'darwin' && (
|
||||
<>
|
||||
<SettingItem title="显示 Dock 图标" divider>
|
||||
<SettingItem title={t('settings.showDockIcon')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={useDockIcon}
|
||||
@ -197,30 +291,28 @@ const GeneralConfig: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="显示网速信息" divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={showTraffic}
|
||||
onValueChange={async (v) => {
|
||||
await patchAppConfig({ showTraffic: v })
|
||||
await restartCore()
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingItem title="使用系统标题栏" divider>
|
||||
<SettingItem title={t('settings.useWindowFrame')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={useWindowFrame}
|
||||
onValueChange={async (v) => {
|
||||
await patchAppConfig({ useWindowFrame: v })
|
||||
await relaunchApp()
|
||||
}}
|
||||
isDisabled={isRelaunching}
|
||||
onValueChange={debounce(async (v) => {
|
||||
if (isRelaunching) return
|
||||
setIsRelaunching(true)
|
||||
try {
|
||||
await patchAppConfig({ useWindowFrame: v })
|
||||
await relaunchApp()
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
setIsRelaunching(false)
|
||||
}
|
||||
}, 1000)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="背景色" divider>
|
||||
<SettingItem title={t('settings.backgroundColor')} divider>
|
||||
<Tabs
|
||||
size="sm"
|
||||
color="primary"
|
||||
@ -230,20 +322,20 @@ const GeneralConfig: React.FC = () => {
|
||||
patchAppConfig({ appTheme: key as AppTheme })
|
||||
}}
|
||||
>
|
||||
<Tab key="system" title="自动" />
|
||||
<Tab key="dark" title="深色" />
|
||||
<Tab key="light" title="浅色" />
|
||||
<Tab key="system" title={t('settings.backgroundAuto')} />
|
||||
<Tab key="dark" title={t('settings.backgroundDark')} />
|
||||
<Tab key="light" title={t('settings.backgroundLight')} />
|
||||
</Tabs>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
title="主题"
|
||||
title={t('settings.theme')}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={fetching}
|
||||
isIconOnly
|
||||
title="拉取主题"
|
||||
title={t('settings.fetchTheme')}
|
||||
variant="light"
|
||||
onPress={async () => {
|
||||
setFetching(true)
|
||||
@ -262,7 +354,7 @@ const GeneralConfig: React.FC = () => {
|
||||
<Button
|
||||
size="sm"
|
||||
isIconOnly
|
||||
title="导入主题"
|
||||
title={t('settings.importTheme')}
|
||||
variant="light"
|
||||
onPress={async () => {
|
||||
const files = await getFilePath(['css'])
|
||||
@ -280,7 +372,7 @@ const GeneralConfig: React.FC = () => {
|
||||
<Button
|
||||
size="sm"
|
||||
isIconOnly
|
||||
title="编辑主题"
|
||||
title={t('settings.editTheme')}
|
||||
variant="light"
|
||||
onPress={async () => {
|
||||
setOpenCSSEditor(true)
|
||||
@ -293,9 +385,12 @@ const GeneralConfig: React.FC = () => {
|
||||
>
|
||||
{customThemes && (
|
||||
<Select
|
||||
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
|
||||
className="w-[60%]"
|
||||
size="sm"
|
||||
selectedKeys={new Set([customTheme])}
|
||||
aria-label={t('settings.selectTheme')}
|
||||
disallowEmptySelection={true}
|
||||
onSelectionChange={async (v) => {
|
||||
try {
|
||||
await patchAppConfig({ customTheme: v.currentKey as string })
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
import React, { useState } from 'react'
|
||||
import SettingCard from '../base/base-setting-card'
|
||||
import SettingItem from '../base/base-setting-item'
|
||||
import { Button, Input, Select, SelectItem, Switch } from '@nextui-org/react'
|
||||
import { Button, Input, Select, SelectItem, Switch, Tooltip } from '@heroui/react'
|
||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||
import debounce from '@renderer/utils/debounce'
|
||||
import { getGistUrl, patchControledMihomoConfig, restartCore } from '@renderer/utils/ipc'
|
||||
import { MdDeleteForever } from 'react-icons/md'
|
||||
import { BiCopy } from 'react-icons/bi'
|
||||
import { IoIosHelpCircle } from 'react-icons/io'
|
||||
import { platform, version } from '@renderer/utils/init'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const MihomoConfig: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { appConfig, patchAppConfig } = useAppConfig()
|
||||
const {
|
||||
diffWorkDir = false,
|
||||
controlDns = true,
|
||||
controlSniff = true,
|
||||
delayTestConcurrency,
|
||||
@ -20,6 +25,7 @@ const MihomoConfig: React.FC = () => {
|
||||
pauseSSID = [],
|
||||
delayTestUrl,
|
||||
userAgent,
|
||||
mihomoCpuPriority = 'PRIORITY_NORMAL',
|
||||
proxyCols = 'auto'
|
||||
} = appConfig || {}
|
||||
const [url, setUrl] = useState(delayTestUrl)
|
||||
@ -33,59 +39,59 @@ const MihomoConfig: React.FC = () => {
|
||||
}, 500)
|
||||
return (
|
||||
<SettingCard>
|
||||
<SettingItem title="订阅拉取 UA" divider>
|
||||
<SettingItem title={t('mihomo.userAgent')} divider>
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-[60%]"
|
||||
value={ua}
|
||||
placeholder="默认 clash.meta"
|
||||
placeholder={t('mihomo.userAgentPlaceholder', { version })}
|
||||
onValueChange={(v) => {
|
||||
setUa(v)
|
||||
setUaDebounce(v)
|
||||
}}
|
||||
></Input>
|
||||
</SettingItem>
|
||||
<SettingItem title="延迟测试地址" divider>
|
||||
<SettingItem title={t('mihomo.delayTest.url')} divider>
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-[60%]"
|
||||
value={url}
|
||||
placeholder="默认 https://www.gstatic.com/generate_204"
|
||||
placeholder={t('mihomo.delayTest.urlPlaceholder')}
|
||||
onValueChange={(v) => {
|
||||
setUrl(v)
|
||||
setUrlDebounce(v)
|
||||
}}
|
||||
></Input>
|
||||
</SettingItem>
|
||||
<SettingItem title="延迟测试并发数量" divider>
|
||||
<SettingItem title={t('mihomo.delayTest.concurrency')} divider>
|
||||
<Input
|
||||
type="number"
|
||||
size="sm"
|
||||
className="w-[60%]"
|
||||
value={delayTestConcurrency?.toString()}
|
||||
placeholder="默认 50"
|
||||
placeholder={t('mihomo.delayTest.concurrencyPlaceholder')}
|
||||
onValueChange={(v) => {
|
||||
patchAppConfig({ delayTestConcurrency: parseInt(v) })
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="延迟测试超时时间" divider>
|
||||
<SettingItem title={t('mihomo.delayTest.timeout')} divider>
|
||||
<Input
|
||||
type="number"
|
||||
size="sm"
|
||||
className="w-[60%]"
|
||||
value={delayTestTimeout?.toString()}
|
||||
placeholder="默认 5000"
|
||||
placeholder={t('mihomo.delayTest.timeoutPlaceholder')}
|
||||
onValueChange={(v) => {
|
||||
patchAppConfig({ delayTestTimeout: parseInt(v) })
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
title="同步运行时配置到 Gist"
|
||||
title={t('mihomo.gist.title')}
|
||||
actions={
|
||||
<Button
|
||||
title="复制 Gist URL"
|
||||
title={t('mihomo.gist.copyUrl')}
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
@ -110,29 +116,84 @@ const MihomoConfig: React.FC = () => {
|
||||
size="sm"
|
||||
className="w-[60%]"
|
||||
value={githubToken}
|
||||
placeholder="GitHub Token"
|
||||
placeholder={t('mihomo.gist.token')}
|
||||
onValueChange={(v) => {
|
||||
patchAppConfig({ githubToken: v })
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="代理节点展示列数" divider>
|
||||
<SettingItem title={t('mihomo.proxyColumns.title')} divider>
|
||||
<Select
|
||||
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
|
||||
className="w-[150px]"
|
||||
size="sm"
|
||||
selectedKeys={new Set([proxyCols])}
|
||||
aria-label={t('mihomo.proxyColumns.title')}
|
||||
disallowEmptySelection={true}
|
||||
onSelectionChange={async (v) => {
|
||||
await patchAppConfig({ proxyCols: v.currentKey as 'auto' | '1' | '2' | '3' | '4' })
|
||||
}}
|
||||
>
|
||||
<SelectItem key="auto">自动</SelectItem>
|
||||
<SelectItem key="1">一列</SelectItem>
|
||||
<SelectItem key="2">两列</SelectItem>
|
||||
<SelectItem key="3">三列</SelectItem>
|
||||
<SelectItem key="4">四列</SelectItem>
|
||||
<SelectItem key="auto">{t('mihomo.proxyColumns.auto')}</SelectItem>
|
||||
<SelectItem key="1">{t('mihomo.proxyColumns.one')}</SelectItem>
|
||||
<SelectItem key="2">{t('mihomo.proxyColumns.two')}</SelectItem>
|
||||
<SelectItem key="3">{t('mihomo.proxyColumns.three')}</SelectItem>
|
||||
<SelectItem key="4">{t('mihomo.proxyColumns.four')}</SelectItem>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
<SettingItem title="接管 DNS 设置" divider>
|
||||
{platform === 'win32' && (
|
||||
<SettingItem title={t('mihomo.cpuPriority.title')} divider>
|
||||
<Select
|
||||
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
|
||||
className="w-[150px]"
|
||||
size="sm"
|
||||
selectedKeys={new Set([mihomoCpuPriority])}
|
||||
disallowEmptySelection={true}
|
||||
onSelectionChange={async (v) => {
|
||||
try {
|
||||
await patchAppConfig({
|
||||
mihomoCpuPriority: v.currentKey as Priority
|
||||
})
|
||||
await restartCore()
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem key="PRIORITY_HIGHEST">{t('mihomo.cpuPriority.realtime')}</SelectItem>
|
||||
<SelectItem key="PRIORITY_HIGH">{t('mihomo.cpuPriority.high')}</SelectItem>
|
||||
<SelectItem key="PRIORITY_ABOVE_NORMAL">{t('mihomo.cpuPriority.aboveNormal')}</SelectItem>
|
||||
<SelectItem key="PRIORITY_NORMAL">{t('mihomo.cpuPriority.normal')}</SelectItem>
|
||||
<SelectItem key="PRIORITY_BELOW_NORMAL">{t('mihomo.cpuPriority.belowNormal')}</SelectItem>
|
||||
<SelectItem key="PRIORITY_LOW">{t('mihomo.cpuPriority.low')}</SelectItem>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
)}
|
||||
<SettingItem
|
||||
title={t('mihomo.workDir.title')}
|
||||
actions={
|
||||
<Tooltip content={t('mihomo.workDir.tooltip')}>
|
||||
<Button isIconOnly size="sm" variant="light">
|
||||
<IoIosHelpCircle className="text-lg" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
divider
|
||||
>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={diffWorkDir}
|
||||
onValueChange={async (v) => {
|
||||
try {
|
||||
await patchAppConfig({ diffWorkDir: v })
|
||||
await restartCore()
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title={t('mihomo.controlDns')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={controlDns}
|
||||
@ -147,7 +208,7 @@ const MihomoConfig: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="接管域名嗅探设置" divider>
|
||||
<SettingItem title={t('mihomo.controlSniff')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={controlSniff}
|
||||
@ -162,7 +223,7 @@ const MihomoConfig: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="自动断开连接" divider>
|
||||
<SettingItem title={t('mihomo.autoCloseConnection')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={autoCloseConnection}
|
||||
@ -171,7 +232,7 @@ const MihomoConfig: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="在特定的 WiFi SSID 下直连">
|
||||
<SettingItem title={t('mihomo.pauseSSID.title')}>
|
||||
{pauseSSIDInput.join('') !== pauseSSID.join('') && (
|
||||
<Button
|
||||
size="sm"
|
||||
@ -180,7 +241,7 @@ const MihomoConfig: React.FC = () => {
|
||||
patchAppConfig({ pauseSSID: pauseSSIDInput })
|
||||
}}
|
||||
>
|
||||
确认
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
)}
|
||||
</SettingItem>
|
||||
@ -207,7 +268,7 @@ const MihomoConfig: React.FC = () => {
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="warning"
|
||||
onClick={() => setPauseSSIDInput(pauseSSIDInput.filter((_, i) => i !== index))}
|
||||
onPress={() => setPauseSSIDInput(pauseSSIDInput.filter((_, i) => i !== index))}
|
||||
>
|
||||
<MdDeleteForever className="text-lg" />
|
||||
</Button>
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { Button, Input } from '@nextui-org/react'
|
||||
import { Button, Input } from '@heroui/react'
|
||||
import SettingCard from '../base/base-setting-card'
|
||||
import SettingItem from '../base/base-setting-item'
|
||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||
import React, { KeyboardEvent, useState } from 'react'
|
||||
import { platform } from '@renderer/utils/init'
|
||||
import { registerShortcut } from '@renderer/utils/ipc'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const keyMap = {
|
||||
Backquote: '`',
|
||||
@ -40,9 +41,11 @@ const keyMap = {
|
||||
}
|
||||
|
||||
const ShortcutConfig: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { appConfig, patchAppConfig } = useAppConfig()
|
||||
const {
|
||||
showWindowShortcut = '',
|
||||
showFloatingWindowShortcut = '',
|
||||
triggerSysProxyShortcut = '',
|
||||
triggerTunShortcut = '',
|
||||
ruleModeShortcut = '',
|
||||
@ -53,8 +56,8 @@ const ShortcutConfig: React.FC = () => {
|
||||
} = appConfig || {}
|
||||
|
||||
return (
|
||||
<SettingCard title="快捷键设置">
|
||||
<SettingItem title="打开/关闭窗口" divider>
|
||||
<SettingCard title={t('shortcuts.title')}>
|
||||
<SettingItem title={t('shortcuts.toggleWindow')} divider>
|
||||
<div className="flex justify-end w-[60%]">
|
||||
<ShortcutInput
|
||||
value={showWindowShortcut}
|
||||
@ -63,7 +66,16 @@ const ShortcutConfig: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title="打开/关闭系统代理" divider>
|
||||
<SettingItem title={t('shortcuts.toggleFloatingWindow')} divider>
|
||||
<div className="flex justify-end w-[60%]">
|
||||
<ShortcutInput
|
||||
value={showFloatingWindowShortcut}
|
||||
patchAppConfig={patchAppConfig}
|
||||
action="showFloatingWindowShortcut"
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title={t('shortcuts.toggleSystemProxy')} divider>
|
||||
<div className="flex justify-end w-[60%]">
|
||||
<ShortcutInput
|
||||
value={triggerSysProxyShortcut}
|
||||
@ -72,7 +84,7 @@ const ShortcutConfig: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title="打开/关闭虚拟网卡" divider>
|
||||
<SettingItem title={t('shortcuts.toggleTun')} divider>
|
||||
<div className="flex justify-end w-[60%]">
|
||||
<ShortcutInput
|
||||
value={triggerTunShortcut}
|
||||
@ -81,7 +93,7 @@ const ShortcutConfig: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title="切换规则模式" divider>
|
||||
<SettingItem title={t('shortcuts.toggleRuleMode')} divider>
|
||||
<div className="flex justify-end w-[60%]">
|
||||
<ShortcutInput
|
||||
value={ruleModeShortcut}
|
||||
@ -90,7 +102,7 @@ const ShortcutConfig: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title="切换全局模式" divider>
|
||||
<SettingItem title={t('shortcuts.toggleGlobalMode')} divider>
|
||||
<div className="flex justify-end w-[60%]">
|
||||
<ShortcutInput
|
||||
value={globalModeShortcut}
|
||||
@ -99,7 +111,7 @@ const ShortcutConfig: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title="切换直连模式" divider>
|
||||
<SettingItem title={t('shortcuts.toggleDirectMode')} divider>
|
||||
<div className="flex justify-end w-[60%]">
|
||||
<ShortcutInput
|
||||
value={directModeShortcut}
|
||||
@ -108,7 +120,7 @@ const ShortcutConfig: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title="轻量模式" divider>
|
||||
<SettingItem title={t('shortcuts.toggleLightMode')} divider>
|
||||
<div className="flex justify-end w-[60%]">
|
||||
<ShortcutInput
|
||||
value={quitWithoutCoreShortcut}
|
||||
@ -117,7 +129,7 @@ const ShortcutConfig: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title="重启应用">
|
||||
<SettingItem title={t('shortcuts.restartApp')}>
|
||||
<div className="flex justify-end w-[60%]">
|
||||
<ShortcutInput
|
||||
value={restartAppShortcut}
|
||||
@ -135,6 +147,7 @@ const ShortcutInput: React.FC<{
|
||||
action: string
|
||||
patchAppConfig: (value: Partial<IAppConfig>) => Promise<void>
|
||||
}> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const { value, action, patchAppConfig } = props
|
||||
const [inputValue, setInputValue] = useState(value)
|
||||
|
||||
@ -200,18 +213,18 @@ const ShortcutInput: React.FC<{
|
||||
await patchAppConfig({ [action]: inputValue })
|
||||
window.electron.ipcRenderer.send('updateTrayMenu')
|
||||
} else {
|
||||
alert('快捷键注册失败')
|
||||
alert(t('common.error.shortcutRegistrationFailed'))
|
||||
}
|
||||
} catch (e) {
|
||||
alert(`快捷键注册失败: ${e}`)
|
||||
alert(t('common.error.shortcutRegistrationFailedWithError', { error: e }))
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
placeholder="点击输入快捷键"
|
||||
placeholder={t('shortcuts.input.placeholder')}
|
||||
onKeyDown={(e: KeyboardEvent): void => {
|
||||
parseShortcut(e, setInputValue)
|
||||
}}
|
||||
|
||||
@ -1,76 +1,73 @@
|
||||
import React from 'react'
|
||||
import SettingCard from '../base/base-setting-card'
|
||||
import SettingItem from '../base/base-setting-item'
|
||||
import { RadioGroup, Radio } from '@nextui-org/react'
|
||||
import SettingCard from '@renderer/components/base/base-setting-card'
|
||||
import SettingItem from '@renderer/components/base/base-setting-item'
|
||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||
const titleMap = {
|
||||
sysproxyCardStatus: '系统代理',
|
||||
tunCardStatus: '虚拟网卡',
|
||||
profileCardStatus: '订阅管理',
|
||||
proxyCardStatus: '代理组',
|
||||
ruleCardStatus: '规则',
|
||||
resourceCardStatus: '外部资源',
|
||||
overrideCardStatus: '覆写',
|
||||
connectionCardStatus: '连接',
|
||||
mihomoCoreCardStatus: '内核',
|
||||
dnsCardStatus: 'DNS',
|
||||
sniffCardStatus: '域名嗅探',
|
||||
logCardStatus: '日志',
|
||||
substoreCardStatus: 'Sub-Store'
|
||||
import { Radio, RadioGroup } from '@heroui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { FC } from 'react'
|
||||
|
||||
const titleMap: Record<string, string> = {
|
||||
sysproxyCardStatus: 'sider.cards.systemProxy',
|
||||
tunCardStatus: 'sider.cards.tun',
|
||||
profileCardStatus: 'sider.cards.profiles',
|
||||
proxyCardStatus: 'sider.cards.proxies',
|
||||
ruleCardStatus: 'sider.cards.rules',
|
||||
resourceCardStatus: 'sider.cards.resources',
|
||||
overrideCardStatus: 'sider.cards.override',
|
||||
connectionCardStatus: 'sider.cards.connections',
|
||||
mihomoCoreCardStatus: 'sider.cards.core',
|
||||
dnsCardStatus: 'sider.cards.dns',
|
||||
sniffCardStatus: 'sider.cards.sniff',
|
||||
logCardStatus: 'sider.cards.logs',
|
||||
substoreCardStatus: 'sider.cards.substore'
|
||||
}
|
||||
const SiderConfig: React.FC = () => {
|
||||
|
||||
const sizeMap: Record<string, string> = {
|
||||
'col-span-2': 'sider.size.large',
|
||||
'col-span-1': 'sider.size.small',
|
||||
hidden: 'sider.size.hidden'
|
||||
}
|
||||
|
||||
const SiderConfig: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { appConfig, patchAppConfig } = useAppConfig()
|
||||
const {
|
||||
sysproxyCardStatus = 'col-span-1',
|
||||
tunCardStatus = 'col-span-1',
|
||||
profileCardStatus = 'col-span-2',
|
||||
proxyCardStatus = 'col-span-1',
|
||||
ruleCardStatus = 'col-span-1',
|
||||
resourceCardStatus = 'col-span-1',
|
||||
overrideCardStatus = 'col-span-1',
|
||||
connectionCardStatus = 'col-span-2',
|
||||
mihomoCoreCardStatus = 'col-span-2',
|
||||
dnsCardStatus = 'col-span-1',
|
||||
sniffCardStatus = 'col-span-1',
|
||||
logCardStatus = 'col-span-1',
|
||||
substoreCardStatus = 'col-span-1'
|
||||
} = appConfig || {}
|
||||
|
||||
const cardStatus = {
|
||||
sysproxyCardStatus,
|
||||
tunCardStatus,
|
||||
profileCardStatus,
|
||||
proxyCardStatus,
|
||||
ruleCardStatus,
|
||||
resourceCardStatus,
|
||||
overrideCardStatus,
|
||||
connectionCardStatus,
|
||||
mihomoCoreCardStatus,
|
||||
dnsCardStatus,
|
||||
sniffCardStatus,
|
||||
logCardStatus,
|
||||
substoreCardStatus
|
||||
sysproxyCardStatus: appConfig?.sysproxyCardStatus || 'col-span-1',
|
||||
tunCardStatus: appConfig?.tunCardStatus || 'col-span-1',
|
||||
profileCardStatus: appConfig?.profileCardStatus || 'col-span-2',
|
||||
proxyCardStatus: appConfig?.proxyCardStatus || 'col-span-1',
|
||||
ruleCardStatus: appConfig?.ruleCardStatus || 'col-span-1',
|
||||
resourceCardStatus: appConfig?.resourceCardStatus || 'col-span-1',
|
||||
overrideCardStatus: appConfig?.overrideCardStatus || 'col-span-1',
|
||||
connectionCardStatus: appConfig?.connectionCardStatus || 'col-span-2',
|
||||
mihomoCoreCardStatus: appConfig?.mihomoCoreCardStatus || 'col-span-2',
|
||||
dnsCardStatus: appConfig?.dnsCardStatus || 'col-span-1',
|
||||
sniffCardStatus: appConfig?.sniffCardStatus || 'col-span-1',
|
||||
logCardStatus: appConfig?.logCardStatus || 'col-span-1',
|
||||
substoreCardStatus: appConfig?.substoreCardStatus || 'col-span-1'
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingCard title="侧边栏设置">
|
||||
{Object.keys(cardStatus).map((key, index, array) => {
|
||||
return (
|
||||
<SettingItem title={titleMap[key]} key={key} divider={index !== array.length - 1}>
|
||||
<RadioGroup
|
||||
orientation="horizontal"
|
||||
value={cardStatus[key]}
|
||||
onValueChange={(v) => {
|
||||
patchAppConfig({ [key]: v as CardStatus })
|
||||
}}
|
||||
>
|
||||
<Radio value="col-span-2">大</Radio>
|
||||
<Radio value="col-span-1">小</Radio>
|
||||
<Radio value="hidden">隐藏</Radio>
|
||||
</RadioGroup>
|
||||
</SettingItem>
|
||||
)
|
||||
})}
|
||||
<SettingCard title={t('sider.title')}>
|
||||
{Object.entries(cardStatus).map(([key, value]) => (
|
||||
<SettingItem key={key} title={t(titleMap[key])}>
|
||||
<RadioGroup
|
||||
orientation="horizontal"
|
||||
value={value}
|
||||
onValueChange={(v: string) => {
|
||||
if (v === 'col-span-1' || v === 'col-span-2' || v === 'hidden') {
|
||||
patchAppConfig({ [key]: v })
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Object.entries(sizeMap).map(([size, label]) => (
|
||||
<Radio key={size} value={size}>
|
||||
{t(label)}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</SettingItem>
|
||||
))}
|
||||
</SettingCard>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,17 +1,26 @@
|
||||
import React, { useState } from 'react'
|
||||
import SettingCard from '@renderer/components/base/base-setting-card'
|
||||
import SettingItem from '@renderer/components/base/base-setting-item'
|
||||
import { Button, Input, Switch } from '@nextui-org/react'
|
||||
import { startSubStoreServer } from '@renderer/utils/ipc'
|
||||
import { Button, Input, Switch } from '@heroui/react'
|
||||
import {
|
||||
startSubStoreFrontendServer,
|
||||
startSubStoreBackendServer,
|
||||
stopSubStoreFrontendServer,
|
||||
stopSubStoreBackendServer
|
||||
} from '@renderer/utils/ipc'
|
||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||
import debounce from '@renderer/utils/debounce'
|
||||
import { isValidCron } from 'cron-validator'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SubStoreConfig: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { appConfig, patchAppConfig } = useAppConfig()
|
||||
const {
|
||||
useSubStore = true,
|
||||
useCustomSubStore = false,
|
||||
useProxyInSubStore = false,
|
||||
subStoreHost = '127.0.0.1',
|
||||
customSubStoreUrl,
|
||||
subStoreBackendSyncCron,
|
||||
subStoreBackendDownloadCron,
|
||||
@ -30,15 +39,21 @@ const SubStoreConfig: React.FC = () => {
|
||||
const [subStoreBackendUploadCronValue, setSubStoreBackendUploadCronValue] =
|
||||
useState(subStoreBackendUploadCron)
|
||||
return (
|
||||
<SettingCard title="Sub-Store 设置">
|
||||
<SettingItem title="启用 Sub-Store" divider>
|
||||
<SettingCard title={t('substore.title')}>
|
||||
<SettingItem title={t('substore.enable')} divider={useSubStore}>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={useSubStore}
|
||||
onValueChange={async (v) => {
|
||||
try {
|
||||
await patchAppConfig({ useSubStore: v })
|
||||
if (v) await startSubStoreServer()
|
||||
if (v) {
|
||||
await startSubStoreFrontendServer()
|
||||
await startSubStoreBackendServer()
|
||||
} else {
|
||||
await stopSubStoreFrontendServer()
|
||||
await stopSubStoreBackendServer()
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
}
|
||||
@ -46,138 +61,174 @@ const SubStoreConfig: React.FC = () => {
|
||||
/>
|
||||
</SettingItem>
|
||||
{useSubStore && (
|
||||
<SettingItem title="使用自建 Sub-Store 后端" divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={useCustomSubStore}
|
||||
onValueChange={async (v) => {
|
||||
try {
|
||||
await patchAppConfig({ useCustomSubStore: v })
|
||||
if (!v) await startSubStoreServer()
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
{useCustomSubStore ? (
|
||||
<SettingItem title="自建 Sub-Store 后端地址">
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-[60%]"
|
||||
value={customSubStoreUrlValue}
|
||||
placeholder="必须包含协议头"
|
||||
onValueChange={(v: string) => {
|
||||
setCustomSubStoreUrlValue(v)
|
||||
setCustomSubStoreUrl(v)
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
) : (
|
||||
<>
|
||||
<SettingItem title="定时同步订阅/文件" divider>
|
||||
<div className="flex w-[60%] gap-2">
|
||||
{subStoreBackendSyncCronValue !== subStoreBackendSyncCron && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={async () => {
|
||||
if (
|
||||
!subStoreBackendSyncCronValue ||
|
||||
isValidCron(subStoreBackendSyncCronValue)
|
||||
) {
|
||||
await patchAppConfig({
|
||||
subStoreBackendSyncCron: subStoreBackendSyncCronValue
|
||||
})
|
||||
new Notification('重启应用生效')
|
||||
} else {
|
||||
alert('Cron 表达式无效')
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
)}
|
||||
<SettingItem title={t('substore.allowLan')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={subStoreHost === '0.0.0.0'}
|
||||
onValueChange={async (v) => {
|
||||
try {
|
||||
if (v) {
|
||||
await patchAppConfig({ subStoreHost: '0.0.0.0' })
|
||||
} else {
|
||||
await patchAppConfig({ subStoreHost: '127.0.0.1' })
|
||||
}
|
||||
await startSubStoreFrontendServer()
|
||||
await startSubStoreBackendServer()
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title={t('substore.useCustomBackend')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={useCustomSubStore}
|
||||
onValueChange={async (v) => {
|
||||
try {
|
||||
await patchAppConfig({ useCustomSubStore: v })
|
||||
if (v) {
|
||||
await stopSubStoreBackendServer()
|
||||
} else {
|
||||
await startSubStoreBackendServer()
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
{useCustomSubStore ? (
|
||||
<SettingItem title={t('substore.customBackendUrl.title')}>
|
||||
<Input
|
||||
size="sm"
|
||||
className="flex-grown"
|
||||
value={subStoreBackendSyncCronValue}
|
||||
placeholder="Cron 表达式"
|
||||
className="w-[60%]"
|
||||
value={customSubStoreUrlValue}
|
||||
placeholder={t('substore.customBackendUrl.placeholder')}
|
||||
onValueChange={(v: string) => {
|
||||
setSubStoreBackendSyncCronValue(v)
|
||||
setCustomSubStoreUrlValue(v)
|
||||
setCustomSubStoreUrl(v)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title="定时恢复配置" divider>
|
||||
<div className="flex w-[60%] gap-2">
|
||||
{subStoreBackendDownloadCronValue !== subStoreBackendDownloadCron && (
|
||||
<Button
|
||||
</SettingItem>
|
||||
) : (
|
||||
<>
|
||||
<SettingItem title={t('substore.useProxy')} divider>
|
||||
<Switch
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={async () => {
|
||||
if (
|
||||
!subStoreBackendDownloadCronValue ||
|
||||
isValidCron(subStoreBackendDownloadCronValue)
|
||||
) {
|
||||
await patchAppConfig({
|
||||
subStoreBackendDownloadCron: subStoreBackendDownloadCronValue
|
||||
})
|
||||
new Notification('重启应用生效')
|
||||
} else {
|
||||
alert('Cron 表达式无效')
|
||||
isSelected={useProxyInSubStore}
|
||||
onValueChange={async (v) => {
|
||||
try {
|
||||
await patchAppConfig({ useProxyInSubStore: v })
|
||||
await startSubStoreBackendServer()
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
size="sm"
|
||||
className="flex-grown"
|
||||
value={subStoreBackendDownloadCronValue}
|
||||
placeholder="Cron 表达式"
|
||||
onValueChange={(v: string) => {
|
||||
setSubStoreBackendDownloadCronValue(v)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title="定时备份配置">
|
||||
<div className="flex w-[60%] gap-2">
|
||||
{subStoreBackendUploadCronValue !== subStoreBackendUploadCron && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={async () => {
|
||||
if (
|
||||
!subStoreBackendUploadCronValue ||
|
||||
isValidCron(subStoreBackendUploadCronValue)
|
||||
) {
|
||||
await patchAppConfig({
|
||||
subStoreBackendUploadCron: subStoreBackendUploadCronValue
|
||||
})
|
||||
new Notification('重启应用生效')
|
||||
} else {
|
||||
alert('Cron 表达式无效')
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
size="sm"
|
||||
className="flex-grown"
|
||||
value={subStoreBackendUploadCronValue}
|
||||
placeholder="Cron 表达式"
|
||||
onValueChange={(v: string) => {
|
||||
setSubStoreBackendUploadCronValue(v)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title={t('substore.sync.title')} divider>
|
||||
<div className="flex w-[60%] gap-2">
|
||||
{subStoreBackendSyncCronValue !== subStoreBackendSyncCron && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={async () => {
|
||||
if (
|
||||
!subStoreBackendSyncCronValue ||
|
||||
isValidCron(subStoreBackendSyncCronValue)
|
||||
) {
|
||||
await patchAppConfig({
|
||||
subStoreBackendSyncCron: subStoreBackendSyncCronValue
|
||||
})
|
||||
new Notification(t('common.notification.restartRequired'))
|
||||
} else {
|
||||
alert(t('common.error.invalidCron'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
size="sm"
|
||||
value={subStoreBackendSyncCronValue}
|
||||
placeholder={t('substore.sync.placeholder')}
|
||||
onValueChange={(v: string) => {
|
||||
setSubStoreBackendSyncCronValue(v)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title={t('substore.restore.title')} divider>
|
||||
<div className="flex w-[60%] gap-2">
|
||||
{subStoreBackendDownloadCronValue !== subStoreBackendDownloadCron && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={async () => {
|
||||
if (
|
||||
!subStoreBackendDownloadCronValue ||
|
||||
isValidCron(subStoreBackendDownloadCronValue)
|
||||
) {
|
||||
await patchAppConfig({
|
||||
subStoreBackendDownloadCron: subStoreBackendDownloadCronValue
|
||||
})
|
||||
new Notification(t('common.notification.restartRequired'))
|
||||
} else {
|
||||
alert(t('common.error.invalidCron'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
size="sm"
|
||||
value={subStoreBackendDownloadCronValue}
|
||||
placeholder={t('substore.restore.placeholder')}
|
||||
onValueChange={(v: string) => {
|
||||
setSubStoreBackendDownloadCronValue(v)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem title={t('substore.backup.title')}>
|
||||
<div className="flex w-[60%] gap-2">
|
||||
{subStoreBackendUploadCronValue !== subStoreBackendUploadCron && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={async () => {
|
||||
if (
|
||||
!subStoreBackendUploadCronValue ||
|
||||
isValidCron(subStoreBackendUploadCronValue)
|
||||
) {
|
||||
await patchAppConfig({
|
||||
subStoreBackendUploadCron: subStoreBackendUploadCronValue
|
||||
})
|
||||
new Notification(t('common.notification.restartRequired'))
|
||||
} else {
|
||||
alert(t('common.error.invalidCron'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
size="sm"
|
||||
value={subStoreBackendUploadCronValue}
|
||||
placeholder={t('substore.backup.placeholder')}
|
||||
onValueChange={(v: string) => {
|
||||
setSubStoreBackendUploadCronValue(v)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingCard>
|
||||
|
||||
@ -1,29 +1,48 @@
|
||||
import React, { useState } from 'react'
|
||||
import SettingCard from '../base/base-setting-card'
|
||||
import SettingItem from '../base/base-setting-item'
|
||||
import { Button, Input } from '@nextui-org/react'
|
||||
import { Button, Input, Select, SelectItem } from '@heroui/react'
|
||||
import { listWebdavBackups, webdavBackup } from '@renderer/utils/ipc'
|
||||
import WebdavRestoreModal from './webdav-restore-modal'
|
||||
import debounce from '@renderer/utils/debounce'
|
||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const WebdavConfig: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { appConfig, patchAppConfig } = useAppConfig()
|
||||
const { webdavUrl, webdavUsername, webdavPassword } = appConfig || {}
|
||||
const {
|
||||
webdavUrl,
|
||||
webdavUsername,
|
||||
webdavPassword,
|
||||
webdavDir = 'mihomo-party',
|
||||
webdavMaxBackups = 0
|
||||
} = appConfig || {}
|
||||
const [backuping, setBackuping] = useState(false)
|
||||
const [restoring, setRestoring] = useState(false)
|
||||
const [filenames, setFilenames] = useState<string[]>([])
|
||||
const [restoreOpen, setRestoreOpen] = useState(false)
|
||||
|
||||
const [webdav, setWebdav] = useState({ webdavUrl, webdavUsername, webdavPassword })
|
||||
const setWebdavDebounce = debounce(({ webdavUrl, webdavUsername, webdavPassword }) => {
|
||||
patchAppConfig({ webdavUrl, webdavUsername, webdavPassword })
|
||||
}, 500)
|
||||
const [webdav, setWebdav] = useState({
|
||||
webdavUrl,
|
||||
webdavUsername,
|
||||
webdavPassword,
|
||||
webdavDir,
|
||||
webdavMaxBackups
|
||||
})
|
||||
const setWebdavDebounce = debounce(
|
||||
({ webdavUrl, webdavUsername, webdavPassword, webdavDir, webdavMaxBackups }) => {
|
||||
patchAppConfig({ webdavUrl, webdavUsername, webdavPassword, webdavDir, webdavMaxBackups })
|
||||
},
|
||||
500
|
||||
)
|
||||
const handleBackup = async (): Promise<void> => {
|
||||
setBackuping(true)
|
||||
try {
|
||||
await webdavBackup()
|
||||
new window.Notification('备份成功', { body: '备份文件已上传至 WebDav' })
|
||||
new window.Notification(t('webdav.notification.backupSuccess.title'), {
|
||||
body: t('webdav.notification.backupSuccess.body')
|
||||
})
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
} finally {
|
||||
@ -38,7 +57,7 @@ const WebdavConfig: React.FC = () => {
|
||||
setFilenames(filenames)
|
||||
setRestoreOpen(true)
|
||||
} catch (e) {
|
||||
alert(`获取备份列表失败: ${e}`)
|
||||
alert(t('common.error.getBackupListFailed', { error: e }))
|
||||
} finally {
|
||||
setRestoring(false)
|
||||
}
|
||||
@ -48,8 +67,8 @@ const WebdavConfig: React.FC = () => {
|
||||
{restoreOpen && (
|
||||
<WebdavRestoreModal filenames={filenames} onClose={() => setRestoreOpen(false)} />
|
||||
)}
|
||||
<SettingCard title="WebDav 备份">
|
||||
<SettingItem title="WebDav 地址" divider>
|
||||
<SettingCard title={t('webdav.title')}>
|
||||
<SettingItem title={t('webdav.url')} divider>
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-[60%]"
|
||||
@ -60,7 +79,18 @@ const WebdavConfig: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="WebDav 用户名" divider>
|
||||
<SettingItem title={t('webdav.dir')} divider>
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-[60%]"
|
||||
value={webdav.webdavDir}
|
||||
onValueChange={(v) => {
|
||||
setWebdav({ ...webdav, webdavDir: v })
|
||||
setWebdavDebounce({ ...webdav, webdavDir: v })
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title={t('webdav.username')} divider>
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-[60%]"
|
||||
@ -71,7 +101,7 @@ const WebdavConfig: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="WebDav 密码" divider>
|
||||
<SettingItem title={t('webdav.password')} divider>
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-[60%]"
|
||||
@ -83,9 +113,31 @@ const WebdavConfig: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title={t('webdav.maxBackups')} divider>
|
||||
<Select
|
||||
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
|
||||
className="w-[150px]"
|
||||
size="sm"
|
||||
selectedKeys={new Set([webdav.webdavMaxBackups.toString()])}
|
||||
aria-label={t('webdav.maxBackups')}
|
||||
onSelectionChange={(v) => {
|
||||
const value = Number.parseInt(Array.from(v)[0] as string, 10)
|
||||
setWebdav({ ...webdav, webdavMaxBackups: value })
|
||||
setWebdavDebounce({ ...webdav, webdavMaxBackups: value })
|
||||
}}
|
||||
>
|
||||
<SelectItem key="0">{t('webdav.noLimit')}</SelectItem>
|
||||
<SelectItem key="1">1</SelectItem>
|
||||
<SelectItem key="3">3</SelectItem>
|
||||
<SelectItem key="5">5</SelectItem>
|
||||
<SelectItem key="10">10</SelectItem>
|
||||
<SelectItem key="15">15</SelectItem>
|
||||
<SelectItem key="20">20</SelectItem>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
<div className="flex justify0between">
|
||||
<Button isLoading={backuping} fullWidth size="sm" className="mr-1" onPress={handleBackup}>
|
||||
备份
|
||||
{t('webdav.backup')}
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={restoring}
|
||||
@ -94,7 +146,7 @@ const WebdavConfig: React.FC = () => {
|
||||
className="ml-1"
|
||||
onPress={handleRestore}
|
||||
>
|
||||
恢复
|
||||
{t('webdav.restore.title')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@nextui-org/react'
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react'
|
||||
import { relaunchApp, webdavDelete, webdavRestore } from '@renderer/utils/ipc'
|
||||
import React, { useState } from 'react'
|
||||
import { MdDeleteForever } from 'react-icons/md'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
filenames: string[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const WebdavRestoreModal: React.FC<Props> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const { filenames: names, onClose } = props
|
||||
const [filenames, setFilenames] = useState<string[]>(names)
|
||||
const [restoring, setRestoring] = useState(false)
|
||||
@ -21,10 +25,10 @@ const WebdavRestoreModal: React.FC<Props> = (props) => {
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex">恢复备份</ModalHeader>
|
||||
<ModalHeader className="flex app-drag">{t('webdav.restore.title')}</ModalHeader>
|
||||
<ModalBody>
|
||||
{filenames.length === 0 ? (
|
||||
<div className="flex justify-center">还没有备份</div>
|
||||
<div className="flex justify-center">{t('webdav.restore.noBackups')}</div>
|
||||
) : (
|
||||
filenames.map((filename) => (
|
||||
<div className="flex" key={filename}>
|
||||
@ -39,7 +43,7 @@ const WebdavRestoreModal: React.FC<Props> = (props) => {
|
||||
await webdavRestore(filename)
|
||||
await relaunchApp()
|
||||
} catch (e) {
|
||||
alert(`恢复失败: ${e}`)
|
||||
alert(t('common.error.restoreFailed', { error: e }))
|
||||
} finally {
|
||||
setRestoring(false)
|
||||
}
|
||||
@ -52,12 +56,12 @@ const WebdavRestoreModal: React.FC<Props> = (props) => {
|
||||
color="warning"
|
||||
variant="flat"
|
||||
className="ml-2"
|
||||
onClick={async () => {
|
||||
onPress={async () => {
|
||||
try {
|
||||
await webdavDelete(filename)
|
||||
setFilenames(filenames.filter((name) => name !== filename))
|
||||
} catch (e) {
|
||||
alert(`删除失败: ${e}`)
|
||||
alert(t('common.error.deleteFailed', { error: e }))
|
||||
}
|
||||
}}
|
||||
>
|
||||
@ -68,8 +72,8 @@ const WebdavRestoreModal: React.FC<Props> = (props) => {
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
关闭
|
||||
<Button size="sm" variant="light" onPress={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@nextui-org/react'
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { BaseEditor } from '../base/base-editor'
|
||||
import { getRuntimeConfigStr } from '@renderer/utils/ipc'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
}
|
||||
const ConfigViewer: React.FC<Props> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const { onClose } = props
|
||||
const [currData, setCurrData] = useState('')
|
||||
|
||||
@ -28,13 +31,13 @@ const ConfigViewer: React.FC<Props> = (props) => {
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent className="h-full w-[calc(100%-100px)]">
|
||||
<ModalHeader className="flex pb-0">当前运行时配置</ModalHeader>
|
||||
<ModalHeader className="flex pb-0 app-drag">{t('sider.cards.config')}</ModalHeader>
|
||||
<ModalBody className="h-full">
|
||||
<BaseEditor language="yaml" value={currData} readOnly={true} />
|
||||
</ModalBody>
|
||||
<ModalFooter className="pt-0">
|
||||
<Button size="sm" variant="light" onPress={onClose}>
|
||||
关闭
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Button, Card, CardBody, CardFooter } from '@nextui-org/react'
|
||||
import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react'
|
||||
import { FaCircleArrowDown, FaCircleArrowUp } from 'react-icons/fa6'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { calcTraffic } from '@renderer/utils/calc'
|
||||
import { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { IoLink } from 'react-icons/io5'
|
||||
@ -10,18 +10,25 @@ import { useTheme } from 'next-themes'
|
||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||
import { platform } from '@renderer/utils/init'
|
||||
import { Area, AreaChart, ResponsiveContainer } from 'recharts'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
let currentUpload: number | undefined = undefined
|
||||
let currentDownload: number | undefined = undefined
|
||||
let hasShowTraffic = false
|
||||
let drawing = false
|
||||
|
||||
const ConnCard: React.FC = () => {
|
||||
interface Props {
|
||||
iconOnly?: boolean
|
||||
}
|
||||
const ConnCard: React.FC<Props> = (props) => {
|
||||
const { theme = 'system', systemTheme = 'dark' } = useTheme()
|
||||
const { iconOnly } = props
|
||||
const { appConfig } = useAppConfig()
|
||||
const { showTraffic, connectionCardStatus = 'col-span-2', customTheme } = appConfig || {}
|
||||
const { showTraffic = false, connectionCardStatus = 'col-span-2', customTheme } = appConfig || {}
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const match = location.pathname.includes('/connections')
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [upload, setUpload] = useState(0)
|
||||
const [download, setDownload] = useState(0)
|
||||
@ -38,18 +45,23 @@ const ConnCard: React.FC = () => {
|
||||
const [series, setSeries] = useState(Array(10).fill(0))
|
||||
const [chartColor, setChartColor] = useState('rgba(255,255,255)')
|
||||
|
||||
useEffect(() => {
|
||||
setChartColor(
|
||||
match
|
||||
? `hsla(${getComputedStyle(document.documentElement).getPropertyValue('--heroui-primary-foreground')})`
|
||||
: `hsla(${getComputedStyle(document.documentElement).getPropertyValue('--heroui-foreground')})`
|
||||
)
|
||||
}, [theme, systemTheme, match])
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const islight = theme === 'system' ? systemTheme === 'light' : theme.includes('light')
|
||||
setChartColor(
|
||||
match
|
||||
? 'rgba(255,255,255)'
|
||||
: islight
|
||||
? window.getComputedStyle(document.documentElement).color
|
||||
: 'rgb(255,255,255)'
|
||||
? `hsla(${getComputedStyle(document.documentElement).getPropertyValue('--heroui-primary-foreground')})`
|
||||
: `hsla(${getComputedStyle(document.documentElement).getPropertyValue('--heroui-foreground')})`
|
||||
)
|
||||
}, 1000)
|
||||
}, [theme, systemTheme, match, customTheme])
|
||||
}, 200)
|
||||
}, [customTheme])
|
||||
|
||||
const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null
|
||||
useEffect(() => {
|
||||
@ -82,6 +94,26 @@ const ConnCard: React.FC = () => {
|
||||
}
|
||||
}, [showTraffic])
|
||||
|
||||
if (iconOnly) {
|
||||
return (
|
||||
<div className={`${connectionCardStatus} flex justify-center`}>
|
||||
<Tooltip content={t('sider.cards.connections')} placement="right">
|
||||
<Button
|
||||
size="sm"
|
||||
isIconOnly
|
||||
color={match ? 'primary' : 'default'}
|
||||
variant={match ? 'solid' : 'light'}
|
||||
onPress={() => {
|
||||
navigate('/connections')
|
||||
}}
|
||||
>
|
||||
<IoLink className="text-[20px]" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@ -90,7 +122,7 @@ const ConnCard: React.FC = () => {
|
||||
transition,
|
||||
zIndex: isDragging ? 'calc(infinity)' : undefined
|
||||
}}
|
||||
className={connectionCardStatus}
|
||||
className={`${connectionCardStatus} conn-card`}
|
||||
>
|
||||
{connectionCardStatus === 'col-span-2' ? (
|
||||
<>
|
||||
@ -111,10 +143,12 @@ const ConnCard: React.FC = () => {
|
||||
>
|
||||
<IoLink
|
||||
color="default"
|
||||
className={`${match ? 'text-white' : 'text-foreground'} text-[24px]`}
|
||||
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px]`}
|
||||
/>
|
||||
</Button>
|
||||
<div className={`p-2 w-full ${match ? 'text-white' : 'text-foreground'} `}>
|
||||
<div
|
||||
className={`p-2 w-full ${match ? 'text-primary-foreground' : 'text-foreground'} `}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<div className="w-full text-right mr-2">{calcTraffic(upload)}/s</div>
|
||||
<FaCircleArrowUp className="h-[24px] leading-[24px]" />
|
||||
@ -127,8 +161,10 @@ const ConnCard: React.FC = () => {
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardFooter className="pt-1">
|
||||
<h3 className={`text-md font-bold ${match ? 'text-white' : 'text-foreground'}`}>
|
||||
连接
|
||||
<h3
|
||||
className={`text-md font-bold ${match ? 'text-primary-foreground' : 'text-foreground'}`}
|
||||
>
|
||||
{t('sider.cards.connections')}
|
||||
</h3>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@ -175,14 +211,16 @@ const ConnCard: React.FC = () => {
|
||||
>
|
||||
<IoLink
|
||||
color="default"
|
||||
className={`${match ? 'text-white' : 'text-foreground'} text-[24px] font-bold`}
|
||||
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px] font-bold`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardFooter className="pt-1">
|
||||
<h3 className={`text-md font-bold ${match ? 'text-white' : 'text-foreground'}`}>
|
||||
连接
|
||||
<h3
|
||||
className={`text-md font-bold ${match ? 'text-primary-foreground' : 'text-foreground'}`}
|
||||
>
|
||||
{t('sider.cards.connections')}
|
||||
</h3>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
@ -1,16 +1,25 @@
|
||||
import { Button, Card, CardBody, CardFooter } from '@nextui-org/react'
|
||||
import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react'
|
||||
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
|
||||
import BorderSwitch from '@renderer/components/base/border-swtich'
|
||||
import { LuServer } from 'react-icons/lu'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { patchMihomoConfig } from '@renderer/utils/ipc'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||
const DNSCard: React.FC = () => {
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
iconOnly?: boolean
|
||||
}
|
||||
const DNSCard: React.FC<Props> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const { appConfig } = useAppConfig()
|
||||
const { iconOnly } = props
|
||||
const { dnsCardStatus = 'col-span-1', controlDns = true } = appConfig || {}
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const match = location.pathname.includes('/dns')
|
||||
const { controledMihomoConfig, patchControledMihomoConfig } = useControledMihomoConfig()
|
||||
const { dns, tun } = controledMihomoConfig || {}
|
||||
@ -31,6 +40,26 @@ const DNSCard: React.FC = () => {
|
||||
await patchMihomoConfig({ dns: { enable } })
|
||||
}
|
||||
|
||||
if (iconOnly) {
|
||||
return (
|
||||
<div className={`${dnsCardStatus} ${!controlDns ? 'hidden' : ''} flex justify-center`}>
|
||||
<Tooltip content={t('sider.cards.dns')} placement="right">
|
||||
<Button
|
||||
size="sm"
|
||||
isIconOnly
|
||||
color={match ? 'primary' : 'default'}
|
||||
variant={match ? 'solid' : 'light'}
|
||||
onPress={() => {
|
||||
navigate('/dns')
|
||||
}}
|
||||
>
|
||||
<LuServer className="text-[20px]" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@ -57,7 +86,7 @@ const DNSCard: React.FC = () => {
|
||||
color="default"
|
||||
>
|
||||
<LuServer
|
||||
className={`${match ? 'text-white' : 'text-foreground'} text-[24px] font-bold`}
|
||||
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px] font-bold`}
|
||||
/>
|
||||
</Button>
|
||||
<BorderSwitch
|
||||
@ -69,7 +98,11 @@ const DNSCard: React.FC = () => {
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardFooter className="pt-1">
|
||||
<h3 className={`text-md font-bold ${match ? 'text-white' : 'text-foreground'}`}>DNS</h3>
|
||||
<h3
|
||||
className={`text-md font-bold ${match ? 'text-primary-foreground' : 'text-foreground'}`}
|
||||
>
|
||||
{t('sider.cards.dns')}
|
||||
</h3>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -1,13 +1,23 @@
|
||||
import { Button, Card, CardBody, CardFooter } from '@nextui-org/react'
|
||||
import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react'
|
||||
import { IoJournalOutline } from 'react-icons/io5'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||
const LogCard: React.FC = () => {
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
iconOnly?: boolean
|
||||
}
|
||||
|
||||
const LogCard: React.FC<Props> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const { appConfig } = useAppConfig()
|
||||
const { iconOnly } = props
|
||||
const { logCardStatus = 'col-span-1' } = appConfig || {}
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const match = location.pathname.includes('/logs')
|
||||
const {
|
||||
attributes,
|
||||
@ -20,6 +30,26 @@ const LogCard: React.FC = () => {
|
||||
id: 'log'
|
||||
})
|
||||
const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null
|
||||
|
||||
if (iconOnly) {
|
||||
return (
|
||||
<div className={`${logCardStatus} flex justify-center`}>
|
||||
<Tooltip content={t('sider.cards.logs')} placement="right">
|
||||
<Button
|
||||
size="sm"
|
||||
isIconOnly
|
||||
color={match ? 'primary' : 'default'}
|
||||
variant={match ? 'solid' : 'light'}
|
||||
onPress={() => {
|
||||
navigate('/logs')
|
||||
}}
|
||||
>
|
||||
<IoJournalOutline className="text-[20px]" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@ -47,13 +77,17 @@ const LogCard: React.FC = () => {
|
||||
>
|
||||
<IoJournalOutline
|
||||
color="default"
|
||||
className={`${match ? 'text-white' : 'text-foreground'} text-[24px] font-bold`}
|
||||
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px] font-bold`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardFooter className="pt-1">
|
||||
<h3 className={`text-md font-bold ${match ? 'text-white' : 'text-foreground'}`}>日志</h3>
|
||||
<h3
|
||||
className={`text-md font-bold ${match ? 'text-primary-foreground' : 'text-foreground'}`}
|
||||
>
|
||||
{t('sider.cards.logs')}
|
||||
</h3>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -1,21 +1,28 @@
|
||||
import { Button, Card, CardBody, CardFooter } from '@nextui-org/react'
|
||||
import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react'
|
||||
import { calcTraffic } from '@renderer/utils/calc'
|
||||
import { mihomoVersion, restartCore } from '@renderer/utils/ipc'
|
||||
import { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { IoMdRefresh } from 'react-icons/io'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import PubSub from 'pubsub-js'
|
||||
import useSWR from 'swr'
|
||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||
import { LuCpu } from 'react-icons/lu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const MihomoCoreCard: React.FC = () => {
|
||||
interface Props {
|
||||
iconOnly?: boolean
|
||||
}
|
||||
|
||||
const MihomoCoreCard: React.FC<Props> = (props) => {
|
||||
const { appConfig } = useAppConfig()
|
||||
const { iconOnly } = props
|
||||
const { mihomoCoreCardStatus = 'col-span-2' } = appConfig || {}
|
||||
const { data: version, mutate } = useSWR('mihomoVersion', mihomoVersion)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const match = location.pathname.includes('/mihomo')
|
||||
const {
|
||||
attributes,
|
||||
@ -29,6 +36,7 @@ const MihomoCoreCard: React.FC = () => {
|
||||
})
|
||||
const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null
|
||||
const [mem, setMem] = useState(0)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
const token = PubSub.subscribe('mihomo-core-changed', () => {
|
||||
@ -43,6 +51,26 @@ const MihomoCoreCard: React.FC = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (iconOnly) {
|
||||
return (
|
||||
<div className={`${mihomoCoreCardStatus} flex justify-center`}>
|
||||
<Tooltip content={t('sider.cards.core')} placement="right">
|
||||
<Button
|
||||
size="sm"
|
||||
isIconOnly
|
||||
color={match ? 'primary' : 'default'}
|
||||
variant={match ? 'solid' : 'light'}
|
||||
onPress={() => {
|
||||
navigate('/mihomo')
|
||||
}}
|
||||
>
|
||||
<LuCpu className="text-[20px]" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@ -51,7 +79,7 @@ const MihomoCoreCard: React.FC = () => {
|
||||
transition,
|
||||
zIndex: isDragging ? 'calc(infinity)' : undefined
|
||||
}}
|
||||
className={mihomoCoreCardStatus}
|
||||
className={`${mihomoCoreCardStatus} mihomo-core-card`}
|
||||
>
|
||||
{mihomoCoreCardStatus === 'col-span-2' ? (
|
||||
<Card
|
||||
@ -69,7 +97,7 @@ const MihomoCoreCard: React.FC = () => {
|
||||
className="flex justify-between h-[32px]"
|
||||
>
|
||||
<h3
|
||||
className={`text-md font-bold leading-[32px] ${match ? 'text-white' : 'text-foreground'} `}
|
||||
className={`text-md font-bold leading-[32px] ${match ? 'text-primary-foreground' : 'text-foreground'} `}
|
||||
>
|
||||
{version?.version ?? '-'}
|
||||
</h3>
|
||||
@ -90,16 +118,16 @@ const MihomoCoreCard: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<IoMdRefresh
|
||||
className={`${match ? 'text-white' : 'text-foreground'} text-[24px]`}
|
||||
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px]`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardFooter className="pt-1">
|
||||
<div
|
||||
className={`flex justify-between w-full text-md font-bold ${match ? 'text-white' : 'text-foreground'}`}
|
||||
className={`flex justify-between w-full text-md font-bold ${match ? 'text-primary-foreground' : 'text-foreground'}`}
|
||||
>
|
||||
<h4>内核设置</h4>
|
||||
<h4>{t('sider.cards.core')}</h4>
|
||||
<h4>{calcTraffic(mem)}</h4>
|
||||
</div>
|
||||
</CardFooter>
|
||||
@ -122,14 +150,16 @@ const MihomoCoreCard: React.FC = () => {
|
||||
>
|
||||
<LuCpu
|
||||
color="default"
|
||||
className={`${match ? 'text-white' : 'text-foreground'} text-[24px] font-bold`}
|
||||
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px] font-bold`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
<CardFooter className="pt-1">
|
||||
<h3 className={`text-md font-bold ${match ? 'text-white' : 'text-foreground'}`}>
|
||||
内核设置
|
||||
<h3
|
||||
className={`text-md font-bold ${match ? 'text-primary-foreground' : 'text-foreground'}`}
|
||||
>
|
||||
{t('sider.cards.core')}
|
||||
</h3>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user