- 新增 `shared/` 目录,统一管理前后端共享的类型定义与工具函数,消除重复代码 - 重构 IPC 数据传输,使用 `toIpcSafeSnapshot` 确保序列化安全 - 新增系统托盘独立窗口 (`/tray` 路由) 与主窗口关闭行为设置,提升用户体验 - 优化 Sing-box 配置校验错误提示,增强可读性 - 修复订阅更新无新节点时的提示逻辑 - 更新项目依赖与构建配置,支持共享层路径别名 (`@shared`) - 补充单元测试,完善国际化词条
477 lines
16 KiB
TypeScript
477 lines
16 KiB
TypeScript
import { ipcMain } from 'electron'
|
|
import { runCommand } from '../utils/command'
|
|
import { findAvailablePort } from '../utils/port'
|
|
import { ping, tcpPing } from '../utils/ping'
|
|
import nodeNet from 'node:net'
|
|
import nodeTls from 'node:tls'
|
|
|
|
/**
|
|
* 系统命令执行结果
|
|
*/
|
|
type SystemCommandResult = {
|
|
/** 命令是否执行成功 */
|
|
ok: boolean
|
|
/** 退出代码 */
|
|
code: number
|
|
/** 标准输出内容 */
|
|
output: string
|
|
/** 结果消息或错误提示 */
|
|
message: string
|
|
}
|
|
|
|
/**
|
|
* Windows 系统代理配置快照
|
|
* 用于备份和恢复代理设置
|
|
*/
|
|
type SystemProxySnapshot = {
|
|
ProxyEnable?: number
|
|
ProxyServer?: string
|
|
ProxyOverride?: string
|
|
AutoConfigURL?: string
|
|
AutoDetect?: number
|
|
}
|
|
|
|
/**
|
|
* 系统代理操作结果
|
|
*/
|
|
type SystemProxyResult = {
|
|
ok: boolean
|
|
message: string
|
|
snapshot?: SystemProxySnapshot
|
|
}
|
|
|
|
type FetchUrlResult = {
|
|
ok: boolean
|
|
status: number
|
|
statusText: string
|
|
body: string
|
|
finalUrl?: string
|
|
error?: string
|
|
}
|
|
|
|
/**
|
|
* 系统服务
|
|
*
|
|
* 提供底层系统操作能力,包括:
|
|
* - 网络工具 (Ping, TCP Ping, DNS 刷新)
|
|
* - 虚拟网卡管理
|
|
* - 系统代理设置与清除
|
|
* - HTTP 代理连通性测试
|
|
*/
|
|
export class SystemService {
|
|
constructor() {
|
|
this.registerIPC()
|
|
}
|
|
|
|
async fetchUrl(url: string, timeoutMs: number = 20000): Promise<FetchUrlResult> {
|
|
const target = String(url || '').trim()
|
|
if (!target) {
|
|
return {
|
|
ok: false,
|
|
status: 0,
|
|
statusText: '',
|
|
body: '',
|
|
error: 'empty-url'
|
|
}
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), Math.max(1000, timeoutMs))
|
|
|
|
try {
|
|
const response = await fetch(target, {
|
|
method: 'GET',
|
|
cache: 'no-store',
|
|
signal: controller.signal,
|
|
headers: {
|
|
'User-Agent': 'LagZero/1.0'
|
|
}
|
|
})
|
|
const body = await response.text()
|
|
return {
|
|
ok: response.ok,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
body,
|
|
finalUrl: response.url
|
|
}
|
|
} catch (e: any) {
|
|
return {
|
|
ok: false,
|
|
status: 0,
|
|
statusText: '',
|
|
body: '',
|
|
error: String(e?.message || e || 'fetch-failed')
|
|
}
|
|
} finally {
|
|
clearTimeout(timeout)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 刷新系统 DNS 缓存
|
|
* 目前仅支持 Windows
|
|
*/
|
|
async flushDnsCache(): Promise<SystemCommandResult> {
|
|
if (process.platform === 'win32') {
|
|
const { code, output } = await runCommand('ipconfig', ['/flushdns'])
|
|
return {
|
|
ok: code === 0,
|
|
code,
|
|
output,
|
|
message: code === 0 ? 'DNS cache flushed.' : 'Failed to flush DNS cache. Try running LagZero as administrator.'
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
code: -1,
|
|
output: '',
|
|
message: `Unsupported platform: ${process.platform}`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 重置 TUN 虚拟网卡
|
|
* 尝试卸载指定的网络适配器,以便 sing-box 重新创建
|
|
* @param interfaceName 网卡名称,默认为 'LagZero'
|
|
*/
|
|
async reinstallTunAdapter(interfaceName: string = 'LagZero'): Promise<SystemCommandResult> {
|
|
if (process.platform !== 'win32') {
|
|
return {
|
|
ok: false,
|
|
code: -1,
|
|
output: '',
|
|
message: `Unsupported platform: ${process.platform}`
|
|
}
|
|
}
|
|
|
|
// Try to remove old adapter instance, then sing-box will recreate it on next start.
|
|
const psScript = [
|
|
`$name = '${interfaceName.replace(/'/g, "''")}'`,
|
|
"$adapter = Get-NetAdapter -Name $name -ErrorAction SilentlyContinue",
|
|
"if ($null -eq $adapter) { Write-Output \"TUN adapter not found: $name\"; exit 2 }",
|
|
"Disable-NetAdapter -Name $name -Confirm:$false -ErrorAction SilentlyContinue | Out-Null",
|
|
"Start-Sleep -Milliseconds 400",
|
|
"if (Get-Command Remove-NetAdapter -ErrorAction SilentlyContinue) {",
|
|
" Remove-NetAdapter -Name $name -Confirm:$false -ErrorAction Stop",
|
|
" Write-Output \"TUN adapter removed: $name\"",
|
|
" exit 0",
|
|
"}",
|
|
"Write-Output \"Remove-NetAdapter not available\"",
|
|
"exit 3"
|
|
].join('; ')
|
|
|
|
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], 25000)
|
|
const ok = code === 0 || code === 2
|
|
return {
|
|
ok,
|
|
code,
|
|
output,
|
|
message: ok
|
|
? 'TUN adapter reset finished. Restart acceleration to recreate adapter.'
|
|
: 'Failed to reset TUN adapter. Try running LagZero as administrator.'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 测试 HTTP 代理连通性
|
|
*
|
|
* 尝试通过指定的本地代理连接到目标服务器,支持 HTTPS 握手验证
|
|
*
|
|
* @param proxyPort 代理端口
|
|
* @param targetHost 目标主机
|
|
* @param targetPort 目标端口 (默认 443)
|
|
* @param timeoutMs 超时时间 (默认 5000ms)
|
|
*/
|
|
async testHttpProxyConnect(proxyPort: number, targetHost: string, targetPort: number = 443, timeoutMs: number = 5000) {
|
|
return new Promise<{ ok: boolean, statusLine: string, error?: string }>((resolve) => {
|
|
const socket = nodeNet.createConnection({ host: '127.0.0.1', port: proxyPort })
|
|
let tlsSocket: nodeTls.TLSSocket | null = null
|
|
let settled = false
|
|
let connectBuf = ''
|
|
let responseBuf = ''
|
|
const requestPath = targetHost.includes('google') ? '/generate_204' : '/'
|
|
|
|
const done = (payload: { ok: boolean, statusLine: string, error?: string }) => {
|
|
if (settled) return
|
|
settled = true
|
|
try { tlsSocket?.destroy() } catch { }
|
|
socket.destroy()
|
|
resolve(payload)
|
|
}
|
|
|
|
socket.setTimeout(Math.max(1000, timeoutMs))
|
|
socket.on('timeout', () => done({ ok: false, statusLine: '', error: 'timeout' }))
|
|
socket.on('error', (err: any) => done({ ok: false, statusLine: '', error: String(err?.message || err) }))
|
|
socket.on('connect', () => {
|
|
const req = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\nProxy-Connection: Keep-Alive\r\n\r\n`
|
|
socket.write(req)
|
|
})
|
|
socket.on('data', (chunk: Buffer) => {
|
|
connectBuf += chunk.toString('utf8')
|
|
const headerEnd = connectBuf.indexOf('\r\n\r\n')
|
|
if (headerEnd < 0) return
|
|
|
|
const lineEnd = connectBuf.indexOf('\r\n')
|
|
const connectStatusLine = (lineEnd >= 0 ? connectBuf.slice(0, lineEnd) : '').trim()
|
|
if (!/^HTTP\/1\.[01]\s+200\b/i.test(connectStatusLine)) {
|
|
done({ ok: false, statusLine: connectStatusLine, error: 'connect-not-200' })
|
|
return
|
|
}
|
|
|
|
socket.removeAllListeners('data')
|
|
socket.setTimeout(0)
|
|
|
|
tlsSocket = nodeTls.connect({
|
|
socket,
|
|
servername: targetHost,
|
|
rejectUnauthorized: false
|
|
}, () => {
|
|
const getReq = [
|
|
`GET ${requestPath} HTTP/1.1`,
|
|
`Host: ${targetHost}`,
|
|
'User-Agent: LagZero/1.0',
|
|
'Accept: */*',
|
|
'Connection: close',
|
|
'',
|
|
''
|
|
].join('\r\n')
|
|
tlsSocket?.write(getReq)
|
|
})
|
|
|
|
tlsSocket.setTimeout(Math.max(1000, timeoutMs))
|
|
tlsSocket.on('timeout', () => done({ ok: false, statusLine: '', error: 'https-timeout' }))
|
|
tlsSocket.on('error', (err: any) => done({ ok: false, statusLine: '', error: `https:${String(err?.message || err)}` }))
|
|
tlsSocket.on('data', (data: Buffer) => {
|
|
responseBuf += data.toString('utf8')
|
|
const responseHeaderEnd = responseBuf.indexOf('\r\n\r\n')
|
|
if (responseHeaderEnd < 0) return
|
|
|
|
const responseLineEnd = responseBuf.indexOf('\r\n')
|
|
const responseStatusLine = (responseLineEnd >= 0 ? responseBuf.slice(0, responseLineEnd) : '').trim()
|
|
const ok = /^HTTP\/1\.[01]\s+(2\d\d|3\d\d)\b/i.test(responseStatusLine)
|
|
done({
|
|
ok,
|
|
statusLine: responseStatusLine,
|
|
...(ok ? {} : { error: 'http-not-2xx-3xx' })
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 获取当前系统代理状态 (仅 Windows)
|
|
*/
|
|
async getSystemProxyState(): Promise<{ ok: boolean, message: string, state?: SystemProxySnapshot }> {
|
|
if (process.platform !== 'win32') {
|
|
return {
|
|
ok: false,
|
|
message: `Unsupported platform: ${process.platform}`
|
|
}
|
|
}
|
|
|
|
try {
|
|
const state = await this.readWindowsSystemProxyState()
|
|
return { ok: true, message: 'OK', state }
|
|
} catch (e: any) {
|
|
return { ok: false, message: String(e?.message || e) }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 设置系统代理 (仅 Windows)
|
|
* 会自动备份当前代理设置
|
|
*
|
|
* @param port 代理端口
|
|
* @param bypass 不走代理的地址列表
|
|
*/
|
|
async setSystemProxy(port: number, bypass: string = '<local>'): Promise<SystemProxyResult> {
|
|
if (process.platform !== 'win32') {
|
|
return {
|
|
ok: false,
|
|
message: `Unsupported platform: ${process.platform}`
|
|
}
|
|
}
|
|
|
|
const proxyPort = Math.max(1, Math.floor(Number(port || 0)))
|
|
if (!proxyPort) {
|
|
return {
|
|
ok: false,
|
|
message: 'Invalid proxy port'
|
|
}
|
|
}
|
|
|
|
const snapshot = await this.readWindowsSystemProxyState()
|
|
const safeBypass = String(bypass || '<local>').replace(/'/g, "''")
|
|
const safeServer = `127.0.0.1:${proxyPort}`.replace(/'/g, "''")
|
|
const psScript = [
|
|
"$path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'",
|
|
"$old = Get-ItemProperty -Path $path",
|
|
"Set-ItemProperty -Path $path -Name ProxyEnable -Type DWord -Value 1",
|
|
`Set-ItemProperty -Path $path -Name ProxyServer -Type String -Value '${safeServer}'`,
|
|
`Set-ItemProperty -Path $path -Name ProxyOverride -Type String -Value '${safeBypass}'`,
|
|
"Set-ItemProperty -Path $path -Name AutoDetect -Type DWord -Value 0",
|
|
"Remove-ItemProperty -Path $path -Name AutoConfigURL -ErrorAction SilentlyContinue",
|
|
"Write-Output 'system proxy configured'"
|
|
].join('; ')
|
|
|
|
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], 15000)
|
|
if (code !== 0) {
|
|
return {
|
|
ok: false,
|
|
message: `Failed to set system proxy: ${output || `code=${code}`}`,
|
|
snapshot
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
message: 'System proxy enabled',
|
|
snapshot
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清除或恢复系统代理设置
|
|
* @param snapshot 如果提供,则恢复到该快照状态;否则直接清除代理
|
|
*/
|
|
async clearSystemProxy(snapshot?: SystemProxySnapshot): Promise<SystemProxyResult> {
|
|
if (process.platform !== 'win32') {
|
|
return {
|
|
ok: false,
|
|
message: `Unsupported platform: ${process.platform}`
|
|
}
|
|
}
|
|
|
|
const psScript = this.buildClearSystemProxyScript(snapshot)
|
|
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], 15000)
|
|
if (code !== 0) {
|
|
return {
|
|
ok: false,
|
|
message: `Failed to clear system proxy: ${output || `code=${code}`}`
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
message: 'System proxy cleared'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 构建清除或恢复系统代理的 PowerShell 脚本
|
|
*/
|
|
private buildClearSystemProxyScript(snapshot?: SystemProxySnapshot): string {
|
|
const hasSnapshot = !!snapshot && typeof snapshot === 'object'
|
|
if (!hasSnapshot) {
|
|
return [
|
|
"$path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'",
|
|
"Set-ItemProperty -Path $path -Name ProxyEnable -Type DWord -Value 0",
|
|
"Set-ItemProperty -Path $path -Name AutoDetect -Type DWord -Value 0",
|
|
"Remove-ItemProperty -Path $path -Name ProxyServer -ErrorAction SilentlyContinue",
|
|
"Remove-ItemProperty -Path $path -Name AutoConfigURL -ErrorAction SilentlyContinue",
|
|
"Write-Output 'system proxy cleared'"
|
|
].join('; ')
|
|
}
|
|
|
|
const proxyEnable = Number(snapshot?.ProxyEnable || 0)
|
|
const autoDetect = Number(snapshot?.AutoDetect || 0)
|
|
const proxyServer = String(snapshot?.ProxyServer || '').replace(/'/g, "''")
|
|
const proxyOverride = String(snapshot?.ProxyOverride || '').replace(/'/g, "''")
|
|
const autoConfigUrl = String(snapshot?.AutoConfigURL || '').replace(/'/g, "''")
|
|
|
|
return [
|
|
"$path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'",
|
|
`Set-ItemProperty -Path $path -Name ProxyEnable -Type DWord -Value ${Number.isFinite(proxyEnable) ? proxyEnable : 0}`,
|
|
`Set-ItemProperty -Path $path -Name AutoDetect -Type DWord -Value ${Number.isFinite(autoDetect) ? autoDetect : 0}`,
|
|
proxyServer
|
|
? `Set-ItemProperty -Path $path -Name ProxyServer -Type String -Value '${proxyServer}'`
|
|
: "Remove-ItemProperty -Path $path -Name ProxyServer -ErrorAction SilentlyContinue",
|
|
proxyOverride
|
|
? `Set-ItemProperty -Path $path -Name ProxyOverride -Type String -Value '${proxyOverride}'`
|
|
: "Remove-ItemProperty -Path $path -Name ProxyOverride -ErrorAction SilentlyContinue",
|
|
autoConfigUrl
|
|
? `Set-ItemProperty -Path $path -Name AutoConfigURL -Type String -Value '${autoConfigUrl}'`
|
|
: "Remove-ItemProperty -Path $path -Name AutoConfigURL -ErrorAction SilentlyContinue",
|
|
"Write-Output 'system proxy restored'"
|
|
].join('; ')
|
|
}
|
|
|
|
/**
|
|
* 读取 Windows 系统代理当前状态
|
|
*/
|
|
private async readWindowsSystemProxyState(): Promise<SystemProxySnapshot> {
|
|
const psScript = `
|
|
$path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'
|
|
$p = Get-ItemProperty -Path $path
|
|
$result = [ordered]@{
|
|
ProxyEnable = [int]($p.ProxyEnable)
|
|
ProxyServer = [string]($p.ProxyServer)
|
|
ProxyOverride = [string]($p.ProxyOverride)
|
|
AutoConfigURL = [string]($p.AutoConfigURL)
|
|
AutoDetect = [int]($p.AutoDetect)
|
|
}
|
|
$result | ConvertTo-Json -Compress
|
|
`.trim()
|
|
|
|
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], 10000)
|
|
if (code !== 0) {
|
|
throw new Error(output || `read system proxy state failed: code=${code}`)
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(output || '{}') as SystemProxySnapshot
|
|
} catch {
|
|
throw new Error(`parse system proxy state failed: ${output}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 注册 IPC 监听器
|
|
*/
|
|
private registerIPC() {
|
|
ipcMain.handle('system:flush-dns-cache', () => this.flushDnsCache()) // 刷新 DNS 缓存
|
|
ipcMain.handle('system:tun-reinstall', async (_, interfaceName?: string) => {
|
|
return this.reinstallTunAdapter(interfaceName || 'LagZero')
|
|
}) // 重装 TUN 适配器
|
|
ipcMain.handle('system:find-available-port', async (_, port: number, count?: number) => {
|
|
return findAvailablePort(port, count)
|
|
}) // 查找可用端口
|
|
ipcMain.handle('system:test-http-proxy-connect', async (_, proxyPort: number, targetHost: string, targetPort: number = 443, timeoutMs: number = 5000) => {
|
|
return this.testHttpProxyConnect(proxyPort, targetHost, targetPort, timeoutMs)
|
|
}) // 测试 HTTP 代理连接
|
|
ipcMain.handle('system:ping', async (_, host: string) => {
|
|
try {
|
|
const result = await ping(host)
|
|
return typeof result?.latency === 'number' ? result.latency : -1
|
|
} catch (e: any) {
|
|
console.warn('[系统服务] Ping 失败:', host, e)
|
|
return -1
|
|
}
|
|
}) // 测试 ICMP 代理连接
|
|
ipcMain.handle('system:tcp-ping', async (_, host: string, port: number) => {
|
|
try {
|
|
const result = await tcpPing(host, port)
|
|
return typeof result?.latency === 'number' ? result.latency : -1
|
|
} catch (e: any) {
|
|
console.warn('[系统服务] TCP Ping 失败:', host, port, e)
|
|
return -1
|
|
}
|
|
}) // 测试 TCP 代理连接
|
|
ipcMain.handle('system:set-system-proxy', async (_, port: number, bypass?: string) => {
|
|
return this.setSystemProxy(port, bypass)
|
|
}) // 设置系统代理
|
|
ipcMain.handle('system:clear-system-proxy', async (_, snapshot?: SystemProxySnapshot) => {
|
|
return this.clearSystemProxy(snapshot)
|
|
}) // 清除系统代理
|
|
ipcMain.handle('system:get-system-proxy-state', async () => {
|
|
return this.getSystemProxyState()
|
|
}) // 获取系统代理状态
|
|
ipcMain.handle('system:fetch-url', async (_, url: string, timeoutMs?: number) => {
|
|
return this.fetchUrl(url, timeoutMs)
|
|
}) // 由主进程执行 HTTP 请求(用于规避渲染进程 CORS)
|
|
}
|
|
}
|