Compare commits

..

353 Commits
main ... v1.9.4

Author SHA1 Message Date
Memory
f58f780275
fix: revert pnpm to 10.27.0 to fix wrappy missing in asar bundle 2026-03-30 08:35:43 +08:00
Memory2314
f959801db2 Bump to 1.9.4 2026-03-29 23:41:53 +08:00
Memory2314
d2e651e79d update changelog.md 2026-03-29 23:41:53 +08:00
Memory2314
8530cdefcc pnpm update 2026-03-29 23:41:53 +08:00
StarHeart
2a4b9f4e5b
feat: support user agent config per sub (#1712)
* feat: add user agent support in profile configuration

* Introduced a new user agent field in profile settings, allowing users to specify a custom user agent.
* Updated relevant components and localization files to support the new user agent feature.
* Added a new pnpm workspace configuration for built dependencies.

* feat: enhance profile settings with user agent input and toggle functionality

* Added a user agent input field to the advanced profile settings.
* Implemented toggle icons for showing/hiding advanced settings.
* Updated layout to accommodate the new user agent input alongside the existing auth token field.

* chore: remove pnpm workspace configuration for built dependencies
2026-03-29 18:54:56 +08:00
Memory
07dd85db84
add types & refactor: simplify rule-item extra handling
* add types

* refactor: simplify rule-item extra handling
2026-03-28 11:19:31 +08:00
Memory
a068c74307
feat: detect PowerShell version synchronously before Electron init using mshta 2026-03-28 00:22:05 +08:00
Memory
3270e47a7b
fix: improve Node 16 compatibility for Win7 legacy build 2026-03-27 22:10:46 +08:00
Memory
7600f82c34
fix: correct log cleanup regex to match prefixed filenames 2026-03-26 12:25:27 +08:00
Memory2314
c57741fcc5 Bump to 1.9.3 2026-03-22 12:50:41 +08:00
Memory2314
ff70058e8c update changelog.md 2026-03-22 12:50:41 +08:00
Memory2314
25a3bbe557 update depends 2026-03-22 12:50:41 +08:00
Memory
5445a11ac8
i18n: sider.cards.ip -> sider.cards.network 2026-03-22 12:45:16 +08:00
Memory
85f9e4755a
feat: add network info page & card with IP display, auto-merge missing sider order entries 2026-03-22 11:35:14 +08:00
Martin
58d9e564e5 fix: use sync Canvas rendering for tray traffic icon to prevent flicker
Replace async SVG→Image→Canvas pipeline with synchronous Canvas rendering
to eliminate tray icon flickering. Use showTrafficRef to read showTraffic
value without recreating the IPC listener, preventing icon hiding caused
by appConfig temporarily becoming undefined during config reloads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:37:08 -07:00
Memory
e2fbb2a8ad
fix: reset proxy list scroll position to top on page entry 2026-03-18 11:31:52 +08:00
Daniel Bates
5d4c0cbfb9
Fix #1555: Ensure working directory is prepared when adding new subscription
When adding a new subscription that becomes the current profile with
'diffWorkDir' enabled, 'generateProfile()' was not being called to
prepare the profile's working directory before the core was restarted.

This caused the working directory to be empty and missing necessary
database files (geoip, geosite, etc.), leading to proxies appearing
to work (showing green) but actually failing to function properly.

The fix ensures that when a new profile is added and becomes current
with 'diffWorkDir' enabled, 'generateProfile()' is explicitly called
before 'changeCurrentProfile()' to prepare the working directory.

Co-authored-by: Your Name <your-email@example.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-18 11:12:19 +08:00
Memory
34fc4d2d96
fix: use Virtuoso followOutput and always update logs state 2026-03-09 22:44:27 +08:00
Memory
b9a7e04eee
fix: destroy floating window instead of close 2026-03-09 22:17:54 +08:00
Memory
1a52bff8af
fix: disable compression for subscription requests to prevent ZIP response 2026-03-09 22:17:25 +08:00
Memory
4fd7dec4db
fix: clean up smart override when switching away from smart core
Refactor Smart kernel override management to always call manageSmartOverride.
2026-03-07 15:05:04 +08:00
Memory
9ddb0f15ff
feat: add download progress and sha256 integrity verification for updates 2026-03-07 14:50:16 +08:00
Memory
f5cff160f2
fix: remove download timeout limit for auto updater 2026-03-06 14:05:43 +08:00
Memory
46f0389770
Update changelog.md
Fix various issues including IME input, configuration switching, and log display.
2026-03-02 11:45:21 +08:00
Memory
25b20250a1
fix: freeze log display when auto-scroll is disabled 2026-03-01 23:37:02 +08:00
Memory
31bec33064
fix: auto-refresh relative time on profiles page 2026-03-01 22:50:47 +08:00
agoudbg
9bbb495dae
fix: badge not clickable 2026-03-01 21:53:08 +08:00
Memory
7dbd33fad6
workflow: update runner to ubuntu-latest 2026-02-25 20:15:44 +08:00
Memory2314
9568586f90 Bump to 1.9.2 2026-02-25 19:54:50 +08:00
Memory2314
6425a09fb8 update changelog.md 2026-02-25 19:54:50 +08:00
Memory2314
8d827d40b7 update depends 2026-02-25 19:54:50 +08:00
Memory
c35d150157
fix: guard against undefined proxy in group.all filter 2026-02-25 19:21:39 +08:00
Memory
33a6e4517e
perf: virtualize rule list in edit-rules modal 2026-02-20 17:33:25 +08:00
Memory
a692dc9ead
fix: use https as default delay test URL 2026-02-18 13:00:52 +08:00
Liang Gong
7c46a0e4fa
chore: vscode extension recommendations 2026-02-10 22:56:17 +08:00
Baoshuo
56880867f3
chore: update link to docs 2026-02-10 22:55:52 +08:00
agoudbg
e767ed0da0
fix: connection chart z-index and scale 2026-02-10 22:46:51 +08:00
agoudbg
d5b5a249f7
fix: draggable sider cards animation in reduced-motion 2026-02-10 22:38:53 +08:00
Memory
01b5ed1a8f
style: update Chip styles 2026-02-04 10:43:33 +08:00
Memory
4bfa02023c
perf: improve core startup performance 2026-02-04 10:20:59 +08:00
Memory
dae4939390
feat: add Fish and Nushell support for environment variables 2026-02-03 21:16:42 +08:00
Memory
d2f700a0ef
feat: add rule statistics and disable toggle 2026-02-03 20:25:57 +08:00
Memory
1c17cfb683
refactor: external controller & rule editor
* update external controller labels for clarity

* add generate and toggle visibility for external controller secret

* add validation for external controller and rule payloads

* enhance rule payload validation UI with prominent error styling
2026-02-02 12:46:41 +08:00
Memory
8e6bfb0bd1
feat: Improve backup config import handling 2026-01-31 19:17:21 +08:00
Memory
842e7f1002
chore: ensure ESLint passes and format code 2026-01-25 15:28:03 +08:00
Memory
e9c72ce448
feat: show app icons in connections page 2026-01-25 14:59:48 +08:00
julong
d3a23a0601
feat: subscription timeout settings & subscription update logic efficiency (#1562)
1. Add updateTimeout property to IProfileItem interface
2. Add dedicated timeout config option for subscriptions
3. Improve subscription update logic efficiency
4. Use subscriptionTimeout as the smart fallback time
5. Localize
profiles.editInfo.updateTimeout and profiles.editInfo.updateTimeoutPlaceholder for en-US/fa-IR/ru-RU/zh-CN/zh-TW
2026-01-23 20:42:45 +08:00
Memory
767cdfeef3
fix: unmaximize save 2026-01-22 13:57:32 +08:00
xmk23333
197c9d3af8 feat: add Windows 7 compatibility build using win7 target 2026-01-19 13:14:54 +08:00
xmk23333
388581d75e - fix: fix IME composition input causing character duplication
Handle onCompositionStart/End events to prevent repeated characters
  when typing Chinese in proxy group filter
2026-01-19 13:04:34 +08:00
xmk23333
3c148f2c01 fix: prevent profile switch queue from breaking after failed switch 2026-01-19 12:55:21 +08:00
xmk23333
56e328191f fix: resolve merge conflict and stabilize core/control flows 2026-01-19 00:37:15 +08:00
xmk23333
0cc1d238c9 Merge branch 'smart_core' of https://github.com/mihomo-party-org/clash-party into smart_core 2026-01-19 00:34:36 +08:00
xmk23333
3f7b85afc1 fix: resolve restartCore race condition and add retry mechanism 2026-01-18 23:07:04 +08:00
Memory
72dd214ef0
feat: add startup configuration file check 2026-01-17 22:15:48 +08:00
xmk23333
c7190a311e chore: bump version to 1.9.1 2026-01-16 00:46:37 +08:00
xmk23333
87ccffef7e fix: aur-release-updater wait for updater job to ensure release assets are ready 2026-01-16 00:43:29 +08:00
xmk23333
dd16eabb2a fix: use correct repository url in aur workflow 2026-01-16 00:22:58 +08:00
xmk23333
4a868e53ae fix: silently disable tun on startup instead of showing dialog 2026-01-15 23:55:15 +08:00
xmk23333
9e0f27aea3 fix: prevent crashes from process.exit, empty array destructuring and native module calls 2026-01-15 23:44:28 +08:00
xmk23333
a28f576d78 fix: properly handle critical file copy failures in initFiles 2026-01-15 23:29:27 +08:00
xmk23333
2cfcf8be66 fix: resolve tun crash caused by single instance lock conflict on admin restart 2026-01-15 23:25:48 +08:00
xmk23333
e21558ac37 fix: use sync state to prevent tray icon flickering on macOS with traffic display 2026-01-15 23:14:17 +08:00
xmk23333
228e2cbffc fix: use env var for win7 legacy build instead of regex replacement 2026-01-15 23:11:37 +08:00
xmk23333
ab58248d7b fix: use explicit arch parameter for sysproxy node selection 2026-01-15 22:56:37 +08:00
xmk23333
295c4400e9 fix: resolve auto-run and TUN restart issues on Windows IoT LTSC 2026-01-15 22:49:27 +08:00
xmk23333
bc4b59c66b fix: prevent tray traffic icon flickering on macOS 2026-01-15 22:22:22 +08:00
xmk23333
490f559306 fix: correct musl detection and clean other platform node files 2026-01-15 22:10:51 +08:00
xmk23333
6bb2304f52 fix: add commonjs type to sysproxy package for win7 compatibility 2026-01-15 21:30:34 +08:00
xmk23333
ea190b9bc1 fix: downgrade express and chokidar for win7 electron 22 compatibility 2026-01-15 21:23:04 +08:00
xmk23333
5f179d3ea5 fix: remove sysproxy npm packages, use github release via prepare script 2026-01-15 21:09:00 +08:00
xmk23333
e6cba388b2 fix: await setProxy completion before making proxy requests 2026-01-15 21:05:52 +08:00
xmk23333
291abc0a0d fix: prevent tray icon flickering when showTraffic enabled on macOS 2026-01-15 21:02:34 +08:00
xmk23333
ae42750f34 fix: prevent tray icon flickering on macOS and Linux 2026-01-15 20:56:27 +08:00
xmk23333
7b104df463 fix: configure CJS output for Electron 22 in windows7 build 2026-01-15 19:31:34 +08:00
xmk23333
7aea4af2d0 docs: update changelog for v1.9.0 2026-01-15 19:26:22 +08:00
xmk23333
1d053fe636 chore: add type module and fix preload script output format 2026-01-15 19:22:53 +08:00
xmk23333
b071154263 fix: improve sidecar path lookup for dev environment 2026-01-15 19:00:17 +08:00
xmk23333
026d9d30f9 chore: remove redundant directories 2026-01-15 18:54:10 +08:00
xmk23333
3d68e57158 fix: use helper service for dns settings to avoid permission error on macos 2026-01-15 18:44:02 +08:00
xmk23333
8af815ee60 fix: add delay in coreWatcher to avoid race condition with core self-restart 2026-01-15 18:41:59 +08:00
xmk23333
fdb57431ba fix: add delay before core restart to avoid pipe connection race condition 2026-01-15 18:30:27 +08:00
xmk23333
5eee22292e fix: sanitize NaN port values when reading config 2026-01-15 18:24:46 +08:00
xmk23333
36027cecea fix: handle port value zero correctly and prevent NaN from being written to config 2026-01-15 18:23:46 +08:00
xmk23333
727eceb0cf fix: precise proxy group name replacement in smart override rules 2026-01-15 18:13:43 +08:00
xmk23333
3d9507b10c fix: handle file copy errors gracefully in init 2026-01-15 18:11:15 +08:00
xmk23333
9d5d2bb73d fix: add defensive checks for undefined arrays to prevent find/filter errors 2026-01-15 18:06:37 +08:00
xmk23333
ccaabb7b1a fix: sync updated geo files to profile work dir in lite mode 2026-01-15 18:05:38 +08:00
xmk23333
45fd8e6870 fix: linux desktop icon and app launcher visibility on gnome 2026-01-15 18:03:26 +08:00
xmk23333
5947394338 fix: avoid ENOENT/EPERM errors when copying init files 2026-01-15 18:00:04 +08:00
xmk23333
5f5ca0fd27 fix: ensure items array exists in profile and override config 2026-01-15 17:58:27 +08:00
xmk23333
bab949e16a fix: ensure admin restart waits for new process before quitting 2026-01-15 17:53:21 +08:00
xmk23333
a0bac512dd fix: ensure items array exists in profile and override config 2026-01-15 17:53:13 +08:00
xmk23333
f9176f3fa0 chore: integrate sysproxy-rs v0.1.0 napi module 2026-01-15 17:44:03 +08:00
xmk23333
2bbf896584 update changelog.md 2026-01-15 14:43:44 +08:00
xmk23333
30f87b8439 Merge branches 'smart_core' and 'smart_core' of https://github.com/mihomo-party-org/clash-party into smart_core 2026-01-15 13:53:33 +08:00
xmk23333
075132397c refactor: simplify ipc with proxy pattern and split startCore into smaller functions 2026-01-15 13:53:07 +08:00
Memory
19ff003352
fix: correct sidecar directory lookup path 2026-01-10 14:56:10 +08:00
Memory
52cefa39d3
feat: add option to hide unavailable proxies in proxy list 2026-01-10 14:55:10 +08:00
Memory
e6548a0bc3
fix: update sysproxy-rs path in package.json and pnpm-lock.yaml 2026-01-09 19:56:36 +08:00
xmk23333
a5d2114363 fix: resolve all eslint errors and warnings 2026-01-08 12:56:08 +08:00
xmk23333
e70ca694b9 fix: resolve eslint errors and enhance code review rules 2026-01-08 12:29:10 +08:00
xmk23333
6a9edd8665 refactor: split manager.ts into permissions, process, dns modules 2026-01-07 14:58:46 +08:00
xmk23333
b42287d104 chore: optimize sysproxy-rs with error handling and smaller binary 2026-01-07 14:02:21 +08:00
xmk23333
9963f67433 refactor: extract createBackupZip to reduce duplication in backup.ts 2026-01-06 02:39:35 +08:00
xmk23333
0b65eb490f refactor: optimize init.ts with parallel execution and split migration 2026-01-06 02:37:32 +08:00
xmk23333
bff3aedf86 fix: kill old mihomo processes before copying files to avoid EBUSY 2026-01-06 02:34:42 +08:00
xmk23333
2c638f56c0 refactor: extract common config context logic into factory function 2026-01-06 02:27:58 +08:00
xmk23333
38389e0c3c Merge branch 'smart_core' of https://github.com/mihomo-party-org/clash-party into smart_core 2026-01-06 02:22:38 +08:00
xmk23333
5d10c45206 refactor: replace @mihomo-party/sysproxy with local sysproxy-rs 2026-01-06 02:22:33 +08:00
Memory
0e0b337a8b
fix: correct sidebar card horizontal offset issue 2026-01-05 20:59:36 +08:00
xmk23333
7b7333d271 fix: prevent timer leaks and race conditions in config/profile/ssid modules 2026-01-04 16:45:42 +08:00
xmk23333
923bd8d7ee fix: lock electron and electron-vite versions to prevent breaking upgrades 2026-01-01 11:51:58 +08:00
xmk23333
2c639d5bff fix: resolve race conditions in config writes and profile switching 2026-01-01 11:51:44 +08:00
xmk23333
8384953fb7 fix: read silentStart config synchronously to avoid race condition 2026-01-01 10:57:45 +08:00
xmk23333
85f430f188 fix: request admin privileges for DNS settings on macOS 2026-01-01 10:46:13 +08:00
xmk23333
7634177c5c fix: handle empty port input by treating it as 0 2026-01-01 10:37:29 +08:00
xmk23333
3097019e9e fix: resolve async/null-safety issues in lifecycle, config and ipc modules 2026-01-01 10:33:01 +08:00
xmk23333
9e5d11c3c8 refactor: replace console.log with createLogger in main process 2026-01-01 10:16:49 +08:00
xmk23333
676743d1b0 refactor: replace console.log with logger in github.ts and fix tray comments 2026-01-01 10:07:23 +08:00
xmk23333
c1f7a862aa refactor: split main process into modules and extract tour logic 2026-01-01 09:35:45 +08:00
Memory
3f1d1f84a1
update depends 2025-12-31 22:22:07 +08:00
Memory
abcbb6388b
fix: correct npmrc only-built-dependencies array syntax 2025-12-31 21:19:36 +08:00
Memory
5eced51979
Bump to 1.9.0 2025-12-31 20:33:57 +08:00
Memory
34d2b31579
fix: add force option to file copy to prevent first launch failures 2025-12-31 20:26:04 +08:00
xmk23333
0198630e57 fix: improve macos helper recovery when socket missing after reboot 2025-12-28 20:37:06 +08:00
xmk23333
3d6d545a93 fix: use atomic update in changeCurrentProfile 2025-12-28 20:18:11 +08:00
xmk23333
fbde5c3f09 fix: ensure consistent current profile id when diffWorkDir enabled 2025-12-28 20:06:26 +08:00
xmk23333
75f2522a99 fix: repair yaml syntax in build workflow 2025-12-28 19:51:18 +08:00
xmk23333
a4ab3cb448 ci: upgrade github actions to latest versions 2025-12-28 19:36:17 +08:00
xmk23333
818b546817 fix: add config write queue and prevent ipc listener accumulation 2025-12-28 19:36:01 +08:00
xmk23333
b60c01bb4c fix: resolve event listener memory leaks and add error logging 2025-12-28 19:02:07 +08:00
xmk23333
ae91194a74 Merge branch 'smart_core' of https://github.com/mihomo-party-org/clash-party into smart_core 2025-12-28 18:02:40 +08:00
xmk23333
38d9e8b81b chore: upgrade all dependencies 2025-12-28 18:02:28 +08:00
Memory
dacb77f414
fix: exclude .build-id files from RPM package to prevent conflicts 2025-12-28 16:35:46 +08:00
xmk23333
a159974142 refactor: improve preload security with IPC channel whitelist and fix dependencies 2025-12-28 12:45:07 +08:00
xmk23333
2467306903 feat: remove hardcoded Chinese strings and improve i18n coverage 2025-12-28 12:34:13 +08:00
xmk23333
573be5501e refactor: simplify main process IPC handler registration 2025-12-28 12:22:28 +08:00
xmk23333
98c8280d48 refactor: simplify IPC layer with generic invoke wrapper 2025-12-27 23:41:04 +08:00
xishang0128
54bb819e28
fix format and lint 2025-12-25 15:32:32 +08:00
Memory
b7d6ea8e7a
feat: add tray proxy group style option with submenu mode 2025-12-20 00:33:51 +08:00
xmk23333
51d169d2e8 feat: add HTML detection and error handling for profile parsing 2025-12-16 15:36:48 +08:00
xmk23333
55416f32cd feat: Automatically Choose Direct or Proxy during Subscription Import #issue 1450 2025-12-16 13:22:26 +08:00
xmk23333
041a81cfd4 fix: add file existence checks before backup and fix CSS typo 2025-12-14 22:15:38 +08:00
xmk23333
d811f76bb4 Merge branch 'smart_core' of https://github.com/mihomo-party-org/clash-party into smart_core 2025-12-14 21:46:49 +08:00
xmk23333
393a32bcfe feat: add WebDAV certificate ignore option and improve event listener cleanup 2025-12-14 21:46:43 +08:00
Memory
6542be8490
chore: ensure ESLint passes and format code & update changelog.md
* chore: ensure ESLint passes and format code

* chore: update changelog.md
2025-12-13 15:22:32 +08:00
Tongyuxiu Zhou
8f5486064b
refactor: remove no-longer-used ipc 2025-12-11 20:13:16 +08:00
Tongyuxiu Zhou
ba10dfd3df
fix: missing placeholder and error handling in override page 2025-12-11 07:15:30 +08:00
Memory
7a79adef2e
Add Traditional Chinese (Taiwan) translation
* Add Traditional Chinese (Taiwan) translation

* Add common.loading translation
2025-12-09 23:48:28 +08:00
Memory
94f52cf636
feat: add mrs ruleset preview suppor 2025-12-09 23:33:38 +08:00
xmk23333
19ae63b253 Merge branch 'smart_core' of https://github.com/mihomo-party-org/clash-party into smart_core 2025-12-09 23:32:25 +08:00
xmk23333
34fdd21878 feat: add detailed error toast with copy functionality and refactor error handling 2025-12-09 23:32:13 +08:00
澄妄
ddd0077a61
Merge pull request #1435 from xmk23/smart_core
fix: optimize connections page performance and state management
2025-12-09 17:29:26 +08:00
xmk23333
972d2fe946 fix: add missing await keywords and refactor duplicate code 2025-12-08 22:32:14 +08:00
xmk23333
dcbd837949 refactor: replace alert() with toast notification system 2025-12-08 20:52:09 +08:00
xmk23333
8f2e956fd0 perf: optimize connection and log components with memoization and state management 2025-12-08 19:58:57 +08:00
xmk23333
67aa17f6bb fix: add delay to WebSocket reconnection and improve event listener cleanup 2025-12-08 19:43:20 +08:00
xmk23333
51720296cc fix: optimize connections page performance and state management 2025-12-08 16:53:00 +08:00
Memory
7619b4d3e5
fix: handle non-array response when fetching Mihomo tags 2025-12-08 13:06:07 +08:00
Memory
392285058e
fix: ensure all default config fields are present in config.yaml 2025-12-07 13:53:06 +08:00
Tongyuxiu Zhou
508d7d63c9
fix: handle subscription with failed status code or invalid profile 2025-12-07 13:39:14 +08:00
Memory
9863c1a1de
fix: prevent null access error when finding profile items 2025-12-07 13:22:24 +08:00
Memory
b10075737f
i18n: add translations for sysproxy.pacEditor.title 2025-12-04 13:26:40 +08:00
Memory
f541b5ead1
feat: add authentication token support 2025-12-01 07:56:07 +08:00
e.
47fd7add5f
perf(manager): skip PowerShell profile loading (#1401)
* perf(manager): skip PowerShell profile loading

* perf(manager): skip PowerShell profile loading *2
2025-12-01 07:49:07 +08:00
Memory
b76757bc19
fix: conn detail & log can't select 2025-11-25 23:46:51 +08:00
Memory
0753e5d138
chore: prepare full geoip.dat 2025-11-25 22:30:04 +08:00
Memory
583ece0a64
feat: support disabling autoupdate 2025-11-25 22:21:14 +08:00
Memory
4b8ae4063d
feat: add swap tray click option in settings 2025-11-24 19:26:17 +08:00
Memory
3670f23a1c
fix: Improve app instance lock handling 2025-11-18 23:55:07 +08:00
Memory
485a936d83
fix: correct mixed-port configuration issues 2025-11-18 22:10:47 +08:00
Memory
fb33f37652
feat: Data Colletct File Size
* add @mihomo-party/sysproxy

* feat: Data Colletct File Size

* update changelog
2025-11-17 22:54:17 +08:00
Memory2314
46088f5234
update changelog 2025-11-16 14:30:38 +08:00
Memory2314
70dcf5e598
update depends 2025-11-16 13:13:14 +08:00
ezequielnick
f953dca228 perf: rule edit page 2025-11-15 21:01:59 +08:00
Memory
e1b8c9960a
feat: show current proxy in tray 2025-11-14 21:58:24 +08:00
Memory
9a0eb26ef2
feat: add port enable/disable & random port
* Support random port

* feat: add port enable/disable
2025-11-13 20:22:18 +08:00
Memory
8ebe99a8ca
fix: handle missing 'providers' field 2025-11-10 09:07:47 +08:00
Memory
4af5cae356
Refactor rule (#1326)
* refactor: enhance prepend/append deletion in rule editor

* fix: Reject empty payload for non-MATCH rules

* More validator

* feat(backup): add rules directory support to local and WebDAV backups
2025-11-01 18:19:07 +08:00
ezequielnick
0e58f6f314 fix: remove unused index parameter 2025-10-29 19:48:31 +08:00
ezequielnick
80b59fc9de refactor: migrate external HTTP requests from Node.js to Chromium network stack
(cherry picked from commit ad96c558a3d46c79848e2c9e469673c7e7f68e25)
2025-10-29 19:37:45 +08:00
Memory
98be9d3065
Revert "opt: auto-scroll behavior to reactivate only on user scroll"
This reverts commit 848f6277cb56a0829e7f1da61f2f3ab21ae1064c.
2025-10-28 13:35:21 +08:00
Moon
0b2f64f42d
Revert "fix: prevent event propagation on button press in proxies page"
This reverts commit b272634c11e6ee93493b01e9b9a41941d5d433a1.
2025-10-22 18:27:45 +08:00
Memory
9f46ccf99a
feat(tray): reverse click behavior on macOS 2025-10-22 12:49:03 +08:00
Memory
96552778e6
chore: update depends & add missing translation
* update depends

* add missing translation
2025-10-22 12:38:09 +08:00
Memory
55860af9b3
feat: backup & restore 2025-10-22 12:19:16 +08:00
Moon
f67d4150f1
fix: smartcore initial state in template.ts 2025-10-22 11:51:11 +08:00
Moon
605351a498
style: remove divider of the last setting item 2025-10-22 11:50:35 +08:00
Justin
b272634c11
fix: prevent event propagation on button press in proxies page 2025-10-21 14:30:44 +08:00
Moon
b02d794092
fix: change deb/rpm install path from "mihomo-party" to "clash-party" (#1303)
* fix: incorrect linux install path

* fix: synchronously modify the Linux postinst/uninst script
2025-10-21 11:41:02 +08:00
moon
154e2787d5 fix: core-initial-state 2025-10-20 07:31:28 +08:00
Moon
236ad0ab43
fix: postuninst & postinst script in Linux (#1295)
* refactor: Postinstall script in linux

* fix: add postuninst script in linux
2025-10-20 07:29:35 +08:00
Memory
877a84dc32
feat: rule example & rule validation 2025-10-19 22:38:59 +08:00
Moon
f61072c309
fix: remove envs introduced in v1.7.0 to make Linux TUN work properly (#1283) 2025-10-18 20:46:43 +08:00
ezequielnick
67d378f3f3 chore: bump to v1.8.9 2025-10-17 21:46:27 +08:00
ezequielnick
51b8c879ea feat: add table view to connection page 2025-10-17 21:45:43 +08:00
ezequielnick
a5a583fdc5 feat: enhance core privilege escalation safety checks 2025-10-16 10:04:53 +08:00
Memory
2eb10df116
feat: support pausing on the connections page 2025-10-15 13:08:09 +08:00
Memory
2bf54446df
feat: add visual rule editor (#1251)
* feat: add visual rule editor

* Support MATCH

* Support Additional Parameters

* Use Trash Icon

* Support sorting

* Support adding prepend/append rules

* IoIosArrow -> IoMdArrow

* More checks

* Support delete with undo
2025-10-11 07:02:14 +08:00
Leon Wang
6429e93adf
fix: inconsistent applied profile when rapidly switching profiles (#1268) 2025-10-11 07:01:51 +08:00
Memory
d8fdbebc4b
fix: shortcut window display issue 2025-10-08 14:41:35 +08:00
Memory
96ef2d2bbc
i18n: add translations for mihomo.tproxyPort 2025-10-08 14:38:59 +08:00
ezequielnick
4f5af4ee30 1.8.8 Released 2025-10-06 12:53:33 +08:00
Memory
1ff165d61b
migrate: move WebUI from global settings to mihomo page 2025-10-06 12:40:01 +08:00
ezequielnick
b1d02c9f82 style: add indicator icons to expandable items in settings page 2025-10-05 11:43:24 +08:00
ezequielnick
3aff005f81 fix: resolve all type errors & update changelog 2025-10-04 13:07:38 +08:00
ezequielnick
7bdebcf298 feat: reimplement scroll position memory for proxy page 2025-10-04 12:47:27 +08:00
ezequielnick
a7e769f402 refactor: improve proxy page performance 2025-10-04 12:44:09 +08:00
Memory
a7de9b2588
refactor: Redesign and migrate WebUI to global settings 2025-10-03 22:49:41 +08:00
ezequielnick
f34cc976b4 style: update macos dock icon 2025-10-03 22:43:05 +08:00
ezequielnick
814112f541 style: update logo & changelog 2025-10-03 21:01:41 +08:00
Jiawen Geng
717239f56b
feat: add placeholder for subscription URL in profile management across multiple languages (#1244) 2025-10-03 18:48:32 +08:00
Memory
9681f77e20
feat: connection card pure number display 2025-09-30 00:05:32 +08:00
Memory
f488cc3643
perf: reload config without kernel restart 2025-09-29 23:42:40 +08:00
Memory
6832db788a
feat: switch triggerMainWindow behavior 2025-09-29 23:16:06 +08:00
Memory
99a5505d16
feat: launch WebUI directly from subscription manager 2025-09-27 10:00:21 +08:00
Memory
76a849e376
i18n: refine Russian (ru-RU) translations 2025-09-27 09:37:35 +08:00
Memory
74b65430be
opt: card size 2025-09-27 09:37:22 +08:00
Memory
848f6277cb
opt: auto-scroll behavior to reactivate only on user scroll 2025-09-23 12:18:25 +08:00
Memory
bd49f1884a
fix: Electron uninstall 2025-09-20 22:21:51 +08:00
苹果派派
eb69bd51a6
feat: add webdav backup cron scheduler and resore filename sort (#1192) 2025-09-19 19:27:17 +08:00
Memory
b30f49c9f4
fix: no mihomo.yaml 2025-09-17 11:58:12 +08:00
Memory
29a8904e03
update depends 2025-09-12 12:59:23 +08:00
Memory
1674bd97ba
opt: faster restartAsAdmin with reduced wait time 2025-09-12 12:32:47 +08:00
Memory
ae8582bf94
feat: remember logs page filter keywords 2025-09-11 11:25:14 +08:00
ezequielnick
b21381062f fix: ENOENT: no such file or directory(config.yaml) 2025-09-10 19:21:47 +08:00
Memory
ecd92417e4
feat: add kernel version selection
* feat: add kernel version selection

* full

* update translations
2025-09-10 19:19:06 +08:00
Memory
75762c1263
chore: autoUpdater mihomo-party -> clash-party 2025-09-10 11:25:35 +08:00
ezequielnick
e03d519371 1.8.7 Released 2025-09-10 10:17:58 +08:00
ezequielnick
4137f91ccb fix: handle core state errors caused by rapid profile switching 2025-09-09 17:00:04 +08:00
ezequielnick
e075bd5d8c style: update macos showTraffic tray logo 2025-09-09 10:52:47 +08:00
Memory
a24432f8ce
fix: gist url 404 error 2025-09-09 10:15:01 +08:00
ezequielnick
252ceb8053 sytle: update macos dock logo 2025-09-09 10:12:26 +08:00
ezequielnick
199ecd26dd fix: ENOENT: no such file or directory on MacOS 2025-09-08 17:27:43 +08:00
ezequielnick
23854a9666 fix: install fail on MacOS 2025-09-08 11:23:41 +08:00
Memory
b05fb02e67
style: add mihomo restart title andmissing translations
* style: add mihomo restart title

* feat: Add missing translations
2025-09-07 18:59:29 +08:00
Memory
e663d07b48
fix winget 2025-09-07 17:49:27 +08:00
ezequielnick
3fbd606b82 fix: version update 404 2025-09-07 12:39:53 +08:00
ezequielnick
305210cb96 Merge branch 'socket-man' into smart_core
* socket-man:
  chore: update mac logo
  fix: unexpected lightmode core exit
  Revert "fix: selection loss in light mode"
  refactor: simplify UID handling logic on unix
  fix: missing admin check logs
  style: update macos logo
  style: update app logo
  feat: add socket management
2025-09-07 08:23:26 +08:00
Memory
c8d83f45ac
fix: short-id parsing error 2025-09-06 19:27:00 +08:00
ezequielnick
062566f966 chore: update mac logo 2025-09-06 18:24:21 +08:00
ezequielnick
66a41306d6 fix: unexpected lightmode core exit 2025-09-05 22:37:46 +08:00
ezequielnick
75db218888 Revert "fix: selection loss in light mode"
This reverts commit e176f6db14941c686fdb40f359b1dd039e8b28c2.
2025-09-05 21:54:28 +08:00
Memory
bcecca7ab7
feat: add fetch timeout configuration 2025-09-05 11:57:51 +08:00
Memory
6d337818d0
fix: desktop shortcut fails to launch application on ArchLinux 2025-09-05 11:57:02 +08:00
ezequielnick
dfbe11deb4 refactor: simplify UID handling logic on unix 2025-09-05 10:47:14 +08:00
ezequielnick
895b74ca3f fix: missing admin check logs 2025-09-04 10:43:46 +08:00
Memory
b2a1080fcd
fix AUR 2025-09-04 10:43:10 +08:00
ezequielnick
43eef7fa92 style: update macos logo 2025-09-03 22:07:49 +08:00
ezequielnick
e7eff4ca93 style: update app logo 2025-09-03 21:09:54 +08:00
ezequielnick
b53961201f feat: add socket management 2025-09-03 20:39:56 +08:00
ezequielnick
9c592c9282 chore: bump version to 1.8.7 2025-09-03 11:41:15 +08:00
ezequielnick
6b1815c666 style: update logo 2025-09-03 11:30:14 +08:00
Memory
29584d3ba9
Revert "fix: 修复DNS配置和性能优化,解决运行时配置显示问题 (#1094)" (#1097)
This reverts commit 68bbde0d163ca08fe8b709c60b02b726598fb221.
2025-09-03 09:11:33 +08:00
zengql
68bbde0d16
fix: 修复DNS配置和性能优化,解决运行时配置显示问题 (#1094)
- 修复DNS配置相关问题
- 性能优化改进
- 添加mihomoConfigs() API用于获取实时配置
- 修复config-viewer显示log-level总是info的问题
- 优化核心启动流程,使用API轮询替代日志解析
- 重构配置管理,提升启动稳定性

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-02 23:20:51 +08:00
Memory
d51d6ed0d7
feat: disableAnimations 2025-09-01 10:59:00 +08:00
zengql
b61d0e68ed
feat: 添加DNS fallback配置并修复页面加载慢问题 (#1062)
- 新增DNS回退服务器配置界面和多语言支持
- 修复DNS配置合并逻辑,确保fallback始终为空数组
- 解决订阅fallback配置导致的双重DNS查询性能问题

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-01 09:42:38 +08:00
ezequielnick
c73d147938 1.8.6 Released 2025-08-31 17:32:20 +08:00
ezequielnick
489dc8ac67 chore: update&fix workflow dev version 2025-08-31 17:19:30 +08:00
Memory
195306f251
fix: deleted subscriptions reappearing
* chore: udpate download link

* fix: deleted subscriptions reappearing

---------

Co-authored-by: ezequielnick <107352853+ezequielnick@users.noreply.github.com>
2025-08-31 12:54:43 +08:00
ezequielnick
4946d73183 chore: update workflow to delete old assets 2025-08-31 12:33:41 +08:00
ezequielnick
e1c4a94e02 refactor: commit hash in dev version 2025-08-31 12:08:19 +08:00
ezequielnick
80c611aec8 Revert "feat: add commit hash in dev version"
This reverts commit c01c985c91f8446065e9eb89f600b133db60151b.
2025-08-31 10:34:09 +08:00
ezequielnick
d86106ac00 chore: update changelog 2025-08-30 22:45:00 +08:00
ezequielnick
77058594ec chore: update logo 2025-08-30 22:18:04 +08:00
Memory
fb3860edaa
chore(WinGet): Mihomo-Party.Mihomo-Party -> Mihomo-Party.Clash-Party 2025-08-30 22:08:24 +08:00
Memory
06ff6351a2
chore(WinGet): mihomo-party -> clash-party 2025-08-30 22:02:34 +08:00
Memory
bf79ec2c62
chore(artifactName): mihomo-party -> clash-party 2025-08-30 21:54:33 +08:00
Memory
8672693c33
style: display config parameters after DNS style title 2025-08-30 14:22:25 +08:00
Memory
17c1c8c56d
feat: add tray icon color disable setting 2025-08-30 09:19:35 +08:00
Memory
0c38d3eb26
mihomo-party -> clash-party 2025-08-30 08:52:35 +08:00
Memory
7743097b51
fix: UWPTool no privileges 2025-08-29 20:06:43 +08:00
Memory
6d21f904b3
fix: tray icon refresh on shortcut usage 2025-08-29 13:16:19 +08:00
Memory
1da82c1b3b
feat: add IPv6 loopback addresses to skip-auth-prefixes 2025-08-29 13:15:23 +08:00
ezequielnick
84c89105b4 chore(workflow): update dev 2025-08-29 12:42:06 +08:00
ezequielnick
f7716ae448 feat: add option to disable HardwareAcceleration 2025-08-29 11:09:46 +08:00
ezequielnick
e35fa316fc fix: autoupdater admin privileges issue 2025-08-27 19:53:21 +08:00
ezequielnick
4bea1b93cc 1.8.5 Released 2025-08-27 14:15:34 +08:00
ezequielnick
7089b3ca5b chore: add smart core settings tootips 2025-08-27 14:11:29 +08:00
Memory
add4196dc5
fix: clean up outdated sub-store.bundle.js 2025-08-27 12:59:39 +08:00
Memory
78ec7f9822
opt: tray icon updating 2025-08-27 12:59:28 +08:00
ezequielnick
4dbf054334 feat: more flexible smart override script 2025-08-27 10:50:10 +08:00
Memory
b2aab804a2
fix: tray icon show on macos 2025-08-25 22:50:42 +08:00
Memory
e75fb996c2
fix: tray icon not updating on mode change 2025-08-25 11:39:27 +08:00
Memory
941831728c
fix: rename sub-store.bundle.js to .cjs for ES module compatibility
* fix: rename sub-store.bundle.js to .cjs for ES module compatibility

* Update changelog.md
2025-08-23 20:32:35 +08:00
Memory
3c57f7d99b
style: hide outline again 2025-08-23 12:59:02 +08:00
Memory
a57ea34f1b
feat: real-time tray icon color updates based on proxy status (#984)
* feat: real-time tray icon color updates based on proxy status

* Update changelog.md
2025-08-22 12:46:57 +08:00
Memory
e357700d60
fix: correct version comparison logic 2025-08-21 19:02:01 +08:00
Memory
109bbef3cf
style: hide outline 2025-08-21 19:01:49 +08:00
Memory
c01c985c91
feat: add commit hash in dev version 2025-08-21 19:01:33 +08:00
ezequielnick
2ebb5db055 chore: update log 2025-08-20 20:06:37 +08:00
ezequielnick
4325c77b4c fix: default tun device name doesn't work 2025-08-20 20:06:24 +08:00
zengql
d6b88d407c
feat: 修复权限检查并优化TUN与自启联动 (#977)
1. 修复管理员权限检查不准导致TUN无法开启的问题
   - 增加 'fltmc' 命令作为主要判断,'net session' 作为备用,提高在特定环境下的准确性。

2. 优化自启动以自动保持TUN模式开启
   - 设置自启动时,根据当前运行身份决定任务权限。
   - 为TUN模式提权而重启后,若自启已开启,则自动将计划任务更新为管理员权限。
   - 普通权限启动但TUN开启时,主动提示用户需以管理员身份重启。
2025-08-20 20:01:09 +08:00
Memory
71f7b7b3c0
style: adjust disableTray position 2025-08-17 23:14:41 +08:00
Memory
e176f6db14
fix: selection loss in light mode 2025-08-17 13:38:49 +08:00
Memory
68dd3fd01a
chore: tweak MdContentPaste 2025-08-17 13:38:04 +08:00
Memory
ee19749009
opt: autoQuitWithoutCoreDelay input (#965)
* opt: autoQuitWithoutCoreDelay input

* opt: prevent text overflow in autoQuitWithoutCoreDelay input
2025-08-16 19:56:50 +08:00
Memory
c506d60e66
feat: support cron in profile update interval (#964) 2025-08-16 19:56:39 +08:00
Memory
e20e46aebe
fix: correct split logic in parseSubinfo (#960) 2025-08-15 20:21:18 +08:00
Memory
a23e23a697
fx: only detect mihomo (#958) 2025-08-15 13:56:59 +08:00
Memory
73068f6544
fix: prevent scientific notation parsing in YAML (#957) 2025-08-15 13:24:44 +08:00
Memory
fd708ec8bd
fix: privilege check and elevation autorun logic (#953) 2025-08-14 21:10:09 +08:00
Memory
35f51e1e39
fix: migrate from WMIC to modern APIs and improve privilege checks (#945)
* fix: WMIC is deprecated on windows-latest

* opt: remove WMIC

* fix: correctly detect high-privilege processes
2025-08-14 09:54:54 +08:00
ezequielnick
348c429855 feat: enable service if previously disabled 2025-08-13 18:11:14 +08:00
ezequielnick
bcf104e085 fix: workflow2 2025-08-13 12:29:48 +08:00
ezequielnick
9f8c70c8a8 fix: workflow 2025-08-13 11:36:43 +08:00
ezequielnick
0ea9528b70 feat: set default tun device name on macos (configurable) 2025-08-13 10:34:35 +08:00
ezequielnick
4997e098ba fix: remove core privilege check on non-windows 2025-08-13 09:44:11 +08:00
ezequielnick
d8d79f7b7d fix: tag workflow 2025-08-12 22:48:19 +08:00
ezequielnick
a45bc89b33 1.8.4 Released 2025-08-12 22:07:54 +08:00
Memory
6bdb133cea
fix: no admin rights on Windows (#933) 2025-08-12 21:56:58 +08:00
ezequielnick
db605f24fc feat: enhance core checks with TUN startup detection and unified popup design 2025-08-12 20:58:01 +08:00
ezequielnick
f005a4f4cd feat: add startup prompt for core permission check 2025-08-12 18:10:54 +08:00
ezequielnick
cb3eedfcb8 chore: delete debug log 2025-08-12 09:20:36 +08:00
ezequielnick
d030a8722d feat: separate app logs from core logs 2025-08-12 09:07:20 +08:00
ezequielnick
a8f8cd0fd3 workflow: delete all old assets 2025-08-10 09:33:27 +08:00
ezequielnick
defcbbca5c chore: update version 2025-08-10 09:07:01 +08:00
ezequielnick
dbfd25f481 feat: add floating window Compatibility Mode 2025-08-10 09:04:29 +08:00
ezequielnick
eb41bae23b debug floating window 2025-08-09 21:44:51 +08:00
ezequielnick
4a192586fc chore: delete old dev assets before build 2025-08-09 19:41:18 +08:00
ezequielnick
6172cadca8 chore: add dev release 2025-08-09 19:02:08 +08:00
ezequielnick
b5ee701530 feat: validate TUN configuration and permission compatibility 2025-08-09 15:43:40 +08:00
ezequielnick
58732ce653 feat: add fallback mechanism for floating window startup failure 2025-08-09 13:48:55 +08:00
ezequielnick
294dd75b48 1.8.3 Released 2025-08-09 10:15:33 +08:00
ezequielnick
f56c585818 refactor: copy geodata only if source files are newer 2025-08-09 10:05:09 +08:00
ezequielnick
ef96819621 fix: restore mistakenly removed trafficmonitor download component 2025-08-09 09:49:02 +08:00
ezequielnick
5e0c5b6e69 fix: change floating window to rectangle to avoid white border issue 2025-08-09 09:48:09 +08:00
ezequielnick
8cfee2f5e5 fix: resolve Windows admin mode restart issue 2025-08-06 22:49:39 +08:00
ezequielnick
b5f6658b72 fix: dns/sniffer override button logic2 2025-08-06 22:24:57 +08:00
ezequielnick
6b93a59616 fix: privilege check and elevation restart logic 2025-08-06 21:41:30 +08:00
ezequielnick
e27ddbd16e feat: request admin privileges when enabling TUN 2025-08-06 20:36:08 +08:00
ezequielnick
5c1d30b454 feat: remove enforced admin mode requirement 2025-08-06 18:34:59 +08:00
ezequielnick
45484ffff2 fix: dns/sniffer override logic 2025-08-06 17:21:51 +08:00
ezequielnick
73161d0cc2 1.8.2 Released 2025-08-06 14:45:04 +08:00
ezequielnick
578a8a559f fix: missing Smart core permission setup on Linux 2025-08-06 14:44:43 +08:00
ezequielnick
470adeb519 fix: double-click to edit override & add right-click menu support 2025-08-06 13:54:56 +08:00
ezequielnick
58e0925c5b refactor: Sniffing module with override logic 2025-08-06 13:25:57 +08:00
ezequielnick
0a064bdbb8 fix: windows first startup issue 2025-08-06 10:46:21 +08:00
ezequielnick
d6f0d30f9a fix: console selectedKeys warning 2025-08-05 19:20:32 +08:00
ezequielnick
f00600a83c fix: subscription card action click handler 2025-08-05 14:01:16 +08:00
ezequielnick
b1871591c0 refactor: DNS control module with override logic 2025-08-05 13:24:21 +08:00
ezequielnick
10b7c5c851 fix: incompatible CSS style 2025-08-05 12:28:14 +08:00
ezequielnick
cccd66bb21 1.8.1 Released 2025-08-05 12:28:14 +08:00
ezequielnick
866cdb4661 feat: optimize and improve subscription switching 2025-08-04 18:49:13 +08:00
ezequielnick
2e4090460d chore: Upgrade tailwindcss 2025-08-04 18:49:13 +08:00
ezequielnick
f2ed0caced chore: update deps 2025-08-04 18:49:13 +08:00
ezequielnick
6993015ce1 chore: remove default policy-priority 2025-08-04 18:49:13 +08:00
ezequielnick
e3e373d579 fix: smart gruop "MATCH" 2025-08-04 18:49:13 +08:00
ezequielnick
be7f8677b0 fix: smart core prepare 2025-08-04 18:49:13 +08:00
ezequielnick
dab2ead5fd fix: smart core version fetch logic 2025-08-04 18:49:13 +08:00
ezequielnick
1260b8fb4e 1.8.0 Smart Core Test Released 2025-08-04 18:49:13 +08:00
ezequielnick
46aa654ee3 feat: add smart core & one-click smart 2025-08-04 18:49:07 +08:00
215 changed files with 22024 additions and 10589 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

3
.gitignore vendored
View File

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

5
.npmrc
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,13 +18,13 @@ source=(
)
sha256sums=("52d761e9432e17477acb8adb5744676df946476e0eb5210fee2b6d45f497f218"
"96a6250f67517493f839f964c024434dbcf784b25a73f074bb505f1521f52844"
"560733f0e5bd9b47ff50c849301c8a22ae17a5df26830d8c97033dfcbd392382"
"87fddbcd4a4cc7bda22ec4cadff0040e54395bb13184ee4688b58788c1fa7180"
)
options=('!lto')
prepare(){
cd $srcdir/${_pkgname}-${pkgver}
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
cd $srcdir/clash-party-${pkgver}
sed -i "s/productName: Clash Party/productName: clash-party/" electron-builder.yml
pnpm install
}
@ -37,11 +37,12 @@ package() {
asar extract $srcdir/${_pkgname}-${pkgver}/dist/linux-unpacked/resources/app.asar ${pkgdir}/opt/mihomo-party
cp -r $srcdir/${_pkgname}-${pkgver}/extra/sidecar ${pkgdir}/opt/mihomo-party/resources/
cp -r $srcdir/${_pkgname}-${pkgver}/extra/files ${pkgdir}/opt/mihomo-party/resources/
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo-alpha
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo-smart
install -Dm755 "${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
install -Dm644 "${_pkgname}.desktop" "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
install -Dm644 "${pkgdir}/opt/mihomo-party/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png"
install -Dm644 "${pkgdir}/opt/clash-party/resources/icon.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/${_pkgname}.png"
chown -R root:root ${pkgdir}
}

View File

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

View File

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

View File

@ -12,7 +12,7 @@ optdepends=('libappindicator-gtk3: Allow mihomo-party to extend a menu via Ayata
makedepends=('nodejs' 'pnpm' 'jq' 'libxcrypt-compat')
install=$_pkgname.install
source=("${_pkgname}.sh" "git+$url.git")
sha256sums=("f8049c1f26d5a92fbcebd7bebbdedbb3eab53422b21cf6127418251ccd061282" "SKIP")
sha256sums=("242609b1259e999e944b7187f4c03aacba8134a7671ff3a50e2e246b4c4eff48" "SKIP")
options=('!lto')
pkgver() {
@ -25,7 +25,7 @@ pkgver() {
prepare(){
cd $srcdir/${_pkgname}
sed -i "s/productName: Mihomo Party/productName: mihomo-party/" electron-builder.yml
sed -i "s/productName: Clash Party/productName: clash-party/" electron-builder.yml
pnpm install
}
@ -36,13 +36,14 @@ build(){
package() {
cd $srcdir/${_pkgname}/dist
bsdtar -xf mihomo-party-linux-$(jq '.version' $srcdir/${_pkgname}/package.json | tr -d 'v"')*.deb
bsdtar -xf clash-party-linux-$(jq '.version' $srcdir/${_pkgname}/package.json | tr -d 'v"')*.deb
bsdtar -xf data.tar.xz -C "${pkgdir}/"
chmod +x ${pkgdir}/opt/mihomo-party/mihomo-party
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/mihomo-party/resources/sidecar/mihomo-alpha
chmod +x ${pkgdir}/opt/clash-party/mihomo-party
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo-alpha
chmod +sx ${pkgdir}/opt/clash-party/resources/sidecar/mihomo-smart
install -Dm755 "${srcdir}/../${_pkgname}.sh" "${pkgdir}/usr/bin/${_pkgname}"
sed -i '3s!/opt/mihomo-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
sed -i '3s!/opt/clash-party/mihomo-party!mihomo-party!' "${pkgdir}/usr/share/applications/${_pkgname}.desktop"
chown -R root:root ${pkgdir}
}

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

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

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 204 KiB

View File

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

29
build/linux/postuninst Normal file
View File

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

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

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

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

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

View File

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

View File

@ -1,5 +1,5 @@
appId: party.mihomo.app
productName: Mihomo Party
productName: Clash Party
directories:
buildResources: build
files:
@ -19,7 +19,7 @@ extraResources:
- from: './extra/'
to: ''
protocols:
name: 'Mihomo Party URI Scheme'
name: 'Clash Party URI Scheme'
schemes:
- 'clash'
- 'mihomo'
@ -27,9 +27,9 @@ win:
target:
- nsis
- 7z
artifactName: ${name}-windows-${version}-${arch}-portable.${ext}
artifactName: clash-party-windows-${version}-${arch}-portable.${ext}
nsis:
artifactName: ${name}-windows-${version}-${arch}-setup.${ext}
artifactName: clash-party-windows-${version}-${arch}-setup.${ext}
uninstallDisplayName: ${productName}
allowToChangeInstallationDirectory: true
oneClick: false
@ -39,32 +39,48 @@ mac:
target:
- pkg
entitlementsInherit: build/entitlements.mac.plist
hardenedRuntime: true
gatekeeperAssess: false
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: true
artifactName: ${name}-macos-${version}-${arch}.${ext}
notarize: false
artifactName: clash-party-macos-${version}-${arch}.${ext}
pkg:
allowAnywhere: false
allowCurrentUserHome: false
isRelocatable: false
background:
alignment: bottomleft
file: build/background.png
linux:
executableName: mihomo-party
icon: build/icon.png
desktop:
Name: Mihomo Party
MimeType: 'x-scheme-handler/clash;x-scheme-handler/mihomo'
entry:
Name: Clash Party
GenericName: Proxy Client
Comment: A GUI client based on Mihomo
MimeType: 'x-scheme-handler/clash;x-scheme-handler/mihomo'
Keywords: proxy;clash;mihomo;vpn;
StartupWMClass: mihomo-party
Icon: mihomo-party
target:
- deb
- rpm
maintainer: mihomo-party-org
category: Utility
artifactName: ${name}-linux-${version}-${arch}.${ext}
artifactName: clash-party-linux-${version}-${arch}.${ext}
deb:
afterInstall: 'build/linux/postinst'
afterRemove: 'build/linux/postuninst'
rpm:
afterInstall: 'build/linux/postinst'
afterRemove: 'build/linux/postuninst'
fpm:
- '--rpm-rpmbuild-define'
- '_build_id_links none'
npmRebuild: true
publish: []

View File

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

80
eslint.config.cjs Normal file
View File

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

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

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

11053
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
resources/icon_blue.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
resources/icon_blue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
resources/icon_green.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
resources/icon_green.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
resources/icon_red.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
resources/icon_red.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -1,7 +1,7 @@
#!/bin/bash
echo "=== Mihomo Party Cleanup Tool ==="
echo "This script will remove all Mihomo Party related files and services."
echo "=== Clash Party Cleanup Tool ==="
echo "This script will remove all Clash Party related files and services."
read -p "Are you sure you want to continue? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
@ -17,8 +17,8 @@ sudo launchctl unload /Library/LaunchDaemons/party.mihomo.helper.plist 2>/dev/nu
echo "Removing files..."
sudo rm -f /Library/LaunchDaemons/party.mihomo.helper.plist
sudo rm -f /Library/PrivilegedHelperTools/party.mihomo.helper
sudo rm -rf "/Applications/Mihomo Party.app"
sudo rm -rf "/Applications/Mihomo\\ Party.app"
sudo rm -rf "/Applications/Clash Party.app"
sudo rm -rf "/Applications/Clash\\ Party.app"
sudo rm -rf ~/Library/Application\ Support/mihomo-party
sudo rm -rf ~/Library/Caches/mihomo-party
sudo rm -f ~/Library/Preferences/party.mihomo.app.helper.plist

View File

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

View File

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

View File

@ -1,46 +1,79 @@
import axios from 'axios'
import { readFileSync } from 'fs'
import {
getProcessedVersion,
isDevBuild,
getDownloadUrl,
generateDownloadLinksMarkdown,
getGitCommitHash
} from './version-utils.mjs'
const chat_id = '@MihomoPartyChannel'
const pkg = readFileSync('package.json', 'utf-8')
const changelog = readFileSync('changelog.md', 'utf-8')
const { version } = JSON.parse(pkg)
const downloadUrl = `https://github.com/mihomo-party-org/mihomo-party/releases/download/v${version}`
let content = `<b>🌟 <a href="https://github.com/mihomo-party-org/mihomo-party/releases/tag/v${version}">Mihomo Party v${version}</a> 正式发布</b>\n\n`
for (const line of changelog.split('\n')) {
if (line.length === 0) {
content += '\n'
} else if (line.startsWith('### ')) {
content += `<b>${line.replace('### ', '')}</b>\n`
} else {
content += `${line}\n`
}
// 获取处理后的版本号
const version = getProcessedVersion()
const releaseType = process.env.RELEASE_TYPE || process.argv[2] || 'release'
const isDevRelease = releaseType === 'dev' || isDevBuild()
function convertMarkdownToTelegramHTML(content) {
return content
.split('\n')
.map((line) => {
if (line.trim().length === 0) {
return ''
} else if (line.startsWith('## ')) {
return `<b>${line.replace('## ', '')}</b>`
} else if (line.startsWith('### ')) {
return `<b>${line.replace('### ', '')}</b>`
} else if (line.startsWith('#### ')) {
return `<b>${line.replace('#### ', '')}</b>`
} else {
let processedLine = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
const encodedUrl = encodeURI(url)
return `<a href="${encodedUrl}">${text}</a>`
})
processedLine = processedLine.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
return processedLine
}
})
.join('\n')
}
content += '\n<b>下载地址:</b>\n<b>Windows10/11</b>\n'
content += `安装版:<a href="${downloadUrl}/mihomo-party-windows-${version}-x64-setup.exe">64位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-ia32-setup.exe">32位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-arm64-setup.exe">ARM64</a>\n`
content += `便携版:<a href="${downloadUrl}/mihomo-party-windows-${version}-x64-portable.7z">64位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-ia32-portable.7z">32位</a> | <a href="${downloadUrl}/mihomo-party-windows-${version}-arm64-portable.7z">ARM64</a>\n`
content += '\n<b>Windows7/8</b>\n'
content += `安装版:<a href="${downloadUrl}/mihomo-party-win7-${version}-x64-setup.exe">64位</a> | <a href="${downloadUrl}/mihomo-party-win7-${version}-ia32-setup.exe">32位</a>\n`
content += `便携版:<a href="${downloadUrl}/mihomo-party-win7-${version}-x64-portable.7z">64位</a> | <a href="${downloadUrl}/mihomo-party-win7-${version}-ia32-portable.7z">32位</a>\n`
content += '\n<b>macOS 11+</b>\n'
content += `PKG<a href="${downloadUrl}/mihomo-party-macos-${version}-x64.pkg
">Intel</a> | <a href="${downloadUrl}/mihomo-party-macos-${version}-arm64.pkg">Apple Silicon</a>\n`
content += '\n<b>macOS 10.15+</b>\n'
content += `PKG<a href="${downloadUrl}/mihomo-party-catalina-${version}-x64.pkg
">Intel</a> | <a href="${downloadUrl}/mihomo-party-catalina-${version}-arm64.pkg">Apple Silicon</a>\n`
content += '\n<b>Linux</b>\n'
content += `DEB<a href="${downloadUrl}/mihomo-party-linux-${version}-amd64.deb
">64位</a> | <a href="${downloadUrl}/mihomo-party-linux-${version}-arm64.deb">ARM64</a>\n`
content += `RPM<a href="${downloadUrl}/mihomo-party-linux-${version}-x86_64.rpm">64位</a> | <a href="${downloadUrl}/mihomo-party-linux-${version}-aarch64.rpm">ARM64</a>`
let content = ''
if (isDevRelease) {
// 版本号中提取commit hash
const shortCommitSha = getGitCommitHash(true)
const commitSha = getGitCommitHash(false)
content = `<b>🚧 <a href="https://github.com/mihomo-party-org/clash-party/releases/tag/dev">Clash Party Dev Build</a> 开发版本发布</b>\n\n`
content += `<b>基于版本:</b> ${version}\n`
content += `<b>提交哈希:</b> <a href="https://github.com/mihomo-party-org/clash-party/commit/${commitSha}">${shortCommitSha}</a>\n\n`
content += `<b>更新日志:</b>\n`
content += convertMarkdownToTelegramHTML(changelog)
content += '\n\n<b>⚠️ 注意:这是开发版本,可能存在不稳定性,仅供测试使用</b>\n'
} else {
// 正式版本通知
content = `<b>🌟 <a href="https://github.com/mihomo-party-org/clash-party/releases/tag/v${version}">Clash Party v${version}</a> 正式发布</b>\n\n`
content += convertMarkdownToTelegramHTML(changelog)
}
// 构建下载链接
const downloadUrl = getDownloadUrl(isDevRelease, version)
const downloadLinksMarkdown = generateDownloadLinksMarkdown(downloadUrl, version)
content += convertMarkdownToTelegramHTML(downloadLinksMarkdown)
await axios.post(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
chat_id,
text: content,
link_preview_options: {
is_disabled: false,
url: 'https://github.com/mihomo-party-org/mihomo-party',
url: 'https://github.com/mihomo-party-org/clash-party',
prefer_large_media: true
},
parse_mode: 'HTML'
})
console.log(`${isDevRelease ? '开发版本' : '正式版本'}Telegram 通知发送成功`)

View File

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

View File

@ -1,28 +1,29 @@
import yaml from 'yaml'
import { readFileSync, writeFileSync } from 'fs'
import {
getProcessedVersion,
isDevBuild,
getDownloadUrl,
generateDownloadLinksMarkdown
} from './version-utils.mjs'
const pkg = readFileSync('package.json', 'utf-8')
let changelog = readFileSync('changelog.md', 'utf-8')
const { version } = JSON.parse(pkg)
const downloadUrl = `https://github.com/mihomo-party-org/mihomo-party/releases/download/v${version}`
// 获取处理后的版本号
const version = getProcessedVersion()
const isDev = isDevBuild()
const downloadUrl = getDownloadUrl(isDev, version)
const latest = {
version,
changelog
}
changelog += '\n### 下载地址:\n\n#### Windows10/11\n\n'
changelog += `- 安装版:[64位](${downloadUrl}/mihomo-party-windows-${version}-x64-setup.exe) | [32位](${downloadUrl}/mihomo-party-windows-${version}-ia32-setup.exe) | [ARM64](${downloadUrl}/mihomo-party-windows-${version}-arm64-setup.exe)\n\n`
changelog += `- 便携版:[64位](${downloadUrl}/mihomo-party-windows-${version}-x64-portable.7z) | [32位](${downloadUrl}/mihomo-party-windows-${version}-ia32-portable.7z) | [ARM64](${downloadUrl}/mihomo-party-windows-${version}-arm64-portable.7z)\n\n`
changelog += '\n#### Windows7/8\n\n'
changelog += `- 安装版:[64位](${downloadUrl}/mihomo-party-win7-${version}-x64-setup.exe) | [32位](${downloadUrl}/mihomo-party-win7-${version}-ia32-setup.exe)\n\n`
changelog += `- 便携版:[64位](${downloadUrl}/mihomo-party-win7-${version}-x64-portable.7z) | [32位](${downloadUrl}/mihomo-party-win7-${version}-ia32-portable.7z)\n\n`
changelog += '\n#### macOS 11+\n\n'
changelog += `- PKG[Intel](${downloadUrl}/mihomo-party-macos-${version}-x64.pkg) | [Apple Silicon](${downloadUrl}/mihomo-party-macos-${version}-arm64.pkg)\n\n`
changelog += '\n#### macOS 10.15+\n\n'
changelog += `- PKG[Intel](${downloadUrl}/mihomo-party-catalina-${version}-x64.pkg) | [Apple Silicon](${downloadUrl}/mihomo-party-catalina-${version}-arm64.pkg)\n\n`
changelog += '\n#### Linux\n\n'
changelog += `- DEB[64位](${downloadUrl}/mihomo-party-linux-${version}-amd64.deb) | [ARM64](${downloadUrl}/mihomo-party-linux-${version}-arm64.deb)\n\n`
changelog += `- RPM[64位](${downloadUrl}/mihomo-party-linux-${version}-x86_64.rpm) | [ARM64](${downloadUrl}/mihomo-party-linux-${version}-aarch64.rpm)`
// 使用统一的下载链接生成函数
changelog += generateDownloadLinksMarkdown(downloadUrl, version)
changelog +=
'\n\n### 机场推荐:\n- 高性能海外机场,稳定首选:[https://狗狗加速.com](https://party.dginv.click/#/register?code=ARdo0mXx)'
writeFileSync('latest.yml', yaml.stringify(latest))
writeFileSync('changelog.md', changelog)

88
scripts/version-utils.mjs Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

82
src/main/core/dns.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

139
src/main/core/process.ts Normal file
View File

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

View File

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

View File

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

32
src/main/deeplink.ts Normal file
View File

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

View File

@ -1,135 +1,144 @@
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { registerIpcMainHandlers } from './utils/ipc'
import windowStateKeeper from 'electron-window-state'
import { app, shell, BrowserWindow, Menu, dialog, Notification, powerMonitor } from 'electron'
import { addProfileItem, getAppConfig, patchAppConfig } from './config'
import { quitWithoutCore, startCore, stopCore } from './core/manager'
import { triggerSysProxy } from './sys/sysproxy'
import icon from '../../resources/icon.png?asset'
import { createTray, hideDockIcon, showDockIcon } from './resolve/tray'
import { init } from './utils/init'
import { join } from 'path'
import { initShortcut } from './resolve/shortcut'
import { execSync, spawn, exec } from 'child_process'
import { createElevateTask } from './sys/misc'
import { promisify } from 'util'
import { stat } from 'fs/promises'
import { initProfileUpdater } from './core/profileUpdater'
import { existsSync, writeFileSync } from 'fs'
import { exePath, taskDir } from './utils/dirs'
import path from 'path'
import { startMonitor } from './resolve/trafficMonitor'
import { showFloatingWindow } from './resolve/floatingWindow'
import iconv from 'iconv-lite'
import { initI18n } from '../shared/i18n'
import i18next from 'i18next'
async function fixUserDataPermissions(): Promise<void> {
if (process.platform !== 'darwin') return
const userDataPath = app.getPath('userData')
if (!existsSync(userDataPath)) return
import { execSync } from 'child_process'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, dialog } from 'electron'
if (process.platform === 'win32') {
try {
const stats = await stat(userDataPath)
const currentUid = process.getuid?.() || 0
if (stats.uid === 0 && currentUid !== 0) {
const execPromise = promisify(exec)
const username = process.env.USER || process.env.LOGNAME
if (username) {
await execPromise(`chown -R "${username}:staff" "${userDataPath}"`)
await execPromise(`chmod -R u+rwX "${userDataPath}"`)
}
const stdout = execSync('powershell -NoProfile -Command "$PSVersionTable.PSVersion.Major"', {
encoding: 'utf8',
timeout: 5000
})
const major = parseInt(stdout.trim(), 10)
if (!isNaN(major) && major < 5) {
const isZh = Intl.DateTimeFormat().resolvedOptions().locale?.startsWith('zh')
const title = isZh ? '需要更新 PowerShell' : 'PowerShell Update Required'
const message = isZh
? `检测到您的 PowerShell 版本为 ${major}.x部分功能需要 PowerShell 5.1 才能正常运行。\\n\\n请访问 Microsoft 官网下载并安装 Windows Management Framework 5.1。`
: `Detected PowerShell version ${major}.x. Some features require PowerShell 5.1.\\n\\nPlease install Windows Management Framework 5.1 from the Microsoft website.`
execSync(
`mshta "javascript:var sh=new ActiveXObject('WScript.Shell');sh.Popup('${message}',0,'${title}',48);close()"`,
{ timeout: 60000 }
)
process.exit(0)
}
} catch {
// ignore
}
}
import i18next from 'i18next'
import { initI18n } from '../shared/i18n'
import { registerIpcMainHandlers } from './utils/ipc'
import { getAppConfig, patchAppConfig } from './config'
import {
startCore,
checkAdminRestartForTun,
checkHighPrivilegeCore,
restartAsAdmin,
initAdminStatus,
checkAdminPrivileges,
initCoreWatcher
} from './core/manager'
import { createTray } from './resolve/tray'
import { init, initBasic, safeShowErrorBox } from './utils/init'
import { initShortcut } from './resolve/shortcut'
import { initProfileUpdater } from './core/profileUpdater'
import { startMonitor } from './resolve/trafficMonitor'
import { showFloatingWindow } from './resolve/floatingWindow'
import { logger, createLogger } from './utils/logger'
import { initWebdavBackupScheduler } from './resolve/backup'
import {
createWindow,
mainWindow,
showMainWindow,
triggerMainWindow,
closeMainWindow
} from './window'
import { handleDeepLink } from './deeplink'
import {
fixUserDataPermissions,
setupPlatformSpecifics,
setupAppLifecycle,
getSystemLanguage
} from './lifecycle'
let quitTimeout: NodeJS.Timeout | null = null
export let mainWindow: BrowserWindow | null = null
const mainLogger = createLogger('Main')
if (process.platform === 'win32' && !is.dev && !process.argv.includes('noadmin')) {
try {
createElevateTask()
} catch (createError) {
try {
if (process.argv.slice(1).length > 0) {
writeFileSync(path.join(taskDir(), 'param.txt'), process.argv.slice(1).join(' '))
} else {
writeFileSync(path.join(taskDir(), 'param.txt'), 'empty')
}
if (!existsSync(path.join(taskDir(), 'mihomo-party-run.exe'))) {
throw new Error('mihomo-party-run.exe not found')
} else {
execSync('%SystemRoot%\\System32\\schtasks.exe /run /tn mihomo-party-run')
}
} catch (e) {
let createErrorStr = `${createError}`
let eStr = `${e}`
try {
createErrorStr = iconv.decode((createError as { stderr: Buffer }).stderr, 'gbk')
eStr = iconv.decode((e as { stderr: Buffer }).stderr, 'gbk')
} catch {
// ignore
}
dialog.showErrorBox(
i18next.t('common.error.adminRequired'),
`${i18next.t('common.error.adminRequired')}\n${createErrorStr}\n${eStr}`
)
} finally {
app.exit()
}
}
export { mainWindow, showMainWindow, triggerMainWindow, closeMainWindow }
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
}
async function initApp(): Promise<void> {
await fixUserDataPermissions()
}
initApp()
.then(() => {
const gotTheLock = app.requestSingleInstanceLock()
initApp().catch((e) => {
safeShowErrorBox('common.error.initFailed', `${e}`)
app.quit()
})
if (!gotTheLock) {
app.quit()
setupPlatformSpecifics()
async function checkHighPrivilegeCoreEarly(): Promise<void> {
if (process.platform !== 'win32') return
try {
await initBasic()
const isCurrentAppAdmin = await checkAdminPrivileges()
if (isCurrentAppAdmin) return
const hasHighPrivilegeCore = await checkHighPrivilegeCore()
if (!hasHighPrivilegeCore) return
try {
const appConfig = await getAppConfig()
const language = appConfig.language || (app.getLocale().startsWith('zh') ? 'zh-CN' : 'en-US')
await initI18n({ lng: language })
} catch {
await initI18n({ lng: 'zh-CN' })
}
})
.catch(() => {
// ignore permission fix errors
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
const choice = dialog.showMessageBoxSync({
type: 'warning',
title: i18next.t('core.highPrivilege.title'),
message: i18next.t('core.highPrivilege.message'),
buttons: [i18next.t('common.confirm'), i18next.t('common.cancel')],
defaultId: 0,
cancelId: 1
})
if (choice === 0) {
try {
await restartAsAdmin(false)
app.exit(0)
} catch (error) {
safeShowErrorBox('common.error.adminRequired', `${error}`)
app.exit(1)
}
} else {
app.exit(0)
}
})
export function customRelaunch(): void {
const script = `while kill -0 ${process.pid} 2>/dev/null; do
sleep 0.1
done
${process.argv.join(' ')} & disown
exit
`
spawn('sh', ['-c', `"${script}"`], {
shell: true,
detached: true,
stdio: 'ignore'
})
} catch (e) {
mainLogger.error('Failed to check high privilege core', e)
}
}
if (process.platform === 'linux') {
app.relaunch = customRelaunch
async function initHardwareAcceleration(): Promise<void> {
try {
await initBasic()
const { disableHardwareAcceleration = false } = await getAppConfig()
if (disableHardwareAcceleration) {
app.disableHardwareAcceleration()
}
} catch (e) {
mainLogger.warn('Failed to read hardware acceleration config', e)
}
}
if (process.platform === 'win32' && !exePath().startsWith('C')) {
// https://github.com/electron/electron/issues/43278
// https://github.com/electron/electron/issues/36698
app.commandLine.appendSwitch('in-process-gpu')
}
const initPromise = init()
initHardwareAcceleration()
setupAppLifecycle()
app.on('second-instance', async (_event, commandline) => {
showMainWindow()
@ -144,248 +153,96 @@ app.on('open-url', async (_event, url) => {
await handleDeepLink(url)
})
app.on('before-quit', async (e) => {
e.preventDefault()
triggerSysProxy(false)
await stopCore()
app.exit()
})
powerMonitor.on('shutdown', async () => {
triggerSysProxy(false)
await stopCore()
app.exit()
})
// 获取系统语言
function getSystemLanguage(): 'zh-CN' | 'en-US' {
const locale = app.getLocale()
return locale.startsWith('zh') ? 'zh-CN' : 'en-US'
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
// Set app user model id for windows
electronApp.setAppUserModelId('party.mihomo.app')
const initPromise = (async () => {
await initBasic()
await checkHighPrivilegeCoreEarly()
await initAdminStatus()
try {
await init()
const appConfig = await getAppConfig()
// 如果配置中没有语言设置,则使用系统语言
if (!appConfig.language) {
const systemLanguage = getSystemLanguage()
await patchAppConfig({ language: systemLanguage })
appConfig.language = systemLanguage
}
await initI18n({ lng: appConfig.language })
await initPromise
return appConfig
} catch (e) {
dialog.showErrorBox(i18next.t('common.error.initFailed'), `${e}`)
safeShowErrorBox('common.error.initFailed', `${e}`)
app.quit()
throw e
}
try {
const [startPromise] = await startCore()
startPromise.then(async () => {
await initProfileUpdater()
})
} catch (e) {
dialog.showErrorBox(i18next.t('mihomo.error.coreStartFailed'), `${e}`)
}
try {
await startMonitor()
} catch {
// ignore
}
})()
app.whenReady().then(async () => {
electronApp.setAppUserModelId('party.mihomo.app')
const appConfig = await initPromise
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
const { showFloatingWindow: showFloating = false, disableTray = false } = await getAppConfig()
registerIpcMainHandlers()
await createWindow()
const createWindowPromise = createWindow()
let coreStarted = false
const coreStartPromise = (async (): Promise<void> => {
try {
initCoreWatcher()
const startPromises = await startCore()
if (startPromises.length > 0) {
startPromises[0].then(async () => {
await initProfileUpdater()
await initWebdavBackupScheduler()
await checkAdminRestartForTun()
})
}
coreStarted = true
} catch (e) {
safeShowErrorBox('mihomo.error.coreStartFailed', `${e}`)
}
})()
const monitorPromise = (async (): Promise<void> => {
try {
await startMonitor()
} catch {
// ignore
}
})()
await createWindowPromise
const { showFloatingWindow: showFloating = false, disableTray = false } = appConfig
const uiTasks: Promise<void>[] = [initShortcut()]
if (showFloating) {
showFloatingWindow()
uiTasks.push(
(async () => {
try {
await showFloatingWindow()
} catch (error) {
await logger.error('Failed to create floating window on startup', error)
}
})()
)
}
if (!disableTray) {
await createTray()
uiTasks.push(createTray())
}
await initShortcut()
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
await Promise.all(uiTasks)
await Promise.all([coreStartPromise, monitorPromise])
if (coreStarted) {
mainWindow?.webContents.send('core-started')
}
app.on('activate', () => {
showMainWindow()
})
})
async function handleDeepLink(url: string): Promise<void> {
if (!url.startsWith('clash://') && !url.startsWith('mihomo://')) return
const urlObj = new URL(url)
switch (urlObj.host) {
case 'install-config': {
try {
const profileUrl = urlObj.searchParams.get('url')
const profileName = urlObj.searchParams.get('name')
if (!profileUrl) {
throw new Error(i18next.t('profiles.error.urlParamMissing'))
}
await addProfileItem({
type: 'remote',
name: profileName ?? undefined,
url: profileUrl
})
mainWindow?.webContents.send('profileConfigUpdated')
new Notification({ title: i18next.t('profiles.notification.importSuccess') }).show()
break
} catch (e) {
dialog.showErrorBox(i18next.t('profiles.error.importFailed'), `${url}\n${e}`)
}
}
}
}
export async function createWindow(): Promise<void> {
const { useWindowFrame = false } = await getAppConfig()
const mainWindowState = windowStateKeeper({
defaultWidth: 800,
defaultHeight: 600,
file: 'window-state.json'
})
// https://github.com/electron/electron/issues/16521#issuecomment-582955104
Menu.setApplicationMenu(null)
mainWindow = new BrowserWindow({
minWidth: 800,
minHeight: 600,
width: mainWindowState.width,
height: mainWindowState.height,
x: mainWindowState.x,
y: mainWindowState.y,
show: false,
frame: useWindowFrame,
fullscreenable: false,
titleBarStyle: useWindowFrame ? 'default' : 'hidden',
titleBarOverlay: useWindowFrame
? false
: {
height: 49
},
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon: icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
spellcheck: false,
sandbox: false,
devTools: true
}
})
mainWindowState.manage(mainWindow)
mainWindow.on('ready-to-show', async () => {
const {
silentStart = false,
autoQuitWithoutCore = false,
autoQuitWithoutCoreDelay = 60
} = await getAppConfig()
if (autoQuitWithoutCore && !mainWindow?.isVisible()) {
if (quitTimeout) {
clearTimeout(quitTimeout)
}
quitTimeout = setTimeout(async () => {
await quitWithoutCore()
}, autoQuitWithoutCoreDelay * 1000)
}
if (!silentStart) {
if (quitTimeout) {
clearTimeout(quitTimeout)
}
mainWindow?.show()
mainWindow?.focusOnWebView()
}
})
mainWindow.webContents.on('did-fail-load', () => {
mainWindow?.webContents.reload()
})
mainWindow.on('show', () => {
showDockIcon()
})
mainWindow.on('close', async (event) => {
event.preventDefault()
mainWindow?.hide()
const {
autoQuitWithoutCore = false,
autoQuitWithoutCoreDelay = 60,
useDockIcon = true
} = await getAppConfig()
if (!useDockIcon) {
hideDockIcon()
}
if (autoQuitWithoutCore) {
if (quitTimeout) {
clearTimeout(quitTimeout)
}
quitTimeout = setTimeout(async () => {
await quitWithoutCore()
}, autoQuitWithoutCoreDelay * 1000)
}
})
mainWindow.on('resized', () => {
if (mainWindow) mainWindowState.saveState(mainWindow)
})
mainWindow.on('move', () => {
if (mainWindow) mainWindowState.saveState(mainWindow)
})
mainWindow.on('session-end', async () => {
triggerSysProxy(false)
await stopCore()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// 在开发模式下自动打开 DevTools
if (is.dev) {
mainWindow.webContents.openDevTools()
}
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
export function triggerMainWindow(): void {
if (mainWindow?.isVisible()) {
closeMainWindow()
} else {
showMainWindow()
}
}
export function showMainWindow(): void {
if (mainWindow) {
if (quitTimeout) {
clearTimeout(quitTimeout)
}
mainWindow.show()
mainWindow.focusOnWebView()
}
}
export function closeMainWindow(): void {
if (mainWindow) {
mainWindow.close()
}
}

76
src/main/lifecycle.ts Normal file
View File

@ -0,0 +1,76 @@
import { spawn, exec } from 'child_process'
import { promisify } from 'util'
import { stat } from 'fs/promises'
import { existsSync } from 'fs'
import { app, powerMonitor } from 'electron'
import { stopCore, cleanupCoreWatcher } from './core/manager'
import { triggerSysProxy } from './sys/sysproxy'
import { exePath } from './utils/dirs'
export function customRelaunch(): void {
const script = `while kill -0 ${process.pid} 2>/dev/null; do
sleep 0.1
done
${process.argv.join(' ')} & disown
exit
`
spawn('sh', ['-c', script], {
detached: true,
stdio: 'ignore'
})
}
export async function fixUserDataPermissions(): Promise<void> {
if (process.platform !== 'darwin') return
const userDataPath = app.getPath('userData')
if (!existsSync(userDataPath)) return
try {
const stats = await stat(userDataPath)
const currentUid = process.getuid?.() || 0
if (stats.uid === 0 && currentUid !== 0) {
const execPromise = promisify(exec)
const username = process.env.USER || process.env.LOGNAME
if (username) {
await execPromise(`chown -R "${username}:staff" "${userDataPath}"`)
await execPromise(`chmod -R u+rwX "${userDataPath}"`)
}
}
} catch {
// ignore
}
}
export function setupPlatformSpecifics(): void {
if (process.platform === 'linux') {
app.relaunch = customRelaunch
}
if (process.platform === 'win32' && !exePath().startsWith('C')) {
app.commandLine.appendSwitch('in-process-gpu')
}
}
export function setupAppLifecycle(): void {
app.on('before-quit', async (e) => {
e.preventDefault()
cleanupCoreWatcher()
await triggerSysProxy(false)
await stopCore()
app.exit()
})
powerMonitor.on('shutdown', async () => {
cleanupCoreWatcher()
triggerSysProxy(false)
await stopCore()
app.exit()
})
}
export function getSystemLanguage(): 'zh-CN' | 'en-US' {
const locale = app.getLocale()
return locale.startsWith('zh') ? 'zh-CN' : 'en-US'
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import { ChildProcess, spawn } from 'child_process'
import { getAppConfig } from '../config'
import { dataDir, resourcesFilesDir } from '../utils/dirs'
import path from 'path'
import { existsSync } from 'fs'
import { readFile, rm, writeFile } from 'fs/promises'
import { dataDir, resourcesFilesDir } from '../utils/dirs'
import { getAppConfig } from '../config'
let child: ChildProcess

View File

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

View File

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

View File

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

View File

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

View File

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

63
src/main/utils/appName.ts Normal file
View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

228
src/main/utils/github.ts Normal file
View File

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

287
src/main/utils/icon.ts Normal file
View File

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

View File

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

View File

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

View File

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

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