Compare commits

...

29 Commits

Author SHA1 Message Date
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
29 changed files with 324 additions and 142 deletions

View File

@ -114,9 +114,7 @@ jobs:
env:
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
run: |
pnpm install
pnpm add @mihomo-party/sysproxy-win32-${{ matrix.arch }}-msvc
run: pnpm install
- name: Update Version for Dev Build
if: github.event_name == 'workflow_dispatch'
env:
@ -197,9 +195,13 @@ jobs:
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
# Downgrade packages incompatible with Node.js 16 (Electron 22)
pnpm add express@4.21.2 chokidar@3.6.0
(Get-Content electron-builder.yml) -replace 'windows', 'win7' | Set-Content electron-builder.yml
# Electron 22 requires CJS format
(Get-Content package.json) -replace '"type": "module"', '"type": "commonjs"' | Set-Content package.json
(Get-Content electron.vite.config.ts) -replace 'plugins: \[externalizeDepsPlugin\(\)\]', "plugins: [externalizeDepsPlugin()],`n build: { rollupOptions: { output: { format: 'cjs' } } }" | Set-Content electron.vite.config.ts
- name: Update Version for Dev Build
if: github.event_name == 'workflow_dispatch'
env:
@ -280,7 +282,6 @@ jobs:
npm_config_target_arch: ${{ matrix.arch }}
run: |
pnpm install
pnpm add @mihomo-party/sysproxy-linux-${{ matrix.arch }}-gnu
sed -i "s/productName: Clash Party/productName: clash-party/" electron-builder.yml
- name: Update Version for Dev Build
if: github.event_name == 'workflow_dispatch'
@ -354,9 +355,7 @@ jobs:
env:
npm_config_arch: ${{ matrix.arch }}
npm_config_target_arch: ${{ matrix.arch }}
run: |
pnpm install
pnpm add @mihomo-party/sysproxy-darwin-${{ matrix.arch }}
run: pnpm install
- name: Update Version for Dev Build
if: github.event_name == 'workflow_dispatch'
env:
@ -454,7 +453,6 @@ jobs:
npm_config_target_arch: ${{ matrix.arch }}
run: |
pnpm install
pnpm add @mihomo-party/sysproxy-darwin-${{ matrix.arch }}
pnpm add -D electron@32.2.2
- name: Update Version for Dev Build
if: github.event_name == 'workflow_dispatch'

View File

@ -2,6 +2,7 @@
## 新功能 (Feat)
- 支持隐藏不可用代理选项
- 支持禁用自动更新
- 支持交换任务栏点击行为
- 支持订阅导入时自动选择直连或代理
@ -12,48 +13,30 @@
- 托盘代理组样式支持子菜单模式
- 增加繁体中文(台湾)翻译
- 增加 HTML 检测和配置文件解析错误处理
- 将 sysproxy 迁移至 sysproxy-rs
## 修复 (Fix)
- 修复 Windows 旧 mihomo 进程导致的 EBUSY 错误
- 修复侧边栏卡片水平偏移
- macOS DNS 设置使用 helper 服务避免权限问题
- 修复首次启动时资源文件复制失败导致程序无法运行的问题
- 改进 macOS 助手在重启后套接字丢失时的恢复能力
- 使用原子更新修复 changeCurrentProfile
- 确保启用 diffWorkDir 时当前配置文件 ID 一致
- 修复配置写入队列并防止 IPC 监听器累积
- 解决事件监听器内存泄漏并添加错误日志
- 修复 RPM 包中的 .build-id 文件冲突
- 修复 WebSocket 重连延迟和事件监听器清理
- 优化连接页面性能和状态管理
- 处理获取 Mihomo 标签时的非数组响应
- 确保所有默认配置字段都存在于 config.yaml 中
- 处理失败状态码或无效配置文件的订阅
- 防止查找配置文件项时的空访问错误
- 修复连接详情和日志无法选择的问题
- 改进应用实例锁处理
- 修复 mixed-port 配置问题
- 备份前添加文件存在性检查
- 空端口输入处理为 0
- 修复覆盖页面中缺失的占位符和错误处理
- 修复内核自重启时的竞态条件
- 修复端口值为 NaN 时的配置读写问题
- 修复 Smart Core 代理组名称替换精度问题
- 修复 profile/override 配置中 items 数组未定义导致的错误
- 修复 lite 模式下 geo 文件同步到 profile 工作目录
- 修复 Linux GNOME 桌面图标和启动器可见性问题
- 修复管理员重启时等待新进程启动
## 优化 (Optimize)
- 使用通知系统替换 alert() 弹窗
- 使用记忆化和状态管理优化连接和日志组件
- 跳过 PowerShell 配置文件加载以提升性能
## 重构 (Refactor)
- 使用 IPC 通道白名单改进预加载安全性
- 简化主进程 IPC 处理器注册
- 使用通用调用包装器简化 IPC 层
- 移除硬编码的中文字符串并改进国际化覆盖
- 移除不再使用的 IPC 代码
- 添加缺失的 await 关键字并重构重复代码
### 其他 (chore)
- 升级所有依赖项
- 升级 GitHub Actions 到最新版本
- 确保 ESLint 通过并格式化代码
- 优化连接页面性能
# 1.8.9

