Compare commits

..

No commits in common. "9963f674330866525c8a2e3446f115070a27ce92" and "0e0b337a8b983e37e5453d4ae86f3c84d0d5b97c" have entirely different histories.

13 changed files with 545 additions and 596 deletions

View File

@ -31,7 +31,7 @@
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"sysproxy-rs": "file:src/native/sysproxy", "@mihomo-party/sysproxy": "^2.0.8",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"axios": "^1.13.2", "axios": "^1.13.2",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",

90
pnpm-lock.yaml generated
View File

@ -11,6 +11,9 @@ importers:
'@electron-toolkit/utils': '@electron-toolkit/utils':
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0(electron@37.10.0) version: 4.0.0(electron@37.10.0)
'@mihomo-party/sysproxy':
specifier: ^2.0.8
version: 2.0.8
adm-zip: adm-zip:
specifier: ^0.5.16 specifier: ^0.5.16
version: 0.5.16 version: 0.5.16
@ -35,9 +38,6 @@ importers:
iconv-lite: iconv-lite:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
sysproxy-rs:
specifier: file:src/native/sysproxy
version: file:src/native/sysproxy
webdav: webdav:
specifier: ^5.8.0 specifier: ^5.8.0
version: 5.8.0 version: 5.8.0
@ -1405,6 +1405,54 @@ packages:
resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
'@mihomo-party/sysproxy-darwin-arm64@2.0.8':
resolution: {integrity: sha512-4bSqsjEkmtXzgr8zrSUiNmOdlfRDnkFoXICfJqH7ZlM+4L6n74zrm6perFP0NHPpn/oZO97QXGxIJUQSNhFDrw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@mihomo-party/sysproxy-darwin-x64@2.0.8':
resolution: {integrity: sha512-sIDzG7yyQZu+DKQ8X1MeYubdEqXSDjzYjVi+5rVZG/jfLlucS9QZNNiXyoTTDUD5cGRcqv1gYNRynd2Csewesg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@mihomo-party/sysproxy-linux-arm64-gnu@2.0.8':
resolution: {integrity: sha512-weKk+KcB4lghEj3z15x9FSyla3PT3uLIEU4l4LE4RqhzxgkbJmOt7Wu+ofx4/1k8g8OwwGXIucNgYsV0qpnZQg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@mihomo-party/sysproxy-linux-x64-gnu@2.0.8':
resolution: {integrity: sha512-wLt63mztsnZoGFUKxzizRRRd5qAtINg+tB2zdhOnr+0E9TaKLGxZnhYm+Nk8tAB1EBvqjmTWsJG9MDHikh2agg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@mihomo-party/sysproxy-win32-arm64-msvc@2.0.8':
resolution: {integrity: sha512-+Mxkw8d3rD6sbFZjjZ18kfx1/WrWXOVlpKd8k3Gdf4LUg7nW8vr64Eaxvjxwcw9AQ1Bu61SHEtvNZfu7woCc1w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@mihomo-party/sysproxy-win32-ia32-msvc@2.0.8':
resolution: {integrity: sha512-xOVagbGu21MGzEMidpgMoQGRHY0V1EFdDKb+ZhPPnoIbFhECBRR9fQK7lENV6e7S41ppNbg1Rja4J94VpXdvZA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@mihomo-party/sysproxy-win32-x64-msvc@2.0.8':
resolution: {integrity: sha512-AIgCFoExX36BgXN8sQyf0G99wrObFO0LGBzEFs9OsS2cg8bPkpt63XkAkNcGVxlqJD4WLzsS1GhbsL3qzo64DQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@mihomo-party/sysproxy@2.0.8':
resolution: {integrity: sha512-tCnkDL4UjbUPvFvFubswmWxz56f+gTsYDpv1ULke1YDEZN7aTSREgC3K+Ge7JjZj2jUZYU1lEYuhFgeFz6+W6w==}
engines: {node: '>= 10'}
'@npmcli/fs@2.1.2': '@npmcli/fs@2.1.2':
resolution: {integrity: sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==} resolution: {integrity: sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@ -4698,9 +4746,6 @@ packages:
resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
sysproxy-rs@file:src/native/sysproxy:
resolution: {directory: src/native/sysproxy, type: directory}
tailwind-merge@3.4.0: tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
@ -6724,6 +6769,37 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@mihomo-party/sysproxy-darwin-arm64@2.0.8':
optional: true
'@mihomo-party/sysproxy-darwin-x64@2.0.8':
optional: true
'@mihomo-party/sysproxy-linux-arm64-gnu@2.0.8':
optional: true
'@mihomo-party/sysproxy-linux-x64-gnu@2.0.8':
optional: true
'@mihomo-party/sysproxy-win32-arm64-msvc@2.0.8':
optional: true
'@mihomo-party/sysproxy-win32-ia32-msvc@2.0.8':
optional: true
'@mihomo-party/sysproxy-win32-x64-msvc@2.0.8':
optional: true
'@mihomo-party/sysproxy@2.0.8':
optionalDependencies:
'@mihomo-party/sysproxy-darwin-arm64': 2.0.8
'@mihomo-party/sysproxy-darwin-x64': 2.0.8
'@mihomo-party/sysproxy-linux-arm64-gnu': 2.0.8
'@mihomo-party/sysproxy-linux-x64-gnu': 2.0.8
'@mihomo-party/sysproxy-win32-arm64-msvc': 2.0.8
'@mihomo-party/sysproxy-win32-ia32-msvc': 2.0.8
'@mihomo-party/sysproxy-win32-x64-msvc': 2.0.8
'@npmcli/fs@2.1.2': '@npmcli/fs@2.1.2':
dependencies: dependencies:
'@gar/promisify': 1.1.3 '@gar/promisify': 1.1.3
@ -10859,8 +10935,6 @@ snapshots:
dependencies: dependencies:
'@pkgr/core': 0.2.9 '@pkgr/core': 0.2.9
sysproxy-rs@file:src/native/sysproxy: {}
tailwind-merge@3.4.0: {} tailwind-merge@3.4.0: {}
tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18): tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18):

View File

@ -55,42 +55,37 @@ async function getWebDAVClient(): Promise<WebDAVContext> {
return { client, webdavDir, webdavMaxBackups } return { client, webdavDir, webdavMaxBackups }
} }
function createBackupZip(): AdmZip {
const zip = new AdmZip()
const files = [
appConfigPath(),
controledMihomoConfigPath(),
profileConfigPath(),
overrideConfigPath()
]
const folders = [
{ path: themesDir(), name: 'themes' },
{ path: profilesDir(), name: 'profiles' },
{ path: overrideDir(), name: 'override' },
{ path: rulesDir(), name: 'rules' },
{ path: subStoreDir(), name: 'substore' }
]
for (const file of files) {
if (existsSync(file)) {
zip.addLocalFile(file)
}
}
for (const { path, name } of folders) {
if (existsSync(path)) {
zip.addLocalFolder(path, name)
}
}
return zip
}
export async function webdavBackup(): Promise<boolean> { export async function webdavBackup(): Promise<boolean> {
const { client, webdavDir, webdavMaxBackups } = await getWebDAVClient() const { client, webdavDir, webdavMaxBackups } = await getWebDAVClient()
const zip = createBackupZip() const zip = new AdmZip()
if (existsSync(appConfigPath())) {
zip.addLocalFile(appConfigPath())
}
if (existsSync(controledMihomoConfigPath())) {
zip.addLocalFile(controledMihomoConfigPath())
}
if (existsSync(profileConfigPath())) {
zip.addLocalFile(profileConfigPath())
}
if (existsSync(overrideConfigPath())) {
zip.addLocalFile(overrideConfigPath())
}
if (existsSync(themesDir())) {
zip.addLocalFolder(themesDir(), 'themes')
}
if (existsSync(profilesDir())) {
zip.addLocalFolder(profilesDir(), 'profiles')
}
if (existsSync(overrideDir())) {
zip.addLocalFolder(overrideDir(), 'override')
}
if (existsSync(rulesDir())) {
zip.addLocalFolder(rulesDir(), 'rules')
}
if (existsSync(subStoreDir())) {
zip.addLocalFolder(subStoreDir(), 'substore')
}
const date = new Date() const date = new Date()
const zipFileName = `${process.platform}_${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip` const zipFileName = `${process.platform}_${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip`
@ -219,7 +214,34 @@ export async function reinitScheduler(): Promise<void> {
* *
*/ */
export async function exportLocalBackup(): Promise<boolean> { export async function exportLocalBackup(): Promise<boolean> {
const zip = createBackupZip() const zip = new AdmZip()
if (existsSync(appConfigPath())) {
zip.addLocalFile(appConfigPath())
}
if (existsSync(controledMihomoConfigPath())) {
zip.addLocalFile(controledMihomoConfigPath())
}
if (existsSync(profileConfigPath())) {
zip.addLocalFile(profileConfigPath())
}
if (existsSync(overrideConfigPath())) {
zip.addLocalFile(overrideConfigPath())
}
if (existsSync(themesDir())) {
zip.addLocalFolder(themesDir(), 'themes')
}
if (existsSync(profilesDir())) {
zip.addLocalFolder(profilesDir(), 'profiles')
}
if (existsSync(overrideDir())) {
zip.addLocalFolder(overrideDir(), 'override')
}
if (existsSync(subStoreDir())) {
zip.addLocalFolder(subStoreDir(), 'substore')
}
if (existsSync(rulesDir())) {
zip.addLocalFolder(rulesDir(), 'rules')
}
const date = new Date() const date = new Date()
const zipFileName = `clash-party-backup-${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip` const zipFileName = `clash-party-backup-${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip`

View File

@ -1,8 +1,10 @@
import { triggerAutoProxy, triggerManualProxy } from 'sysproxy-rs' import { triggerAutoProxy, triggerManualProxy } from '@mihomo-party/sysproxy'
import { getAppConfig, getControledMihomoConfig } from '../config' import { getAppConfig, getControledMihomoConfig } from '../config'
import { pacPort, startPacServer, stopPacServer } from '../resolve/server' import { pacPort, startPacServer, stopPacServer } from '../resolve/server'
import { promisify } from 'util' import { promisify } from 'util'
import { exec } from 'child_process' import { exec, execFile } from 'child_process'
import path from 'path'
import { resourcesFilesDir } from '../utils/dirs'
import { net } from 'electron' import { net } from 'electron'
import axios from 'axios' import axios from 'axios'
import fs from 'fs' import fs from 'fs'
@ -74,51 +76,87 @@ async function enableSysProxy(): Promise<void> {
const { sysProxy } = await getAppConfig() const { sysProxy } = await getAppConfig()
const { mode, host, bypass = defaultBypass } = sysProxy const { mode, host, bypass = defaultBypass } = sysProxy
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig() const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
const proxyHost = host || '127.0.0.1' const execFilePromise = promisify(execFile)
switch (mode || 'manual') {
if (process.platform === 'darwin') { case 'auto': {
// macOS 需要 helper 提权 if (process.platform === 'win32') {
if (mode === 'auto') { 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(() => await helperRequest(() =>
axios.post( axios.post(
'http://localhost/pac', 'http://localhost/pac',
{ url: `http://${proxyHost}:${pacPort}/pac` }, { url: `http://${host || '127.0.0.1'}:${pacPort}/pac` },
{ socketPath: helperSocketPath } {
socketPath: helperSocketPath
}
) )
) )
} else { } else {
triggerAutoProxy(true, `http://${host || '127.0.0.1'}:${pacPort}/pac`)
}
break
}
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(() => await helperRequest(() =>
axios.post( axios.post(
'http://localhost/global', 'http://localhost/global',
{ host: proxyHost, port: port.toString(), bypass: bypass.join(',') }, { host: host || '127.0.0.1', port: port.toString(), bypass: bypass.join(',') },
{ socketPath: helperSocketPath } {
) socketPath: helperSocketPath
)
} }
)
)
} else { } else {
// Windows / Linux 直接使用 sysproxy-rs triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(','))
if (mode === 'auto') { }
triggerAutoProxy(true, `http://${proxyHost}:${pacPort}/pac`) break
} else {
triggerManualProxy(true, proxyHost, port, bypass.join(','))
} }
} }
} }
async function disableSysProxy(): Promise<void> { async function disableSysProxy(): Promise<void> {
await stopPacServer() await stopPacServer()
const execFilePromise = promisify(execFile)
if (process.platform === 'darwin') { if (process.platform === 'win32') {
try {
await execFilePromise(path.join(resourcesFilesDir(), 'sysproxy.exe'), ['set', '1'])
} catch {
triggerAutoProxy(false, '')
triggerManualProxy(false, '', 0, '')
}
} else if (process.platform === 'darwin') {
await helperRequest(() => await helperRequest(() =>
axios.get('http://localhost/off', { socketPath: helperSocketPath }) axios.get('http://localhost/off', {
socketPath: helperSocketPath
})
) )
} else { } else {
// Windows / Linux 直接使用 sysproxy-rs
triggerAutoProxy(false, '') triggerAutoProxy(false, '')
triggerManualProxy(false, '', 0, '') triggerManualProxy(false, '', 0, '')
} }
} }
// Helper function to check if socket file exists
function isSocketFileExists(): boolean { function isSocketFileExists(): boolean {
try { try {
return fs.existsSync(helperSocketPath) return fs.existsSync(helperSocketPath)
@ -127,6 +165,7 @@ function isSocketFileExists(): boolean {
} }
} }
// Check if helper process is running (no admin privileges needed)
async function isHelperRunning(): Promise<boolean> { async function isHelperRunning(): Promise<boolean> {
try { try {
const execPromise = promisify(exec) const execPromise = promisify(exec)
@ -137,6 +176,7 @@ async function isHelperRunning(): Promise<boolean> {
} }
} }
// Start or restart helper service via launchctl
async function startHelperService(): Promise<void> { async function startHelperService(): Promise<void> {
const execPromise = promisify(exec) const execPromise = promisify(exec)
const shell = `launchctl kickstart -k system/party.mihomo.helper` const shell = `launchctl kickstart -k system/party.mihomo.helper`
@ -145,6 +185,7 @@ async function startHelperService(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 1500)) await new Promise((resolve) => setTimeout(resolve, 1500))
} }
// Send signal to recreate socket (only if process is running)
async function requestSocketRecreation(): Promise<void> { async function requestSocketRecreation(): Promise<void> {
try { try {
const execPromise = promisify(exec) const execPromise = promisify(exec)
@ -158,6 +199,7 @@ async function requestSocketRecreation(): Promise<void> {
} }
} }
// Wrapper function for helper requests with auto-retry on socket issues
async function helperRequest(requestFn: () => Promise<unknown>, maxRetries = 2): Promise<unknown> { async function helperRequest(requestFn: () => Promise<unknown>, maxRetries = 2): Promise<unknown> {
let lastError: Error | null = null let lastError: Error | null = null

View File

@ -47,6 +47,7 @@ import { initLogger } from './logger'
let isInitBasicCompleted = false let isInitBasicCompleted = false
// 安全错误处理
export function safeShowErrorBox(titleKey: string, message: string): void { export function safeShowErrorBox(titleKey: string, message: string): void {
let title: string let title: string
try { try {
@ -83,9 +84,12 @@ async function fixDataDirPermissions(): Promise<void> {
} }
} }
// 比较修改 geodata 文件修改时间
async function isSourceNewer(sourcePath: string, targetPath: string): Promise<boolean> { async function isSourceNewer(sourcePath: string, targetPath: string): Promise<boolean> {
try { try {
const [sourceStats, targetStats] = await Promise.all([stat(sourcePath), stat(targetPath)]) const sourceStats = await stat(sourcePath)
const targetStats = await stat(targetPath)
return sourceStats.mtime > targetStats.mtime return sourceStats.mtime > targetStats.mtime
} catch { } catch {
return true return true
@ -95,6 +99,7 @@ async function isSourceNewer(sourcePath: string, targetPath: string): Promise<bo
async function initDirs(): Promise<void> { async function initDirs(): Promise<void> {
await fixDataDirPermissions() await fixDataDirPermissions()
// 按依赖顺序创建目录
const dirsToCreate = [ const dirsToCreate = [
dataDir(), dataDir(),
themesDir(), themesDir(),
@ -107,13 +112,16 @@ async function initDirs(): Promise<void> {
subStoreDir() subStoreDir()
] ]
await Promise.all( for (const dir of dirsToCreate) {
dirsToCreate.map(async (dir) => { try {
if (!existsSync(dir)) { if (!existsSync(dir)) {
await mkdir(dir, { recursive: true }) await mkdir(dir, { recursive: true })
} }
}) } catch (error) {
) await initLogger.error(`Failed to create directory ${dir}`, error)
throw new Error(`Failed to create directory ${dir}: ${error}`)
}
}
} }
async function initConfig(): Promise<void> { async function initConfig(): Promise<void> {
@ -129,120 +137,97 @@ async function initConfig(): Promise<void> {
} }
] ]
await Promise.all( for (const config of configs) {
configs.map(async (config) => { try {
if (!existsSync(config.path)) { if (!existsSync(config.path)) {
await writeFile(config.path, stringify(config.content)) await writeFile(config.path, stringify(config.content))
} }
}) } catch (error) {
) await initLogger.error(`Failed to create ${config.name} at ${config.path}`, error)
throw new Error(`Failed to create ${config.name}: ${error}`)
} }
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, 500))
} catch {
// 忽略错误
} }
} }
async function initFiles(): Promise<void> { async function initFiles(): Promise<void> {
await killOldMihomoProcesses() const copy = async (file: string): Promise<void> => {
const targetPath = path.join(mihomoWorkDir(), file)
const copyFile = async (file: string): Promise<void> => { const testTargetPath = path.join(mihomoTestDir(), file)
const sourcePath = path.join(resourcesFilesDir(), file) const sourcePath = path.join(resourcesFilesDir(), file)
if (!existsSync(sourcePath)) return
const targets = [ try {
path.join(mihomoWorkDir(), file), // 检查是否需要复制
path.join(mihomoTestDir(), file) if (existsSync(sourcePath)) {
] const shouldCopyToWork =
!existsSync(targetPath) || (await isSourceNewer(sourcePath, targetPath))
await Promise.all( if (shouldCopyToWork) {
targets.map(async (targetPath) => {
const shouldCopy = !existsSync(targetPath) || (await isSourceNewer(sourcePath, targetPath))
if (shouldCopy) {
await cp(sourcePath, targetPath, { recursive: true, force: true }) await cp(sourcePath, targetPath, { recursive: true, force: true })
} }
})
)
} }
if (existsSync(sourcePath)) {
const files = [ const shouldCopyToTest =
'country.mmdb', !existsSync(testTargetPath) || (await isSourceNewer(sourcePath, testTargetPath))
'geoip.metadb', if (shouldCopyToTest) {
'geoip.dat', await cp(sourcePath, testTargetPath, { recursive: true, force: true })
'geosite.dat', }
'ASN.mmdb', }
'sub-store.bundle.cjs', } catch (error) {
'sub-store-frontend' await initLogger.error(`Failed to copy ${file}`, error)
] if (['country.mmdb', 'geoip.dat', 'geosite.dat'].includes(file)) {
throw new Error(`Failed to copy critical file ${file}: ${error}`)
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}`)
} }
} }
} }
// 确保工作目录存在
if (!existsSync(mihomoWorkDir())) {
await mkdir(mihomoWorkDir(), { recursive: true })
}
if (!existsSync(mihomoTestDir())) {
await mkdir(mihomoTestDir(), { recursive: true })
}
await Promise.all([
copy('country.mmdb'),
copy('geoip.metadb'),
copy('geoip.dat'),
copy('geosite.dat'),
copy('ASN.mmdb'),
copy('sub-store.bundle.cjs'),
copy('sub-store-frontend')
])
} }
async function cleanup(): Promise<void> { async function cleanup(): Promise<void> {
const [dataFiles, logFiles] = await Promise.all([readdir(dataDir()), readdir(logDir())]) // update cache
const files = await readdir(dataDir())
// 清理更新缓存 for (const file of files) {
const cacheExtensions = ['.exe', '.pkg', '.7z'] if (file.endsWith('.exe') || file.endsWith('.pkg') || file.endsWith('.7z')) {
const cacheCleanup = dataFiles try {
.filter((file) => cacheExtensions.some((ext) => file.endsWith(ext))) await rm(path.join(dataDir(), file))
.map((file) => rm(path.join(dataDir(), file)).catch(() => {})) } catch {
// ignore
// 清理过期日志 }
}
}
// logs
const { maxLogDays = 7 } = await getAppConfig() const { maxLogDays = 7 } = await getAppConfig()
const maxAge = maxLogDays * 24 * 60 * 60 * 1000 const logs = await readdir(logDir())
const datePattern = /^\d{4}-\d{2}-\d{2}/ const datePattern = /^\d{4}-\d{2}-\d{2}/
for (const log of logs) {
const logCleanup = logFiles
.filter((log) => {
const match = log.match(datePattern) const match = log.match(datePattern)
if (!match) return false if (!match) continue
const date = new Date(match[0]) const date = new Date(match[0])
return !isNaN(date.getTime()) && Date.now() - date.getTime() > maxAge if (isNaN(date.getTime())) continue
}) const diff = Date.now() - date.getTime()
.map((log) => rm(path.join(logDir(), log)).catch(() => {})) if (diff > maxLogDays * 24 * 60 * 60 * 1000) {
try {
await Promise.all([...cacheCleanup, ...logCleanup]) await rm(path.join(logDir(), log))
} catch {
// ignore
}
}
}
} }
async function migrateSubStoreFiles(): Promise<void> { async function migrateSubStoreFiles(): Promise<void> {
@ -258,97 +243,111 @@ async function migrateSubStoreFiles(): Promise<void> {
} }
} }
// 迁移:添加 substore 到侧边栏 async function migration(): Promise<void> {
async function migrateSiderOrder(): Promise<void> { const {
const { siderOrder = [], useSubStore = true } = await getAppConfig() 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,
tun
} = await getControledMihomoConfig()
// add substore sider card
if (useSubStore && !siderOrder.includes('substore')) { if (useSubStore && !siderOrder.includes('substore')) {
await patchAppConfig({ siderOrder: [...siderOrder, 'substore'] }) await patchAppConfig({ siderOrder: [...siderOrder, 'substore'] })
} }
// add default skip auth prefix
if (!skipAuthPrefixes) {
await patchControledMihomoConfig({ 'skip-auth-prefixes': ['127.0.0.1/32', '::1/128'] })
} else if (skipAuthPrefixes.length >= 1 && skipAuthPrefixes[0] === '127.0.0.1/32') {
const filteredPrefixes = skipAuthPrefixes.filter((ip) => ip !== '::1/128')
const newPrefixes = [filteredPrefixes[0], '::1/128', ...filteredPrefixes.slice(1)]
if (JSON.stringify(newPrefixes) !== JSON.stringify(skipAuthPrefixes)) {
await patchControledMihomoConfig({ 'skip-auth-prefixes': newPrefixes })
} }
}
// 迁移:修复 appTheme // add default authentication
async function migrateAppTheme(): Promise<void> { if (!authentication) {
const { appTheme = 'system' } = await getAppConfig() 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': [] })
}
// default tun device
if (!tun?.device || (process.platform === 'darwin' && tun.device === 'Mihomo')) {
const defaultDevice = process.platform === 'darwin' ? 'utun1500' : 'Mihomo'
await patchControledMihomoConfig({
tun: {
...tun,
device: defaultDevice
}
})
}
// remove custom app theme
if (!['system', 'light', 'dark'].includes(appTheme)) { if (!['system', 'light', 'dark'].includes(appTheme)) {
await patchAppConfig({ appTheme: 'system' }) await patchAppConfig({ appTheme: 'system' })
} }
} // change env type
// 迁移envType 字符串转数组
async function migrateEnvType(): Promise<void> {
const { envType } = await getAppConfig()
if (typeof envType === 'string') { if (typeof envType === 'string') {
await patchAppConfig({ envType: [envType] }) 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': '' })
} }
// 迁移:禁用托盘时必须显示悬浮窗
async function migrateTraySettings(): Promise<void> {
const { showFloatingWindow = false, disableTray = false } = await getAppConfig()
if (!showFloatingWindow && disableTray) { if (!showFloatingWindow && disableTray) {
await patchAppConfig({ disableTray: false }) await patchAppConfig({ disableTray: false })
} }
} // remove password
// 迁移:移除加密密码
async function migrateRemovePassword(): Promise<void> {
const { encryptedPassword } = await getAppConfig()
if (encryptedPassword) { if (encryptedPassword) {
await patchAppConfig({ encryptedPassword: undefined }) 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> {
await Promise.all([
migrateSiderOrder(),
migrateAppTheme(),
migrateEnvType(),
migrateTraySettings(),
migrateRemovePassword(),
migrateMihomoConfig()
])
}
function initDeeplink(): void { function initDeeplink(): void {
if (process.defaultApp) { if (process.defaultApp) {
if (process.argv.length >= 2) { if (process.argv.length >= 2) {
@ -361,8 +360,11 @@ function initDeeplink(): void {
} }
} }
// 基础初始化
export async function initBasic(): Promise<void> { export async function initBasic(): Promise<void> {
if (isInitBasicCompleted) return if (isInitBasicCompleted) {
return
}
await initDirs() await initDirs()
await initConfig() await initConfig()
@ -377,7 +379,6 @@ export async function initBasic(): Promise<void> {
export async function init(): Promise<void> { export async function init(): Promise<void> {
await startSubStoreFrontendServer() await startSubStoreFrontendServer()
await startSubStoreBackendServer() await startSubStoreBackendServer()
const { sysProxy } = await getAppConfig() const { sysProxy } = await getAppConfig()
try { try {
if (sysProxy.enable) { if (sysProxy.enable) {
@ -387,7 +388,7 @@ export async function init(): Promise<void> {
} catch { } catch {
// ignore // ignore
} }
await startSSIDCheck() await startSSIDCheck()
initDeeplink() initDeeplink()
} }

View File

@ -1,24 +0,0 @@
export interface SysproxyInfo {
enable: boolean
host: string
port: number
bypass: string
}
export interface AutoproxyInfo {
enable: boolean
url: string
}
export function triggerManualProxy(
enable: boolean,
host: string,
port: number,
bypass: string
): void
export function triggerAutoProxy(enable: boolean, url: string): void
export function getSystemProxy(): SysproxyInfo
export function getAutoProxy(): AutoproxyInfo

View File

@ -1,75 +0,0 @@
const { existsSync, readFileSync } = require('fs')
const { join } = require('path')
const { platform, arch } = process
let nativeBinding = null
let loadError = null
function isMusl() {
if (!process.report || typeof process.report.getReport !== 'function') {
try {
const lddPath = require('child_process').execSync('which ldd').toString().trim()
return readFileSync(lddPath, 'utf8').includes('musl')
} catch {
return true
}
} else {
const { glibcVersionRuntime } = process.report.getReport().header
return !glibcVersionRuntime
}
}
function getBindingName() {
switch (platform) {
case 'win32':
if (arch === 'x64') return 'sysproxy.win32-x64-msvc.node'
if (arch === 'arm64') return 'sysproxy.win32-arm64-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: ${platform}-${arch}`)
}
function loadBinding() {
const bindingName = getBindingName()
// 查找项目根目录的 extra/sidecar
let currentDir = __dirname
while (currentDir !== require('path').dirname(currentDir)) {
const sidecarPath = join(currentDir, 'extra', 'sidecar', bindingName)
if (existsSync(sidecarPath)) {
try {
nativeBinding = require(sidecarPath)
return nativeBinding
} catch (e) {
loadError = e
}
}
currentDir = require('path').dirname(currentDir)
}
if (loadError) {
throw loadError
}
throw new Error(`Native binding not found: ${bindingName}`)
}
const binding = loadBinding()
module.exports.triggerManualProxy = binding.triggerManualProxy
module.exports.triggerAutoProxy = binding.triggerAutoProxy
module.exports.getSystemProxy = binding.getSystemProxy
module.exports.getAutoProxy = binding.getAutoProxy

View File

@ -1,8 +0,0 @@
{
"name": "sysproxy-rs",
"version": "0.4.0",
"description": "System proxy library for Node.js",
"main": "index.js",
"types": "index.d.ts",
"license": "MIT"
}

View File

@ -1,72 +0,0 @@
import React, { createContext, useContext, ReactNode, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { showError } from '@renderer/utils/error-display'
import useSWR, { KeyedMutator } from 'swr'
interface ConfigContextValue<T> {
config: T | undefined
mutate: KeyedMutator<T>
}
interface CreateConfigContextOptions<T> {
swrKey: string
fetcher: () => Promise<T>
ipcEvent: string
}
export function createConfigContext<T>(options: CreateConfigContextOptions<T>) {
const { swrKey, fetcher, ipcEvent } = options
const Context = createContext<ConfigContextValue<T> | undefined>(undefined)
const Provider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { data: config, mutate } = useSWR(swrKey, fetcher)
useEffect(() => {
const handler = (): void => {
mutate()
}
window.electron.ipcRenderer.on(ipcEvent, handler)
return () => {
window.electron.ipcRenderer.removeListener(ipcEvent, handler)
}
}, [mutate])
return <Context.Provider value={{ config, mutate }}>{children}</Context.Provider>
}
const useConfig = (): ConfigContextValue<T> => {
const context = useContext(Context)
if (!context) {
throw new Error(`useConfig must be used within Provider`)
}
return context
}
return { Provider, useConfig, Context }
}
interface ActionOptions {
errorKey: string
updateTray?: boolean
}
export function useConfigAction<T>(
mutate: KeyedMutator<T>,
action: () => Promise<void>,
options: ActionOptions
): () => Promise<void> {
const { t } = useTranslation()
return useCallback(async () => {
try {
await action()
} catch (e) {
await showError(e, t(options.errorKey))
} finally {
mutate()
if (options.updateTray) {
window.electron.ipcRenderer.send('updateTrayMenu')
}
}
}, [mutate, action, t, options.errorKey, options.updateTray])
}

View File

@ -1,60 +1,51 @@
import React, { ReactNode, useCallback } from 'react' import React, { createContext, useContext, ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { showError } from '@renderer/utils/error-display' import { showError } from '@renderer/utils/error-display'
import { createConfigContext } from './create-config-context' import useSWR from 'swr'
import { getAppConfig, patchAppConfig as patch } from '@renderer/utils/ipc' import { getAppConfig, patchAppConfig as patch } from '@renderer/utils/ipc'
const { Provider, useConfig } = createConfigContext<IAppConfig>({
swrKey: 'getAppConfig',
fetcher: getAppConfig,
ipcEvent: 'appConfigUpdated'
})
interface AppConfigContextType { interface AppConfigContextType {
appConfig: IAppConfig | undefined appConfig: IAppConfig | undefined
mutateAppConfig: () => void mutateAppConfig: () => void
patchAppConfig: (value: Partial<IAppConfig>) => Promise<void> patchAppConfig: (value: Partial<IAppConfig>) => Promise<void>
} }
const AppConfigContext = createContext<AppConfigContextType | undefined>(undefined)
export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
return (
<Provider>
<AppConfigContextWrapper>{children}</AppConfigContextWrapper>
</Provider>
)
}
const AppConfigContextWrapper: React.FC<{ children: ReactNode }> = ({ children }) => {
const { config, mutate } = useConfig()
const { t } = useTranslation() const { t } = useTranslation()
const { data: appConfig, mutate: mutateAppConfig } = useSWR('getConfig', () => getAppConfig())
const patchAppConfig = useCallback( const patchAppConfig = async (value: Partial<IAppConfig>): Promise<void> => {
async (value: Partial<IAppConfig>): Promise<void> => {
try { try {
await patch(value) await patch(value)
} catch (e) { } catch (e) {
await showError(e, t('common.error.updateAppConfigFailed')) await showError(e, t('common.error.updateAppConfigFailed'))
} finally { } finally {
mutate() mutateAppConfig()
} }
}, }
[mutate, t]
) React.useEffect(() => {
const handler = (): void => {
mutateAppConfig()
}
window.electron.ipcRenderer.on('appConfigUpdated', handler)
return (): void => {
window.electron.ipcRenderer.removeListener('appConfigUpdated', handler)
}
}, [])
return ( return (
<AppConfigContext.Provider <AppConfigContext.Provider value={{ appConfig, mutateAppConfig, patchAppConfig }}>
value={{ appConfig: config, mutateAppConfig: mutate, patchAppConfig }}
>
{children} {children}
</AppConfigContext.Provider> </AppConfigContext.Provider>
) )
} }
const AppConfigContext = React.createContext<AppConfigContextType | undefined>(undefined)
export const useAppConfig = (): AppConfigContextType => { export const useAppConfig = (): AppConfigContextType => {
const context = React.useContext(AppConfigContext) const context = useContext(AppConfigContext)
if (!context) { if (context === undefined) {
throw new Error('useAppConfig must be used within an AppConfigProvider') throw new Error('useAppConfig must be used within an AppConfigProvider')
} }
return context return context

View File

@ -1,7 +1,7 @@
import React, { ReactNode, useCallback } from 'react' import React, { createContext, useContext, ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { showError } from '@renderer/utils/error-display' import { showError } from '@renderer/utils/error-display'
import { createConfigContext } from './create-config-context' import useSWR from 'swr'
import { import {
getOverrideConfig, getOverrideConfig,
setOverrideConfig as set, setOverrideConfig as set,
@ -10,12 +10,6 @@ import {
updateOverrideItem as update updateOverrideItem as update
} from '@renderer/utils/ipc' } from '@renderer/utils/ipc'
const { Provider, useConfig } = createConfigContext<IOverrideConfig>({
swrKey: 'getOverrideConfig',
fetcher: getOverrideConfig,
ipcEvent: 'overrideConfigUpdated'
})
interface OverrideConfigContextType { interface OverrideConfigContextType {
overrideConfig: IOverrideConfig | undefined overrideConfig: IOverrideConfig | undefined
setOverrideConfig: (config: IOverrideConfig) => Promise<void> setOverrideConfig: (config: IOverrideConfig) => Promise<void>
@ -25,59 +19,60 @@ interface OverrideConfigContextType {
removeOverrideItem: (id: string) => Promise<void> removeOverrideItem: (id: string) => Promise<void>
} }
const OverrideConfigContext = React.createContext<OverrideConfigContextType | undefined>(undefined) const OverrideConfigContext = createContext<OverrideConfigContextType | undefined>(undefined)
export const OverrideConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const OverrideConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
return (
<Provider>
<OverrideConfigContextWrapper>{children}</OverrideConfigContextWrapper>
</Provider>
)
}
const OverrideConfigContextWrapper: React.FC<{ children: ReactNode }> = ({ children }) => {
const { config, mutate } = useConfig()
const { t } = useTranslation() const { t } = useTranslation()
const { data: overrideConfig, mutate: mutateOverrideConfig } = useSWR('getOverrideConfig', () =>
getOverrideConfig()
)
const withErrorHandling = useCallback( const setOverrideConfig = async (config: IOverrideConfig): Promise<void> => {
(action: () => Promise<void>, errorKey: string) => async () => {
try { try {
await action() await set(config)
} catch (e) { } catch (e) {
await showError(e, t(errorKey)) await showError(e, t('common.error.saveOverrideConfigFailed'))
} finally { } finally {
mutate() mutateOverrideConfig()
}
} }
},
[mutate, t]
)
const setOverrideConfig = useCallback( const addOverrideItem = async (item: Partial<IOverrideItem>): Promise<void> => {
(cfg: IOverrideConfig) => withErrorHandling(() => set(cfg), 'common.error.saveOverrideConfigFailed')(), try {
[withErrorHandling] await add(item)
) } catch (e) {
await showError(e, t('common.error.addOverrideFailed'))
} finally {
mutateOverrideConfig()
}
}
const addOverrideItem = useCallback( const removeOverrideItem = async (id: string): Promise<void> => {
(item: Partial<IOverrideItem>) => withErrorHandling(() => add(item), 'common.error.addOverrideFailed')(), try {
[withErrorHandling] await remove(id)
) } catch (e) {
await showError(e, t('common.error.deleteOverrideFailed'))
} finally {
mutateOverrideConfig()
}
}
const removeOverrideItem = useCallback( const updateOverrideItem = async (item: IOverrideItem): Promise<void> => {
(id: string) => withErrorHandling(() => remove(id), 'common.error.deleteOverrideFailed')(), try {
[withErrorHandling] await update(item)
) } catch (e) {
await showError(e, t('common.error.updateOverrideFailed'))
const updateOverrideItem = useCallback( } finally {
(item: IOverrideItem) => withErrorHandling(() => update(item), 'common.error.updateOverrideFailed')(), mutateOverrideConfig()
[withErrorHandling] }
) }
return ( return (
<OverrideConfigContext.Provider <OverrideConfigContext.Provider
value={{ value={{
overrideConfig: config, overrideConfig,
setOverrideConfig, setOverrideConfig,
mutateOverrideConfig: mutate, mutateOverrideConfig,
addOverrideItem, addOverrideItem,
removeOverrideItem, removeOverrideItem,
updateOverrideItem updateOverrideItem
@ -89,8 +84,8 @@ const OverrideConfigContextWrapper: React.FC<{ children: ReactNode }> = ({ child
} }
export const useOverrideConfig = (): OverrideConfigContextType => { export const useOverrideConfig = (): OverrideConfigContextType => {
const context = React.useContext(OverrideConfigContext) const context = useContext(OverrideConfigContext)
if (!context) { if (context === undefined) {
throw new Error('useOverrideConfig must be used within an OverrideConfigProvider') throw new Error('useOverrideConfig must be used within an OverrideConfigProvider')
} }
return context return context

View File

@ -1,7 +1,7 @@
import React, { ReactNode, useCallback, useRef } from 'react' import React, { createContext, ReactNode, useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { showError } from '@renderer/utils/error-display' import { showError } from '@renderer/utils/error-display'
import { createConfigContext } from './create-config-context' import useSWR from 'swr'
import { import {
addProfileItem as add, addProfileItem as add,
changeCurrentProfile as change, changeCurrentProfile as change,
@ -11,12 +11,6 @@ import {
updateProfileItem as update updateProfileItem as update
} from '@renderer/utils/ipc' } from '@renderer/utils/ipc'
const { Provider, useConfig } = createConfigContext<IProfileConfig>({
swrKey: 'getProfileConfig',
fetcher: getProfileConfig,
ipcEvent: 'profileConfigUpdated'
})
interface ProfileConfigContextType { interface ProfileConfigContextType {
profileConfig: IProfileConfig | undefined profileConfig: IProfileConfig | undefined
setProfileConfig: (config: IProfileConfig) => Promise<void> setProfileConfig: (config: IProfileConfig) => Promise<void>
@ -27,64 +21,80 @@ interface ProfileConfigContextType {
changeCurrentProfile: (id: string) => Promise<void> changeCurrentProfile: (id: string) => Promise<void>
} }
const ProfileConfigContext = React.createContext<ProfileConfigContextType | undefined>(undefined) const ProfileConfigContext = createContext<ProfileConfigContextType | undefined>(undefined)
export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
return (
<Provider>
<ProfileConfigContextWrapper>{children}</ProfileConfigContextWrapper>
</Provider>
)
}
const ProfileConfigContextWrapper: React.FC<{ children: ReactNode }> = ({ children }) => {
const { config, mutate } = useConfig()
const { t } = useTranslation() const { t } = useTranslation()
const targetProfileId = useRef<string | null>(null) const { data: profileConfig, mutate: mutateProfileConfig } = useSWR('getProfileConfig', () =>
const pendingTask = useRef<Promise<void> | null>(null) getProfileConfig()
)
const targetProfileId = React.useRef<string | null>(null)
const pendingTask = React.useRef<Promise<void> | null>(null)
const withErrorHandling = useCallback( const setProfileConfig = async (config: IProfileConfig): Promise<void> => {
(action: () => Promise<void>, errorKey: string, updateTray = true) =>
async () => {
try { try {
await action() await set(config)
} catch (e) { } catch (e) {
await showError(e, t(errorKey)) await showError(e, t('common.error.saveProfileConfigFailed'))
} finally { } finally {
mutate() mutateProfileConfig()
if (updateTray) {
window.electron.ipcRenderer.send('updateTrayMenu') window.electron.ipcRenderer.send('updateTrayMenu')
} }
} }
},
[mutate, t]
)
const setProfileConfig = useCallback( const addProfileItem = async (item: Partial<IProfileItem>): Promise<void> => {
(cfg: IProfileConfig) => try {
withErrorHandling(() => set(cfg), 'common.error.saveProfileConfigFailed')(), await add(item)
[withErrorHandling] } catch (e) {
) await showError(e, t('common.error.addProfileFailed'))
} finally {
mutateProfileConfig()
window.electron.ipcRenderer.send('updateTrayMenu')
}
}
const addProfileItem = useCallback( const removeProfileItem = async (id: string): Promise<void> => {
(item: Partial<IProfileItem>) => try {
withErrorHandling(() => add(item), 'common.error.addProfileFailed')(), await remove(id)
[withErrorHandling] } catch (e) {
) await showError(e, t('common.error.deleteProfileFailed'))
} finally {
mutateProfileConfig()
window.electron.ipcRenderer.send('updateTrayMenu')
}
}
const removeProfileItem = useCallback( const updateProfileItem = async (item: IProfileItem): Promise<void> => {
(id: string) => withErrorHandling(() => remove(id), 'common.error.deleteProfileFailed')(), try {
[withErrorHandling] await update(item)
) } catch (e) {
await showError(e, t('common.error.updateProfileFailed'))
} finally {
mutateProfileConfig()
window.electron.ipcRenderer.send('updateTrayMenu')
}
}
const updateProfileItem = useCallback( const changeCurrentProfile = async (id: string): Promise<void> => {
(item: IProfileItem) => if (targetProfileId.current === id) {
withErrorHandling(() => update(item), 'common.error.updateProfileFailed')(), return
[withErrorHandling] }
)
const processChange = useCallback(async () => { // 立即更新 UI 状态和托盘菜单,提供即时反馈
if (pendingTask.current) return if (profileConfig) {
const optimisticUpdate = { ...profileConfig, current: id }
mutateProfileConfig(optimisticUpdate, false)
window.electron.ipcRenderer.send('updateTrayMenu')
}
targetProfileId.current = id
await processChange()
}
const processChange = async () => {
if (pendingTask.current) {
return
}
while (targetProfileId.current) { while (targetProfileId.current) {
const targetId = targetProfileId.current const targetId = targetProfileId.current
@ -92,48 +102,41 @@ const ProfileConfigContextWrapper: React.FC<{ children: ReactNode }> = ({ childr
pendingTask.current = change(targetId) pendingTask.current = change(targetId)
try { try {
// 异步执行后台切换,不阻塞 UI
await pendingTask.current await pendingTask.current
} catch (e) { } catch (e) {
const errorMsg = (e as { message?: string })?.message || String(e) const errorMsg = (e as { message?: string })?.message || String(e)
// 处理 IPC 超时错误
if (errorMsg.includes('reply was never sent')) { if (errorMsg.includes('reply was never sent')) {
setTimeout(() => mutate(), 1000) setTimeout(() => mutateProfileConfig(), 1000)
} else { } else {
await showError(errorMsg, t('common.error.switchProfileFailed')) await showError(errorMsg, t('common.error.switchProfileFailed'))
mutate() mutateProfileConfig()
} }
} finally { } finally {
pendingTask.current = null pendingTask.current = null
} }
} }
}, [mutate, t])
const changeCurrentProfile = useCallback(
async (id: string) => {
if (targetProfileId.current === id) return
if (config) {
mutate({ ...config, current: id }, false)
window.electron.ipcRenderer.send('updateTrayMenu')
} }
targetProfileId.current = id
await processChange()
},
[config, mutate, processChange]
)
React.useEffect(() => { React.useEffect(() => {
return () => { const handler = (): void => {
mutateProfileConfig()
}
window.electron.ipcRenderer.on('profileConfigUpdated', handler)
return (): void => {
// 清理待处理任务,防止内存泄漏
targetProfileId.current = null targetProfileId.current = null
window.electron.ipcRenderer.removeListener('profileConfigUpdated', handler)
} }
}, []) }, [])
return ( return (
<ProfileConfigContext.Provider <ProfileConfigContext.Provider
value={{ value={{
profileConfig: config, profileConfig,
setProfileConfig, setProfileConfig,
mutateProfileConfig: mutate, mutateProfileConfig,
addProfileItem, addProfileItem,
removeProfileItem, removeProfileItem,
updateProfileItem, updateProfileItem,
@ -146,8 +149,8 @@ const ProfileConfigContextWrapper: React.FC<{ children: ReactNode }> = ({ childr
} }
export const useProfileConfig = (): ProfileConfigContextType => { export const useProfileConfig = (): ProfileConfigContextType => {
const context = React.useContext(ProfileConfigContext) const context = useContext(ProfileConfigContext)
if (!context) { if (context === undefined) {
throw new Error('useProfileConfig must be used within a ProfileConfigProvider') throw new Error('useProfileConfig must be used within a ProfileConfigProvider')
} }
return context return context