View File

@ -56,6 +56,8 @@ pkg:
alignment: bottomleft
file: build/background.png
linux:
executableName: mihomo-party
icon: build/icon.png
desktop:
entry:
Name: Clash Party
@ -63,7 +65,8 @@ linux:
Comment: A GUI client based on Mihomo
MimeType: 'x-scheme-handler/clash;x-scheme-handler/mihomo'
Keywords: proxy;clash;mihomo;vpn;
StartupWMClass: clash-party
StartupWMClass: mihomo-party
Icon: mihomo-party
target:
- deb
- rpm

View File

@ -20,7 +20,15 @@ export default defineConfig({
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
output: {
format: 'cjs',
entryFileNames: '[name].cjs'
}
}
}
},
renderer: {
build: {

View File

@ -2,6 +2,7 @@
"name": "mihomo-party",
"version": "1.9.0",
"description": "Clash Party",
"type": "module",
"main": "./out/main/index.js",
"author": "mihomo-party-org",
"homepage": "https://mihomo.party",

View File

@ -316,11 +316,68 @@ 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`
})
/* ======= 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() {
const isMusl = (() => {
if (platform !== 'linux') return false
try {
// 通过 ldd --version 输出判断是否为 musl
const output = execSync('ldd --version 2>&1 || true').toString()
return output.includes('musl')
} catch {
return false
}
})()
switch (platform) {
case 'win32':
if (arch === 'x64') return 'sysproxy.win32-x64-msvc.node'
if (arch === 'arm64') return 'sysproxy.win32-arm64-msvc.node'
if (arch === 'ia32') return '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')
@ -429,8 +486,7 @@ const tasks = [
{
name: 'sysproxy',
func: resolveSysproxy,
retry: 5,
winOnly: true
retry: 5
},
{
name: 'monitor',

View File

@ -33,6 +33,14 @@ export async function getControledMihomoConfig(force = false): Promise<Partial<I
// 确保配置包含所有必要的默认字段,处理升级场景
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
@ -43,6 +51,14 @@ export async function patchControledMihomoConfig(patch: Partial<IMihomoConfig>):
controledMihomoWriteQueue = controledMihomoWriteQueue.then(async () => {
const { controlDns = true, controlSniff = true } = await getAppConfig()
// 过滤端口字段中的 NaN 值,防止写入无效配置
const portFields = ['mixed-port', 'socks-port', 'port', 'redir-port', 'tproxy-port'] as const
for (const field of portFields) {
if (field in patch && (typeof patch[field] !== 'number' || Number.isNaN(patch[field]))) {
delete patch[field]
}
}
if (patch.hosts) {
controledMihomoConfig.hosts = patch.hosts
}

View File

@ -14,6 +14,7 @@ export async function getOverrideConfig(force = false): Promise<IOverrideConfig>
overrideConfig = parse(data) || { items: [] }
}
if (typeof overrideConfig !== 'object') overrideConfig = { items: [] }
if (!Array.isArray(overrideConfig.items)) overrideConfig.items = []
return overrideConfig
}

View File

@ -27,6 +27,7 @@ export async function getProfileConfig(force = false): Promise<IProfileConfig> {
profileConfig = parse(data) || { items: [] }
}
if (typeof profileConfig !== 'object') profileConfig = { items: [] }
if (!Array.isArray(profileConfig.items)) profileConfig.items = []
return structuredClone(profileConfig)
}
@ -46,6 +47,7 @@ export async function updateProfileConfig(
const data = await readFile(profileConfigPath(), 'utf-8')
profileConfig = parse(data) || { items: [] }
if (typeof profileConfig !== 'object') profileConfig = { items: [] }
if (!Array.isArray(profileConfig.items)) profileConfig.items = []
profileConfig = await updater(structuredClone(profileConfig))
result = profileConfig
await writeFile(profileConfigPath(), stringify(profileConfig), 'utf-8')

View File

@ -118,18 +118,42 @@ function main(config) {
}
// 更新规则中的代理组引用
// 规则参数列表,这些不是策略组名称
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') {
let updatedRule = rule
nameMapping.forEach((newName, oldName) => {
// 使用简单的字符串替换,检查是否完全匹配
if (updatedRule.includes(oldName)) {
updatedRule = updatedRule.split(oldName).join(newName)
console.log('[Smart Override] Updated rule reference:', oldName, '→', newName)
// 按逗号分割规则,精确匹配策略组名称位置
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
}
}
}
})
return updatedRule
// 只替换策略组名称位置
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 => {

View File

@ -1,9 +1,11 @@
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
@ -41,10 +43,18 @@ async function getOriginDNS(): Promise<void> {
async function setDNS(dns: string): Promise<void> {
const service = await getDefaultService()
// networksetup 需要 root 权限,通过 osascript 请求管理员权限执行
const shell = `networksetup -setdnsservers "${service}" ${dns}`
const command = `do shell script "${shell}" with administrator privileges`
await execPromise(`osascript -e '${command}'`)
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> {

View File

@ -1,4 +1,4 @@
import { copyFile, mkdir, writeFile, readFile } from 'fs/promises'
import { copyFile, mkdir, writeFile, readFile, stat } from 'fs/promises'
import vm from 'vm'
import { existsSync, writeFileSync } from 'fs'
import path from 'path'
@ -160,10 +160,23 @@ 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)
}
}

View File

@ -85,6 +85,8 @@ export function initCoreWatcher(): void {
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()

View File

@ -105,14 +105,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 })
}
}

View File

@ -260,31 +260,29 @@ export async function restartAsAdmin(forTun: boolean = true): Promise<void> {
const args = process.argv.slice(1)
const restartArgs = forTun ? [...args, '--admin-restart-for-tun'] : args
try {
const escapedExePath = exePath.replace(/'/g, "''")
const argsString = restartArgs.map((arg) => arg.replace(/'/g, "''")).join("', '")
const escapedExePath = exePath.replace(/'/g, "''")
const argsString = restartArgs.map((arg) => arg.replace(/'/g, "''")).join("', '")
const command =
restartArgs.length > 0
? `powershell -NoProfile -Command "Start-Process -FilePath '${escapedExePath}' -ArgumentList '${argsString}' -Verb RunAs"`
: `powershell -NoProfile -Command "Start-Process -FilePath '${escapedExePath}' -Verb RunAs"`
const command =
restartArgs.length > 0
? `powershell -NoProfile -Command "Start-Process -FilePath '${escapedExePath}' -ArgumentList '${argsString}' -Verb RunAs -Wait:$false; exit 0"`
: `powershell -NoProfile -Command "Start-Process -FilePath '${escapedExePath}' -Verb RunAs -Wait:$false; exit 0"`
managerLogger.info('Restarting as administrator with command', command)
managerLogger.info('Restarting as administrator with command', command)
return new Promise((resolve, reject) => {
exec(command, { windowsHide: true }, (error, _stdout, stderr) => {
if (error) {
managerLogger.error('PowerShell execution error', error)
managerLogger.error('stderr', stderr)
} else {
managerLogger.info('PowerShell command executed successfully')
reject(new Error(`Failed to restart as administrator: ${error.message}`))
return
}
managerLogger.info('PowerShell command executed successfully, quitting app')
setTimeout(() => app.quit(), 500)
resolve()
})
app.quit()
} catch (error) {
managerLogger.error('Failed to restart as administrator', error)
throw new Error(`Failed to restart as administrator: ${error}`)
}
})
}
export async function requestTunPermissions(): Promise<void> {

View File

@ -13,7 +13,7 @@ async function updateProfile(id: string): Promise<void> {
}
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)) {

View File

@ -39,7 +39,7 @@ async function createFloatingWindow(): Promise<void> {
closable: safeMode,
backgroundColor: safeMode ? '#ffffff' : useCompatMode ? '#f0f0f0' : '#00000000',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
preload: join(__dirname, '../preload/index.cjs'),
spellcheck: false,
sandbox: false,
nodeIntegration: false,

View File

@ -23,7 +23,7 @@ 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> {

View File

@ -528,8 +528,10 @@ export function updateTrayIconImmediate(sysProxyEnabled: boolean, tunEnabled: bo
const status = calculateTrayIconStatus(sysProxyEnabled, tunEnabled)
const iconPaths = getIconPaths()
getAppConfig().then(({ disableTrayIconColor = false }) => {
getAppConfig().then(({ disableTrayIconColor = false, showTraffic = false }) => {
if (!tray) return
// macOS 开启流量显示时,由 trayIconUpdate 负责图标更新
if (process.platform === 'darwin' && showTraffic) return
const iconPath = disableTrayIconColor ? iconPaths.white : iconPaths[status]
try {
if (process.platform === 'darwin') {
@ -549,7 +551,9 @@ export function updateTrayIconImmediate(sysProxyEnabled: boolean, tunEnabled: bo
export async function updateTrayIcon(): Promise<void> {
if (!tray) return
const { disableTrayIconColor = false } = await getAppConfig()
const { disableTrayIconColor = false, showTraffic = false } = await getAppConfig()
// macOS 开启流量显示时,由 trayIconUpdate 负责图标更新
if (process.platform === 'darwin' && showTraffic) return
const status = await getTrayIconStatus()
const iconPaths = getIconPaths()
const iconPath = disableTrayIconColor ? iconPaths.white : iconPaths[status]

View File

@ -25,6 +25,25 @@ export interface Response<T = unknown> {
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
@ -45,27 +64,17 @@ export async function request<T = unknown>(
} = options
return new Promise((resolve, reject) => {
let sessionToUse: Electron.Session | undefined = session.defaultSession
let tempPartition: string | null = null
let sessionToUse: Electron.Session = session.defaultSession
// Set up proxy if specified
const setupProxy = async (): Promise<void> => {
if (proxy) {
// Create temporary session partition to avoid affecting global proxy settings
tempPartition = `temp-request-${Date.now()}-${Math.random()}`
sessionToUse = session.fromPartition(tempPartition, { cache: false })
const proxyUrl = `${proxy.protocol}://${proxy.host}:${proxy.port}`
await sessionToUse.setProxy({ proxyRules: proxyUrl })
sessionToUse = await getProxySession(proxyUrl)
}
}
const cleanup = (): void => {
// Cleanup temporary session if created
if (tempPartition) {
// Note: Electron doesn't provide session.destroy(), but temporary sessions
// will be garbage collected when no longer referenced
sessionToUse = undefined
}
}
setupProxy()

View File

@ -178,16 +178,22 @@ async function initFiles(): Promise<void> {
const sourcePath = path.join(resourcesFilesDir(), file)
if (!existsSync(sourcePath)) return
const targets = [
path.join(mihomoWorkDir(), file),
path.join(mihomoTestDir(), file)
]
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) {
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') {
await initLogger.warn(`Skipping ${file}: file is in use`)
return
}
throw error
}
})
)

View File

@ -39,7 +39,7 @@ export async function createWindow(): Promise<void> {
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon: icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
preload: join(__dirname, '../preload/index.cjs'),
spellcheck: false,
sandbox: false,
devTools: true

View File

@ -22,3 +22,7 @@ export function triggerAutoProxy(enable: boolean, url: string): void
export function getSystemProxy(): SysproxyInfo
export function getAutoProxy(): AutoproxyInfo
export function setSystemProxy(proxy: SysproxyInfo): void
export function setAutoProxy(proxy: AutoproxyInfo): void

View File

@ -1,5 +1,5 @@
const { existsSync, readFileSync } = require('fs')
const { join } = require('path')
const { join, dirname } = require('path')
const { platform, arch } = process
@ -24,6 +24,7 @@ function getBindingName() {
switch (platform) {
case 'win32':
if (arch === 'x64') return 'sysproxy.win32-x64-msvc.node'
if (arch === 'ia32') return 'sysproxy.win32-ia32-msvc.node'
if (arch === 'arm64') return 'sysproxy.win32-arm64-msvc.node'
break
case 'darwin':
@ -43,13 +44,45 @@ function getBindingName() {
throw new Error(`Unsupported platform: ${platform}-${arch}`)
}
function getResourcesPath() {
// 开发环境:优先使用 process.cwd()
const cwd = process.cwd()
if (existsSync(join(cwd, 'extra', 'sidecar'))) {
return cwd
}
// Electron 打包后的路径
if (process.resourcesPath && existsSync(join(process.resourcesPath, 'sidecar'))) {
return process.resourcesPath
}
// 备选:使用 app.getAppPath() (Electron 特有)
try {
const { app } = require('electron')
const appPath = app.getAppPath()
if (existsSync(join(appPath, 'extra', 'sidecar'))) {
return appPath
}
} catch {}
// 备选:从 __dirname 向上查找
let currentDir = __dirname
while (currentDir !== dirname(currentDir)) {
if (existsSync(join(currentDir, 'extra', 'sidecar'))) {
return currentDir
}
currentDir = dirname(currentDir)
}
return cwd
}
function loadBinding() {
const bindingName = getBindingName()
const resourcesPath = getResourcesPath()
// 查找项目根目录的 sidecar
let currentDir = __dirname
while (currentDir !== require('path').dirname(currentDir)) {
const sidecarPath = join(currentDir, 'sidecar', bindingName)
const searchPaths = [
join(resourcesPath, 'sidecar', bindingName),
join(resourcesPath, 'extra', 'sidecar', bindingName)
]
for (const sidecarPath of searchPaths) {
if (existsSync(sidecarPath)) {
try {
nativeBinding = require(sidecarPath)
@ -58,7 +91,6 @@ function loadBinding() {
loadError = e
}
}
currentDir = require('path').dirname(currentDir)
}
if (loadError) {
@ -73,3 +105,5 @@ module.exports.triggerManualProxy = binding.triggerManualProxy
module.exports.triggerAutoProxy = binding.triggerAutoProxy
module.exports.getSystemProxy = binding.getSystemProxy
module.exports.getAutoProxy = binding.getAutoProxy
module.exports.setSystemProxy = binding.setSystemProxy
module.exports.setAutoProxy = binding.setAutoProxy

View File

@ -2,6 +2,7 @@
"name": "sysproxy-rs",
"version": "0.4.0",
"description": "System proxy library for Node.js",
"type": "commonjs",
"main": "index.js",
"types": "index.d.ts",
"license": "MIT"

View File

@ -60,6 +60,8 @@ const ConnCard: React.FC<Props> = (props) => {
const currentDownloadRef = useRef<number | undefined>(undefined)
const hasShowTrafficRef = useRef(false)
const drawingRef = useRef(false)
// 保存待绘制的流量数据,避免跳过更新导致图标闪烁
const pendingTrafficRef = useRef<{ up: number; down: number } | null>(null)
// Chart.js 配置
const chartData = useMemo(() => {
@ -138,21 +140,30 @@ const ConnCard: React.FC<Props> = (props) => {
data.push(info.up + info.down)
return data
})
if (platform === 'darwin' && showTraffic) {
if (drawingRef.current) return
drawingRef.current = true
try {
await drawSvg(info.up, info.down, currentUploadRef, currentDownloadRef)
hasShowTrafficRef.current = true
} catch {
// ignore
} finally {
drawingRef.current = false
if (platform === 'darwin') {
if (showTraffic) {
// 保存最新流量数据,确保绘制完成后使用最新值
pendingTrafficRef.current = { up: info.up, down: info.down }
if (drawingRef.current) return
drawingRef.current = true
try {
// 循环处理待绘制数据,直到没有新数据
while (pendingTrafficRef.current) {
const { up, down } = pendingTrafficRef.current
pendingTrafficRef.current = null
await drawSvg(up, down, currentUploadRef, currentDownloadRef)
}
hasShowTrafficRef.current = true
} catch {
// ignore
} finally {
drawingRef.current = false
}
} else if (hasShowTrafficRef.current) {
// 只在从 showTraffic=true 切换到 false 时恢复一次原始图标
window.electron.ipcRenderer.send('trayIconUpdate', trayIconBase64)
hasShowTrafficRef.current = false
}
} else {
if (!hasShowTrafficRef.current) return
window.electron.ipcRenderer.send('trayIconUpdate', trayIconBase64)
hasShowTrafficRef.current = false
}
},
[showTraffic]

View File

@ -3,7 +3,7 @@ import { toast } from '@renderer/components/base/toast'
import BorderSwitch from '@renderer/components/base/border-swtich'
import { useLocation, useNavigate } from 'react-router-dom'
import { useAppConfig } from '@renderer/hooks/use-app-config'
import { triggerSysProxy, updateTrayIcon, updateTrayIconImmediate } from '@renderer/utils/ipc'
import { triggerSysProxy, updateTrayIconImmediate } from '@renderer/utils/ipc'
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
import { AiOutlineGlobal } from 'react-icons/ai'
import React from 'react'
@ -50,7 +50,6 @@ const SysproxySwitcher: React.FC<Props> = (props) => {
window.electron.ipcRenderer.send('updateFloatingWindow')
window.electron.ipcRenderer.send('updateTrayMenu')
await updateTrayIcon()
} catch (e) {
await patchAppConfig({ sysProxy: { enable: previousState } })
// 回滚图标

View File

@ -3,7 +3,7 @@ import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-c
import BorderSwitch from '@renderer/components/base/border-swtich'
import { TbDeviceIpadHorizontalBolt } from 'react-icons/tb'
import { useLocation, useNavigate } from 'react-router-dom'
import { restartCore, updateTrayIcon, updateTrayIconImmediate } from '@renderer/utils/ipc'
import { restartCore, updateTrayIconImmediate } from '@renderer/utils/ipc'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import React from 'react'
@ -99,7 +99,6 @@ const TunSwitcher: React.FC<Props> = (props) => {
await restartCore()
window.electron.ipcRenderer.send('updateFloatingWindow')
window.electron.ipcRenderer.send('updateTrayMenu')
await updateTrayIcon()
}
if (iconOnly) {

View File

@ -130,11 +130,11 @@ const Mihomo: React.FC = () => {
const { 'store-selected': storeSelected, 'store-fake-ip': storeFakeIp } = profile
const [isManualPortChange, setIsManualPortChange] = useState(false)
const [mixedPortInput, setMixedPortInput] = useState(showMixedPort || mixedPort)
const [socksPortInput, setSocksPortInput] = useState(showSocksPort || socksPort)
const [httpPortInput, setHttpPortInput] = useState(showHttpPort || httpPort)
const [redirPortInput, setRedirPortInput] = useState(showRedirPort || redirPort)
const [tproxyPortInput, setTproxyPortInput] = useState(showTproxyPort || tproxyPort)
const [mixedPortInput, setMixedPortInput] = useState(showMixedPort ?? mixedPort)
const [socksPortInput, setSocksPortInput] = useState(showSocksPort ?? socksPort)
const [httpPortInput, setHttpPortInput] = useState(showHttpPort ?? httpPort)
const [redirPortInput, setRedirPortInput] = useState(showRedirPort ?? redirPort)
const [tproxyPortInput, setTproxyPortInput] = useState(showTproxyPort ?? tproxyPort)
const [externalControllerInput, setExternalControllerInput] = useState(externalController)
const [secretInput, setSecretInput] = useState(secret)
const [lanAllowedIpsInput, setLanAllowedIpsInput] = useState(lanAllowedIps)
@ -702,7 +702,7 @@ const Mihomo: React.FC = () => {
size="sm"
type="number"
className="w-[100px]"
value={showMixedPort?.toString()}
value={(showMixedPort ?? mixedPort ?? '').toString()}
max={65535}
min={0}
onValueChange={(v) => {
@ -763,7 +763,7 @@ const Mihomo: React.FC = () => {
size="sm"
type="number"
className="w-[100px]"
value={showSocksPort?.toString()}
value={(showSocksPort ?? socksPort ?? '').toString()}
max={65535}
min={0}
onValueChange={(v) => {
@ -796,7 +796,7 @@ const Mihomo: React.FC = () => {
onValueChange={(value) => {
patchAppConfig({ enableSocksPort: value })
if (value) {
const port = appConfig?.showSocksPort || socksPort
const port = appConfig?.showSocksPort ?? socksPort
onChangeNeedRestart({ 'socks-port': port })
} else {
onChangeNeedRestart({ 'socks-port': 0 })
@ -824,7 +824,7 @@ const Mihomo: React.FC = () => {
size="sm"
type="number"
className="w-[100px]"
value={showHttpPort?.toString()}
value={(showHttpPort ?? httpPort ?? '').toString()}
max={65535}
min={0}
onValueChange={(v) => {
@ -857,7 +857,7 @@ const Mihomo: React.FC = () => {
onValueChange={(value) => {
patchAppConfig({ enableHttpPort: value })
if (value) {
const port = appConfig?.showHttpPort || httpPort
const port = appConfig?.showHttpPort ?? httpPort
onChangeNeedRestart({ port: port })
} else {
onChangeNeedRestart({ port: 0 })
@ -886,7 +886,7 @@ const Mihomo: React.FC = () => {
size="sm"
type="number"
className="w-[100px]"
value={showRedirPort?.toString()}
value={(showRedirPort ?? redirPort ?? '').toString()}
max={65535}
min={0}
onValueChange={(v) => {
@ -919,7 +919,7 @@ const Mihomo: React.FC = () => {
onValueChange={(value) => {
patchAppConfig({ enableRedirPort: value })
if (value) {
const port = appConfig?.showRedirPort || redirPort
const port = appConfig?.showRedirPort ?? redirPort
onChangeNeedRestart({ 'redir-port': port })
} else {
onChangeNeedRestart({ 'redir-port': 0 })
@ -949,7 +949,7 @@ const Mihomo: React.FC = () => {
size="sm"
type="number"
className="w-[100px]"
value={showTproxyPort?.toString()}
value={(showTproxyPort ?? tproxyPort ?? '').toString()}
max={65535}
min={0}
onValueChange={(v) => {
@ -982,7 +982,7 @@ const Mihomo: React.FC = () => {
onValueChange={(value) => {
patchAppConfig({ enableTproxyPort: value })
if (value) {
const port = appConfig?.showTproxyPort || tproxyPort
const port = appConfig?.showTproxyPort ?? tproxyPort
onChangeNeedRestart({ 'tproxy-port': port })
} else {
onChangeNeedRestart({ 'tproxy-port': 0 })