diff --git a/.gitignore b/.gitignore index f65bff60..5a1390a1 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ subscription-cache/ # Any local override configs local.config.* *.local.json + +# NaiveProxy 核心库(由 npm run fetch:cronet 下载,打包时随 resources 打入;体积大不入库) +resources/*/libcronet.* diff --git a/README.md b/README.md index eaed04a6..29d90198 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ - ✅ 支持排除进程代理模式 - ✅ 跨平台支持(Windows / macOS / Linux(测试)) - ✅ 支持中英文语言切换 +- ✅ 无缝切换节点(selector + clash_api 热切换,默认优雅不断流;可选「切换时中断现有连接」) +- ✅ Block QUIC(节点无关:reject 代理向 QUIC/UDP 443、逼浏览器回退 TCP,解决节点 UDP relay 不通导致的网页卡顿) +- ✅ 抗封增强:TLS Fragment(全局)/ ECH / Multiplex / httpupgrade / Hysteria2 端口跳跃(订阅自动识别,部分提供手动开关) --- ## 🖼 界面预览 @@ -68,7 +71,7 @@ 打开 `.dmg` 并拖入 Applications ### macOS (Intel) -需要从源码构建 +打开 `.dmg` 并拖入 Applications(已提供 Intel / x64 预构建包) 若 macOS 提示“软件已损坏”: @@ -91,11 +94,7 @@ npm run package:win npm run package:mac ``` -macOS Intel 用户需要修改 `electron-builder.json`: - -```json -"arch": ["x64"] -``` +`package:mac` 默认构建 **arm64 + x64** 两个架构(见 `electron-builder.json` 的 `mac.target.arch`)。 --- @@ -127,6 +126,28 @@ macOS Intel 用户需要修改 `electron-builder.json`: --- +## 🛡 抗封 / 切换 / NaiveProxy 说明 + +### 无缝切换节点 +默认采用 **selector + clash_api 热切换**:切换节点不重启核心、现有连接保留至自然关闭,新连接走新节点(优雅不断流)。高级设置中的「**切换时中断现有连接**」开关(默认关)开启后,切换会强制断开现有连接、立即在新节点重建。跨模式/端口/TUN/规则等改动仍会重启以应用。 + +### Block QUIC(高级设置) +开启后对**代理向的 QUIC(UDP 443)**执行 reject、逼浏览器回退 TCP,解决「节点 UDP relay 不通导致网页卡顿/断流」。**节点无关**,对所有协议一视同仁;hysteria2/tuic/naive 等以 QUIC 拨号的节点其**自身拨号不受影响**(受 fwmark 保护)。默认关。 + +### 抗封增强 +- **TLS Fragment**(高级设置全局开关):切分 TLS ClientHello,规避基于 SNI 关键词的 DPI 阻断。对所有 TCP-TLS 节点生效;hysteria2/tuic/naive 自动排除(其 TLS 不在 TCP 层)。 +- **ECH / Multiplex / httpupgrade / Hysteria2 端口跳跃**:从 **sing-box JSON 订阅自动识别并生效**(Multiplex 对 reality+vision 节点自动跳过;端口跳跃支持多段范围)。 + +### ⚠️ NaiveProxy(naive)核心库说明 +naive 出站底层走 **Chromium 的 Cronet 网络库** 以获得与浏览器一致的指纹。各平台链接方式不同: + +- **Linux / Windows**:cronet 走**动态库**(`libcronet.so` / `libcronet.dll`),需与核心同目录。打包时由 `npm run fetch:cronet` 从 [SagerNet/cronet-go](https://github.com/SagerNet/cronet-go/releases) 拉取并随安装包打入(体积大,不入库、CI/打包时拉取)。 +- **macOS(arm64 与 x64)**:cronet 由 sing-box 核心二进制**静态编入**(CGO),naive **开箱即用、无需任何外部库**;两个架构均随安装包发布(核心统一 sing-box 1.13.13)。 + +> 在缺少 cronet 的平台/架构上,naive 节点会被**自动跳过**(不影响其它协议节点;若选中的正是 naive 节点会给出明确提示)。 + +--- + ## 🔧 技术栈 - Electron diff --git a/electron-builder.json b/electron-builder.json index 5bfab101..3fce1a1d 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -74,7 +74,8 @@ { "target": "dir", "arch": [ - "arm64" + "arm64", + "x64" ] } ], diff --git a/package.json b/package.json index 1a2387a7..f1974b63 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,10 @@ "lint": "eslint src --ext .ts,.tsx", "lint:fix": "eslint src --ext .ts,.tsx --fix", "format": "prettier --write \"src/**/*.{ts,tsx,json,css}\"", + "fetch:cronet": "node scripts/fetch-cronet.mjs", "release:tag": "node scripts/push-release.js", "release:tag:update": "node scripts/push-release.js -y -u", - "release:prepare": "npm run build && npm run package:all" + "release:prepare": "npm run fetch:cronet && npm run build && npm run package:all" }, "keywords": [ "electron", diff --git a/resources/linux/sing-box b/resources/linux/sing-box index fc583476..6b296289 100755 Binary files a/resources/linux/sing-box and b/resources/linux/sing-box differ diff --git a/resources/mac-arm64/sing-box b/resources/mac-arm64/sing-box index 70111a40..6c4caa79 100755 Binary files a/resources/mac-arm64/sing-box and b/resources/mac-arm64/sing-box differ diff --git a/resources/mac-x64/sing-box b/resources/mac-x64/sing-box index b195ffae..a8f77651 100755 Binary files a/resources/mac-x64/sing-box and b/resources/mac-x64/sing-box differ diff --git a/resources/win/sing-box.exe b/resources/win/sing-box.exe index 80b4dfed..7071a98b 100644 Binary files a/resources/win/sing-box.exe and b/resources/win/sing-box.exe differ diff --git a/scripts/fetch-cronet.mjs b/scripts/fetch-cronet.mjs new file mode 100644 index 00000000..f21b4a99 --- /dev/null +++ b/scripts/fetch-cronet.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +/** + * fetch-cronet.mjs — 下载各平台 NaiveProxy 核心库 libcronet 到 resources/{平台}/, + * 供 electron-builder 的 extraResources(`**​/*` filter) 随安装包打包(与 sing-box 二进制同模式)。 + * + * 用法:node scripts/fetch-cronet.mjs [--force] + * + * ⚠️ macOS 不在此脚本范围:cronet 在 mac 上不走动态库。FlowZ 的 mac-arm64 sing-box 二进制已把 cronet + * 静态编入(CGO,实测二进制内含 cronet 符号、无 dlopen libcronet.dylib),naive 开箱即用、无需任何 + * 外部库。mac-x64 二进制未编入 cronet → naive 暂不可用(需重编带 naive 的 x64 核心)。详见 README。 + * + * ⚠️ 版本耦合:CRONET_VERSION 应与「随 app 打包的 sing-box 所用 cronet-go 版本」对应。cronet 走 + * C API(Chromium 稳定 ABI),跨 sing-box 小版本一般兼容;若升级 sing-box 后 naive 报符号错,提高 + * 此处版本并重打包。来源:https://github.com/SagerNet/cronet-go/releases + */ +import { createWriteStream, existsSync, mkdirSync, renameSync, unlinkSync } from 'fs'; +import { get } from 'https'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const CRONET_VERSION = 'v148.0.7778.96-1'; // ← 与打包 sing-box 的 cronet-go 版本对齐 +const REPO = 'SagerNet/cronet-go'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const FORCE = process.argv.includes('--force'); + +// 仅 linux/windows 走动态库;mac 静态编入核心二进制,不需下载(见文件头)。 +// resources 目标目录 ← cronet-go 资产名 → 落地文件名(purego 期望) +const TARGETS = [ + { dir: 'resources/linux', asset: 'libcronet-linux-amd64.so', out: 'libcronet.so' }, + { dir: 'resources/win', asset: 'libcronet-windows-amd64.dll', out: 'libcronet.dll' }, +]; + +function download(url, dest, redirects = 0) { + return new Promise((resolve, reject) => { + if (redirects > 5) return reject(new Error('too many redirects')); + get(url, { headers: { 'User-Agent': 'FlowZ-fetch-cronet' } }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + return resolve(download(res.headers.location, dest, redirects + 1)); + } + if (res.statusCode !== 200) { + res.resume(); + return reject(new Error(`HTTP ${res.statusCode} for ${url}`)); + } + // 原子写:先写 .tmp,完成再 rename;失败/中断则删 .tmp,避免把截断文件当成"已存在"误用 + const tmp = `${dest}.tmp`; + const file = createWriteStream(tmp); + const fail = (e) => { + try { + unlinkSync(tmp); + } catch { + /* ignore */ + } + reject(e); + }; + res.on('error', fail); + file.on('error', fail); + res.pipe(file); + file.on('finish', () => + file.close((err) => { + if (err) return fail(err); + try { + renameSync(tmp, dest); + resolve(); + } catch (e) { + fail(e); + } + }) + ); + }).on('error', reject); + }); +} + +let ok = 0; +let failed = 0; +for (const t of TARGETS) { + const absDir = join(ROOT, t.dir); + const dest = join(absDir, t.out); + if (existsSync(dest) && !FORCE) { + console.log(`skip (exists): ${t.dir}/${t.out}`); + ok++; + continue; + } + mkdirSync(absDir, { recursive: true }); + const url = `https://github.com/${REPO}/releases/download/${CRONET_VERSION}/${t.asset}`; + try { + console.log(`downloading ${t.asset} → ${t.dir}/${t.out} ...`); + await download(url, dest); + console.log(` ok`); + ok++; + } catch (e) { + console.error(` FAILED: ${e.message}`); + failed++; + } +} +console.log(`\ncronet libs: ${ok} ready, ${failed} failed (version ${CRONET_VERSION}).`); +console.log('macOS: cronet 静态编入 mac-arm64 核心,无需下载(见脚本头注)。'); +process.exit(failed > 0 ? 1 : 0); diff --git a/src/main/index.ts b/src/main/index.ts index db827b11..6347b40f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -583,6 +583,7 @@ if (gotTheLock) { // 初始化 ProxyManager(需要在窗口创建后) proxyManager = new ProxyManager(logManager, mainWindow || undefined); coreUpdateService.setProxyManager(proxyManager); + coreUpdateService.setConfigProvider(() => configManager.loadConfig()); // 初始化自动换节点服务 autoSwitchService = new AutoSwitchService( @@ -629,6 +630,18 @@ if (gotTheLock) { 'Main' ); } + + // 若是核心更新后新核心首次启动失败 → 自动回滚到旧核心并以旧核心重启代理 + try { + const rolledBack = await coreUpdateService.autoRollbackIfPendingUpdate(); + if (rolledBack) { + logManager.addLog('warn', '新核心启动失败,已自动回滚,正在以旧核心重启代理...', 'Main'); + const cfg = await configManager.loadConfig(); + await proxyManager?.start(cfg); + } + } catch (rollbackErr) { + logManager.addLog('error', `自动回滚重启失败: ${rollbackErr}`, 'Main'); + } }); proxyManager.on('started', async () => { @@ -796,11 +809,11 @@ if (gotTheLock) { await configManager.saveConfig(config); logManager.addLog('info', `Server selected from tray: ${serverId}`, 'Main'); - // 如果代理正在运行,重启以应用新服务器 + // 如果代理正在运行,应用新服务器:切节点走 switchMode(clash_api 热切换、不断流), + // 失败自动退回重启——与渲染端切换路径行为一致,避免托盘切节点硬重启断流。 if (proxyManager && proxyManager.getStatus().running) { - await proxyManager.stop(); - await proxyManager.start(config); - logManager.addLog('info', 'Proxy restarted with new server', 'Main'); + await proxyManager.switchMode(config); + logManager.addLog('info', 'Applied server switch from tray', 'Main'); } // 更新托盘菜单 @@ -1104,26 +1117,20 @@ if (gotTheLock) { const isRunning = proxyManager?.getStatus().running ?? false; updateTrayMenuState(isRunning); - // 2. 如果代理正在运行,自动重启以应用新配置 + // 2. 如果代理正在运行,应用新配置:仅切节点走 clash_api 热切换(不断流),其余重启(见 switchMode) if (isRunning && proxyManager) { - logManager.addLog('info', 'Configuration changed, restarting proxy...', 'Main'); try { - await proxyManager.stop(); // 重新加载配置以确保使用最新值 const latestConfig = await configManager.loadConfig(); - await proxyManager.start(latestConfig); - logManager.addLog('info', 'Proxy restarted successfully with new configuration', 'Main'); + await proxyManager.switchMode(latestConfig); + logManager.addLog('info', 'Applied configuration change', 'Main'); - // 重启后再次更新托盘(以防状态有变) - updateTrayMenuState(true); + // 应用后再次更新托盘(以防状态有变) + updateTrayMenuState(proxyManager.getStatus().running); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - logManager.addLog( - 'error', - `Failed to restart proxy after config change: ${errorMessage}`, - 'Main' - ); - // 重启失败,更新托盘状态为停止 + logManager.addLog('error', `Failed to apply config change: ${errorMessage}`, 'Main'); + // 应用失败,更新托盘状态为停止 updateTrayMenuState(false, true); } } diff --git a/src/main/services/CoreUpdateService.ts b/src/main/services/CoreUpdateService.ts index dcbb0cca..fedf10e6 100644 --- a/src/main/services/CoreUpdateService.ts +++ b/src/main/services/CoreUpdateService.ts @@ -11,6 +11,8 @@ import { LogManager } from './LogManager'; import { ProxyManager } from './ProxyManager'; import { resourceManager } from './ResourceManager'; +import type { UserConfig } from '../../shared/types'; + export interface CoreUpdateCheckResult { hasUpdate: boolean; currentVersion: string; @@ -27,10 +29,28 @@ export interface CoreVersionInfo { lastKnownVersion: string | null; } +/** + * FlowZ 配置生成器已验证适配的 sing-box 版本带上限,编码为 major*1000+minor(1013 = 1.13)。 + * 跨越此版本带(如 1.14)可能因 sing-box 配置 schema 破坏性变更导致生成的配置无法解析;默认不 + * 自动跨带更新(可在设置关闭)。用整数编码比较,避免 parseFloat 把 "1.20" 误判为 1.2 < 1.13 而 + * 漏放跨带更新。sing-box 升级并验证配置生成兼容后,应调高此常量。 + */ +const COMPATIBLE_CEILING = 1013; + export class CoreUpdateService { private logManager: LogManager; private proxyManager: ProxyManager | null = null; private isUpdating: boolean = false; + // 更新后等待「首次成功运行」验证的新版本号;首启成功→清除并删备份,首启失败→自动回滚 + private pendingUpdateVersion: string | null = null; + private pendingUpdateAt: number = 0; // 更新落盘时间戳,用于"待验证"过期保护(防陈旧 pending 误回滚) + private stabilityTimer: ReturnType | null = null; + private configProvider: (() => Promise) | null = null; + // 新核心首启成功后需"稳定运行"此时长(无 error)才删旧备份——防 'started'(仅1s存活) 假成功删掉回滚网。 + // 不变量:必须 > ProxyManager 健康检查间隔(10s),否则 TUN 模式下崩溃在轮询检测到之前就被判稳定、误删备份。 + private static readonly STABILITY_DWELL_MS = 30000; + // "待验证"最长有效期:超过则后续 error 视为与本次更新无关,不再触发回滚(防陈旧 pending 误回滚) + private static readonly PENDING_MAX_AGE_MS = 5 * 60 * 1000; constructor(logManager: LogManager) { this.logManager = logManager; @@ -40,6 +60,11 @@ export class CoreUpdateService { this.proxyManager = proxyManager; } + /** 注入配置读取器(用于读取"仅兼容版本带内更新"开关)。 */ + setConfigProvider(provider: () => Promise): void { + this.configProvider = provider; + } + /** * 检查核心更新 */ @@ -71,6 +96,39 @@ export class CoreUpdateService { ); if (this.compareVersions(latestVersion, currentVersion) > 0) { + // 跳过曾预检/启动失败的问题版本(手动更新可绕过) + if (this.isKnownBad(latestVersion)) { + this.logManager.addLog( + 'info', + `最新版本 ${latestVersion} 曾验证失败,已跳过自动更新`, + 'CoreUpdateService' + ); + return { + hasUpdate: false, + currentVersion, + latestVersion, + error: `版本 ${latestVersion} 与当前配置不兼容(已跳过);如需仍可手动更新`, + }; + } + + // 版本带闸门:默认不自动跨越配置生成器已验证的版本带上限(防 schema 破坏导致无法解析) + if (await this.isRestrictToCompatibleMinor()) { + const latest = this.encodeMajorMinor(latestVersion); + if (!isNaN(latest) && latest > COMPATIBLE_CEILING) { + this.logManager.addLog( + 'info', + `最新版本 ${latestVersion} 跨越兼容版本带(>1.13),不自动更新`, + 'CoreUpdateService' + ); + return { + hasUpdate: false, + currentVersion, + latestVersion, + error: `新版本 ${latestVersion} 跨越兼容版本带,建议随 App 升级(可在设置关闭"仅兼容版本带内更新"以手动尝试)`, + }; + } + } + // 找到适合当前平台的资源 const asset = this.findSuitableAsset(latestRelease.assets); if (asset) { @@ -111,6 +169,7 @@ export class CoreUpdateService { } this.isUpdating = true; + let backupMade = false; try { // 1. 下载文件 @@ -122,6 +181,21 @@ export class CoreUpdateService { this.logManager.addLog('info', '正在解压核心文件...', 'CoreUpdateService'); const { corePath, extractDir: tempExtractDir } = await this.extractCore(tempPath); + // 2.5 预检:新核心可执行 + 可解析当前配置。不通过则不动现役核心(代理继续运行,永不 brick)。 + const preflight = await this.preflightValidate(corePath); + if (!preflight.ok) { + if (preflight.version) this.markKnownBad(preflight.version); + try { + fs.unlinkSync(tempPath); + if (tempExtractDir && fs.existsSync(tempExtractDir)) { + fs.rmSync(tempExtractDir, { recursive: true, force: true }); + } + } catch { + /* ignore */ + } + throw new Error(`核心更新预检失败,已放弃(现役核心继续运行):${preflight.reason}`); + } + // 3. 停止代理 let wasRunning = false; if (this.proxyManager) { @@ -137,6 +211,7 @@ export class CoreUpdateService { // 4. 备份旧核心 await this.backupCurrentCore(); + backupMade = true; // 5. 替换核心 this.logManager.addLog('info', '正在替换核心文件...', 'CoreUpdateService'); @@ -172,6 +247,10 @@ export class CoreUpdateService { fs.chmodSync(targetPath, 0o755); } + // 核心单独更新后,确保 NaiveProxy 库 libcronet 与新核心同目录(官方 sing-box 包不含 libcronet, + // naive 仍依赖随 app 打包的那个;purego 从核心同目录加载)。 + await resourceManager.ensureCronetBeside(targetDir); + // macOS: 清除下载隔离标记并重新 ad-hoc 签名 // 原因: macOS Gatekeeper 对新放入的未公证二进制会拦截执行 (SIGKILL) // xattr -cr 清除 quarantine 标记, codesign --force -s - 重新 ad-hoc 签名使其被系统接受 @@ -192,6 +271,12 @@ export class CoreUpdateService { this.logManager.addLog('info', '核心文件替换成功', 'CoreUpdateService'); + // 标记"待首启验证":稳定运行→删备份(稳定即弃);首启失败→自动回滚(见 index 'error' 钩子)。 + // 同时抑制 ProxyManager 自动重启——让新核心首次异常退出立即上报,而非在坏核心上空转重试。 + this.pendingUpdateVersion = preflight.version; + this.pendingUpdateAt = Date.now(); + this.proxyManager?.setAutoRestartSuppressed(true); + // 6. 清理临时文件 try { fs.unlinkSync(tempPath); @@ -221,8 +306,8 @@ export class CoreUpdateService { const msg = error instanceof Error ? error.message : String(error); this.logManager.addLog('error', `更新核心失败: ${msg}`, 'CoreUpdateService'); - // 尝试恢复备份 - await this.restoreBackup(); + // 尝试恢复备份(仅当本次确已备份;预检阶段失败时尚无新备份,避免误恢复陈旧 .bak 致降级) + if (backupMade) await this.restoreBackup(); throw error; } finally { @@ -290,6 +375,15 @@ export class CoreUpdateService { const data = { version, recordedAt: new Date().toISOString() }; fs.writeFileSync(versionFilePath, JSON.stringify(data, null, 2), 'utf-8'); this.logManager.addLog('info', `已记录成功版本: ${version}`, 'CoreUpdateService'); + + // 该版本成功运行 → 从问题版本名单移除(曾因瞬时原因误标记的版本恢复自动更新资格) + this.clearKnownBad(version); + + // 若处于"更新后待验证"窗口:'started' 仅代表存活 1s、可能假成功,不立即删备份;改启动稳定 + // 观察期,期内无 error 才判稳定→删备份(详见 startStabilityWatch / 修 review #3 假成功删备份) + if (this.pendingUpdateVersion) { + this.startStabilityWatch(); + } } catch (error) { const msg = error instanceof Error ? error.message : String(error); this.logManager.addLog('warn', `记录版本失败: ${msg}`, 'CoreUpdateService'); @@ -462,6 +556,10 @@ export class CoreUpdateService { fs.chmodSync(targetPath, 0o755); } + // 手动替换核心后,确保 libcronet 与新核心同目录(naive 依赖随 app 打包的库;修 review M4, + // 与自动核心更新路径一致) + await resourceManager.ensureCronetBeside(targetDir); + // macOS: 清除隔离标记并重新签名 if (process.platform === 'darwin') { try { @@ -505,6 +603,210 @@ export class CoreUpdateService { this.logManager.addLog('info', '手动替换核心成功', 'CoreUpdateService'); } + // === 核心更新健壮性:预检 / 问题版本跳过 / 备份生命周期 / 自动回滚 === + + /** 取任意 sing-box 二进制的版本号(执行 ` version` 解析);不可执行返回 null。 */ + private async getBinaryVersion(binPath: string): Promise { + try { + const execFileAsync = require('util').promisify(require('child_process').execFile); + const { stdout } = await execFileAsync(binPath, ['version']); + const m = String(stdout).match(/(?:version\s+|v)(\d+\.\d+(?:\.\d+)?)/i); + return m ? m[1] : null; + } catch { + return null; + } + } + + private getKnownBadPath(): string { + return path.join(app.getPath('userData'), 'core-known-bad.json'); + } + + private loadKnownBad(): string[] { + try { + const p = this.getKnownBadPath(); + if (!fs.existsSync(p)) return []; + const d = JSON.parse(fs.readFileSync(p, 'utf-8')); + return Array.isArray(d?.versions) ? d.versions : []; + } catch { + return []; + } + } + + /** 标记某版本为"问题版本"(预检/启动失败),自动更新将跳过;手动更新仍可强制安装。 */ + private markKnownBad(version: string): void { + try { + const list = this.loadKnownBad(); + if (!list.includes(version)) list.push(version); + fs.writeFileSync( + this.getKnownBadPath(), + JSON.stringify({ versions: list }, null, 2), + 'utf-8' + ); + this.logManager.addLog( + 'warn', + `已标记问题版本 ${version},自动更新将跳过`, + 'CoreUpdateService' + ); + } catch { + /* ignore */ + } + } + + isKnownBad(version: string): boolean { + return this.loadKnownBad().includes(version); + } + + /** 从问题版本名单移除某版本(该版本成功运行后调用,使误标记可恢复)。 */ + private clearKnownBad(version: string): void { + try { + const list = this.loadKnownBad(); + if (!list.includes(version)) return; + const next = list.filter((v) => v !== version); + fs.writeFileSync( + this.getKnownBadPath(), + JSON.stringify({ versions: next }, null, 2), + 'utf-8' + ); + } catch { + /* ignore */ + } + } + + /** 把版本编码为可比较整数 major*1000+minor("1.20.3"→1020),用于版本带比较;无法解析返回 NaN。 */ + private encodeMajorMinor(version: string): number { + const m = version.match(/^(\d+)\.(\d+)/); + return m ? parseInt(m[1], 10) * 1000 + parseInt(m[2], 10) : NaN; + } + + private async isRestrictToCompatibleMinor(): Promise { + try { + if (!this.configProvider) return true; + const cfg = await this.configProvider(); + return cfg?.restrictCoreUpdateToCompatibleMinor !== false; // 默认 true + } catch { + return true; + } + } + + /** + * 核心更新预检:新核心二进制可执行 + 能解析"针对其版本生成的"当前配置(sing-box check)。 + * 在替换现役核心之前调用 —— 不通过则现役核心不动、代理继续运行(永不 brick)。 + */ + private async preflightValidate( + newCorePath: string + ): Promise<{ ok: boolean; version: string | null; reason?: string }> { + const version = await this.getBinaryVersion(newCorePath); + if (!version) { + return { ok: false, version: null, reason: '新核心无法执行(架构不符或文件损坏)' }; + } + + const cfgJson = this.proxyManager ? this.proxyManager.buildPreflightConfigJson(version) : null; + if (!cfgJson) { + // 无活动配置(代理从未启动)→ 二进制可执行即视为通过 + this.logManager.addLog( + 'info', + `预检:新核心 ${version} 可执行(无活动配置,跳过 check)`, + 'CoreUpdateService' + ); + return { ok: true, version }; + } + + const tmpCfg = path.join(app.getPath('temp'), `flowz-preflight-${Date.now()}.json`); + try { + fs.writeFileSync(tmpCfg, cfgJson, 'utf-8'); + const execFileAsync = require('util').promisify(require('child_process').execFile); + await execFileAsync(newCorePath, ['check', '-c', tmpCfg]); + this.logManager.addLog( + 'info', + `预检通过:新核心 ${version} 可解析当前配置`, + 'CoreUpdateService' + ); + return { ok: true, version }; + } catch (e: any) { + const detail = String(e?.stderr || e?.message || e).split('\n')[0]; + return { ok: false, version, reason: `新核心无法解析当前配置:${detail}` }; + } finally { + try { + fs.unlinkSync(tmpCfg); + } catch { + /* ignore */ + } + } + } + + /** 删除旧核心备份(新核心已稳定运行后调用 —— 稳定即不再回滚,省磁盘)。 */ + private pruneBackup(): void { + try { + const bak = this.getBackupPath(); + if (fs.existsSync(bak)) { + fs.unlinkSync(bak); + this.logManager.addLog('info', '新核心已稳定运行,已删除旧核心备份', 'CoreUpdateService'); + } + } catch { + /* ignore */ + } + } + + /** + * 新核心首启成功后启动"稳定观察期":STABILITY_DWELL_MS 内无 'error' 才判定稳定 → 删旧备份、清待 + * 验证标记、恢复自动重启。期内若 'error' 触发 autoRollback 会先取消本计时器(备份仍在,可回滚)。 + */ + private startStabilityWatch(): void { + if (this.stabilityTimer) clearTimeout(this.stabilityTimer); + const watching = this.pendingUpdateVersion; + this.stabilityTimer = setTimeout(() => { + this.stabilityTimer = null; + if (this.pendingUpdateVersion !== watching) return; // 已被回滚/其它路径清除 + this.logManager.addLog( + 'info', + `新核心 ${watching} 已稳定运行 ${CoreUpdateService.STABILITY_DWELL_MS / 1000}s,删除旧核心备份`, + 'CoreUpdateService' + ); + this.pruneBackup(); + this.pendingUpdateVersion = null; + this.proxyManager?.setAutoRestartSuppressed(false); + }, CoreUpdateService.STABILITY_DWELL_MS); + } + + /** + * 更新后新核心"首次启动失败"时调用(由上层在 proxy 'error' 事件中触发):回滚到备份核心并标记 + * 问题版本。返回 true 表示已回滚(调用方应以旧核心重启代理)。先清除待验证标记防重入/回滚循环。 + */ + async autoRollbackIfPendingUpdate(): Promise { + if (this.stabilityTimer) { + clearTimeout(this.stabilityTimer); + this.stabilityTimer = null; + } + const pending = this.pendingUpdateVersion; + const pendingAge = Date.now() - this.pendingUpdateAt; + this.pendingUpdateVersion = null; + this.proxyManager?.setAutoRestartSuppressed(false); // 回滚后旧核心恢复正常自动重启 + if (!pending) return false; + // 过期保护:距本次更新过久仍未成功运行,此刻的 error 多半与核心更新无关(如用户改了配置)→ 不回滚 + if (pendingAge > CoreUpdateService.PENDING_MAX_AGE_MS) { + this.logManager.addLog( + 'info', + `待验证版本 ${pending} 距更新已超 ${CoreUpdateService.PENDING_MAX_AGE_MS / 60000} 分钟仍未成功运行,本次错误不触发回滚`, + 'CoreUpdateService' + ); + return false; + } + if (!this.hasBackup()) return false; + this.logManager.addLog( + 'error', + `新核心 ${pending} 启动失败,自动回滚到备份核心`, + 'CoreUpdateService' + ); + try { + await this.rollbackCore(); // 恢复 .bak → 现役(内部停代理 + 删备份 + 记录版本) + this.markKnownBad(pending); + return true; + } catch (e) { + this.logManager.addLog('error', `自动回滚失败: ${e}`, 'CoreUpdateService'); + return false; + } + } + /** * 版本记录文件路径 */ diff --git a/src/main/services/ProxyManager.ts b/src/main/services/ProxyManager.ts index a5b35b07..5bb36d0e 100644 --- a/src/main/services/ProxyManager.ts +++ b/src/main/services/ProxyManager.ts @@ -191,6 +191,8 @@ interface SingBoxOutbound { password: string; }; network?: string; + // naive specific: 走 HTTP/3 (QUIC) 传输 + quic?: boolean; // TUIC specific congestion_control?: string; udp_relay_mode?: string; @@ -217,14 +219,28 @@ interface SingBoxOutbound { public_key: string; short_id: string; }; + ech?: { enabled: boolean }; + fragment?: boolean; }; // Transport transport?: { type: string; path?: string; + host?: string; headers?: Record; service_name?: string; }; + // Multiplex 多路复用 + multiplex?: { + enabled: boolean; + protocol?: string; + max_connections?: number; + min_streams?: number; + padding?: boolean; + }; + // Hysteria2 端口跳跃 + server_ports?: string[]; + hop_interval?: string; // DNS resolver for outbound server domain domain_resolver?: string; // UDP over TCP (UoT) @@ -242,6 +258,10 @@ interface SingBoxOutbound { host_key?: string[]; host_key_algorithms?: string[]; client_version?: string; + // selector specific(用于 clash_api 热切换节点:default=当前选中,interrupt_exist_connections=切换时是否中断现有连接) + outbounds?: string[]; + default?: string; + interrupt_exist_connections?: boolean; } interface SingBoxRouteRule { @@ -315,11 +335,13 @@ export interface IProxyManager { start(config: UserConfig): Promise; stop(): Promise; restart(config: UserConfig): Promise; + switchMode(config: UserConfig): Promise; getStatus(): ProxyStatus; generateSingBoxConfig(config: UserConfig, resolvedIps?: Record): SingBoxConfig; on(event: 'started' | 'stopped' | 'error', listener: (...args: any[]) => void): void; off(event: 'started' | 'stopped' | 'error', listener: (...args: any[]) => void): void; getCoreVersion(): Promise; + buildPreflightConfigJson(targetVersion: string): string | null; } export class ProxyManager extends EventEmitter implements IProxyManager { @@ -328,6 +350,8 @@ export class ProxyManager extends EventEmitter implements IProxyManager { private pid: number | null = null; private singboxPid: number | null = null; // macOS TUN 模式下实际的 sing-box PID private currentConfig: UserConfig | null = null; + // 启动时生成的「节点 id → selector 成员 tag」映射,用于 clash_api 热切换时定位目标 tag + private currentIdToTagMap: Map | null = null; private configPath: string; private singboxPath: string; private logManager: ILogManager | null = null; @@ -346,6 +370,9 @@ export class ProxyManager extends EventEmitter implements IProxyManager { // 自动重启相关 private autoRestartEnabled: boolean = true; + // 核心更新"待验证窗口"内由 CoreUpdateService 置 true:抑制自动重启,让新核心首次异常退出立即 + // 上报 'error' 触发回滚(而非在已知有问题的新核心上空转 MAX_RESTART_COUNT 次后才上报)。 + private autoRestartSuppressed: boolean = false; private restartCount: number = 0; private lastRestartTime: number = 0; private static readonly MAX_RESTART_COUNT = 3; // 最大重启次数 @@ -433,6 +460,11 @@ export class ProxyManager extends EventEmitter implements IProxyManager { await this.copyRuleSetsToUserData(); // 3.5. 预解析所有节点的域名为 IP(仅针对 TUN 模式),防止 Windows 下回流死循环 + // 【待真机验证 / CDN 风险】:对"域名节点"预解析得到的 IP 会进 route_exclude_address,若节点在 + // 共享 CDN(如 Cloudflare) 后,等于把共享 IP 加直连 → 误伤同 IP 的被墙站点、且抗不住 IP 轮换。 + // 现已在 generateRouteConfig 用"全节点域名 → direct"的纯域名规则(sniff SNI)做 CDN 安全豁免; + // 此处 Windows IP 预解析是否仍为断环所必需,需 Wintun 真机验证:若域名规则+sniff 足够 → 删除本块; + // 若 Windows 确需 IP 级兜底 → 应仅对 IP-literal 节点保留、域名节点不预解析。无 Windows 环境暂不擅改。 const resolvedServerIps: Record = {}; if (isTunMode && process.platform === 'win32') { this.logToManager('info', '正在预解析节点域名以防止 TUN 回流...'); @@ -513,6 +545,38 @@ export class ProxyManager extends EventEmitter implements IProxyManager { if (config.proxyModeType === 'systemProxy') { await this.setSystemProxy(config); } + + // H3 修复:sing-box 的 cache_file 会持久化 selector 的 clash_api 选择,重启后缓存会覆盖 config + // 的 default。故启动后用 clash_api 把 selector 校正回 config.selectedServerId,让 FlowZ 配置成为 + // 单一真值、压过缓存。best-effort(不阻塞启动成功)。 + void this.reassertSelectorSelection(config); + } + + /** + * 启动后把 selector 选择校正回 config.selectedServerId(压过 cache_file 持久化的旧选择,修 H3)。 + * best-effort + 短重试,clash_api 刚起可能未就绪;失败不影响启动(cache/default 仍是有效节点)。 + */ + private async reassertSelectorSelection(config: UserConfig): Promise { + for (let i = 0; i < 10; i++) { + if (!this.singboxProcess && !this.singboxPid) return; // 已停止则放弃 + // 每轮读最新 currentConfig.selectedServerId:若启动窗口内用户已热切到别的节点,则用新节点、 + // 不要把它 revert 回启动时的旧节点。 + const targetId = this.currentConfig?.selectedServerId ?? config.selectedServerId; + const tag = this.currentIdToTagMap?.get(targetId as string); + if (!tag) return; + try { + const res = await fetch('http://127.0.0.1:9090/proxies/proxy-selector', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: tag }), + signal: AbortSignal.timeout(2000), + }); + if (res.ok) return; + } catch { + // clash_api 未就绪/瞬时失败 → 短延迟后重试 + } + await new Promise((r) => setTimeout(r, 300)); + } } /** @@ -530,6 +594,8 @@ export class ProxyManager extends EventEmitter implements IProxyManager { } await this.stopSingBoxProcess(); + // 进程已停 → 清掉旧的 id→tag 映射,防止对一个已不存在的 selector 误发 clash_api 切换 + this.currentIdToTagMap = null; } /** @@ -545,77 +611,79 @@ export class ProxyManager extends EventEmitter implements IProxyManager { * 检测模式变化,如果代理正在运行则重启 */ async switchMode(newConfig: UserConfig): Promise { - // 检查是否有模式变化 - const modeChanged = this.hasModeChanged(newConfig); - - if (!modeChanged) { - // 模式没有变化,只更新配置 + // 代理未运行:只更新配置(下次 start 时按新配置生成) + if (!this.singboxProcess && !this.singboxPid) { this.currentConfig = newConfig; return; } - // 如果代理正在运行,需要重启 - if (this.singboxProcess) { - this.logToManager('info', '代理模式已更改,正在重启代理...'); - await this.restart(newConfig); - } else { - // 代理未运行,只更新配置 - this.currentConfig = newConfig; + // 唯一变化是「切节点」→ clash_api 热切换 selector,不重启 sing-box(优雅切换,连接保留与否由 + // selector.interrupt_exist_connections 开关决定)。失败则退回重启式切换,保证一定能应用。 + if (this.canHotSwitch(newConfig)) { + if (await this.hotSwitchNode(newConfig)) { + this.currentConfig = newConfig; + return; + } + this.logToManager('warn', '热切换失败,退回重启式切换'); } + + // 其余变化(模式/端口/TUN/规则/节点集合/interrupt 开关 等需重生成配置的项)→ 重启应用。 + this.logToManager('info', '配置已更改,正在重启代理以应用...'); + await this.restart(newConfig); } /** - * 检查模式是否变化 + * 是否可走 clash_api 热切换:当且仅当唯一变化是「切到一个已在运行中 selector 里的节点」, + * 其余影响配置生成的项(模式/端口/TUN/customRules/servers 集合/appRules/interrupt 开关)都未变。 */ - private hasModeChanged(newConfig: UserConfig): boolean { - if (!this.currentConfig) { - return true; - } - - // 检查代理模式 - if (this.currentConfig.proxyMode !== newConfig.proxyMode) { - return true; - } - - // 检查代理模式类型 - if (this.currentConfig.proxyModeType !== newConfig.proxyModeType) { - return true; - } - - // 检查选中的服务器 - if (this.currentConfig.selectedServerId !== newConfig.selectedServerId) { - return true; - } - - // 检查端口 - if ( - this.currentConfig.socksPort !== newConfig.socksPort || - this.currentConfig.httpPort !== newConfig.httpPort - ) { - return true; - } - - // 检查 TUN 配置(如果是 TUN 模式) - if (newConfig.proxyModeType === 'tun') { - const oldTun = this.currentConfig.tunConfig; - const newTun = newConfig.tunConfig; + private canHotSwitch(newConfig: UserConfig): boolean { + const old = this.currentConfig; + if (!old) return false; + // 必须确实是切节点 + if (old.selectedServerId === newConfig.selectedServerId) return false; + if (!newConfig.selectedServerId) return false; + // 目标节点必须已存在于运行中的 selector(= 启动时的 servers),否则 PUT 指向不存在的成员 + if (!old.servers.some((s) => s.id === newConfig.selectedServerId)) return false; + // Windows TUN:route_exclude_address 在 start 时仅按「选中 + appRules」节点构建;热切到其它 + // IP-literal 节点时其 server IP 不在排除集 → Wintun 会把 sing-box 自身出向包回捕进 TUN 成环。 + // 配置不重生成无法补排除集,故 Windows TUN 一律退回重启(由重启重生成排除集)。 + if (process.platform === 'win32' && newConfig.proxyModeType === 'tun') return false; + // 唯一允许变化的就是 selectedServerId:对齐它、servers 按 id 归一化后整体深比较——任何其它影响 + // 配置生成的字段(blockQuic/tlsFragment/dnsConfig/各 TUN 子字段/appRules/customRules/端口/interrupt + // 开关 等)有差异都退回重启,避免「切节点 + 改某设置」同时发生时把那个设置静默丢掉。 + const norm = (c: UserConfig) => + JSON.stringify({ + ...c, + selectedServerId: null, + servers: [...c.servers].sort((a, b) => a.id.localeCompare(b.id)), + }); + return norm(old) === norm(newConfig); + } - if ( - oldTun.mtu !== newTun.mtu || - oldTun.stack !== newTun.stack || - oldTun.autoRoute !== newTun.autoRoute || - oldTun.strictRoute !== newTun.strictRoute - ) { - return true; + /** + * 通过 clash_api `PUT /proxies/proxy-selector` 把 selector 切到目标节点(无需重启)。 + * 成功返回 true;任何异常/非 2xx 返回 false(调用方退回重启)。 + */ + private async hotSwitchNode(newConfig: UserConfig): Promise { + const targetTag = this.currentIdToTagMap?.get(newConfig.selectedServerId as string); + if (!targetTag) return false; + try { + const res = await fetch('http://127.0.0.1:9090/proxies/proxy-selector', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: targetTag }), + signal: AbortSignal.timeout(2000), + }); + if (!res.ok) { + this.logToManager('warn', `clash_api 热切换返回 HTTP ${res.status}`); + return false; } - } - - // 检查自定义规则 - if (JSON.stringify(this.currentConfig.customRules) !== JSON.stringify(newConfig.customRules)) { + this.logToManager('info', `已热切换节点 → ${targetTag}(clash_api,无重启)`); return true; + } catch (e: any) { + this.logToManager('warn', `clash_api 热切换异常: ${e?.message ?? e}`); + return false; } - - return false; } /** @@ -679,6 +747,26 @@ export class ProxyManager extends EventEmitter implements IProxyManager { } } + /** + * 为「核心更新预检」生成针对目标版本的配置 JSON:用当前活动配置 + 目标核心版本的生成风格 + * (<1.13 走 inbound sniff,≥1.13 走 route action),供 sing-box check 校验新核心能否解析。 + * 无活动配置(代理从未启动)时返回 null —— 调用方仅校验二进制可执行即可。 + */ + buildPreflightConfigJson(targetVersion: string): string | null { + if (!this.currentConfig) return null; + const savedVersion = this.coreVersion; + try { + this.coreVersion = targetVersion; + const cfg = this.generateSingBoxConfig(this.currentConfig); + return JSON.stringify(cfg, null, 2); + } catch (e) { + this.logToManager('warn', `预检配置生成失败: ${(e as any)?.message ?? e}`); + return null; + } finally { + this.coreVersion = savedVersion; + } + } + /** * 生成 sing-box 配置(sing-box 1.12.x / 1.13.x 兼容格式) */ @@ -687,6 +775,12 @@ export class ProxyManager extends EventEmitter implements IProxyManager { if (!selectedServer) { throw new Error('Selected server not found'); } + // 选中节点不可用(naive 缺 libcronet)→ 明确报错,不静默切到别的节点(修 review M1) + if (!this.isNodeUsable(selectedServer)) { + throw new Error( + `选中的节点「${selectedServer.name}」是 NaiveProxy,但未找到 libcronet 核心库(macOS 暂无官方预编译库)。请选择其它协议的节点。` + ); + } // 调试日志 console.log('[ProxyManager] Generating config with:', { @@ -703,7 +797,8 @@ export class ProxyManager extends EventEmitter implements IProxyManager { // 关键优化:预先生成 ID 到 Tag 的唯一映射,使用服务器名称作为 Tag,确保拓扑和日志显示友好名称 // 这样做之后内容拓扑(Clash API)和日志中显示的将是“香港 01”而不是“proxy-uuid” const idToTagMap = new Map(); - const usedTags = new Set(); + // 预占内置出站 tag,防止用户把节点命名为 proxy-selector/direct/block 等导致 tag 撞车启动 FATAL + const usedTags = new Set(['proxy-selector', 'direct', 'block', 'direct-loopback']); const getUniqueTag = (server: ServerConfig) => { let baseTag = server.name.trim() || '未命名节点'; @@ -721,13 +816,12 @@ export class ProxyManager extends EventEmitter implements IProxyManager { for (const s of config.servers) { idToTagMap.set(s.id, getUniqueTag(s)); } + // 记录本次生成的 id→tag 映射,供 clash_api 热切换定位 selector 成员 tag(见 hotSwitchNode) + this.currentIdToTagMap = idToTagMap; const singboxConfig: SingBoxConfig = { log: this.generateLogConfig(config), - dns: this.generateDnsConfig( - config, - idToTagMap.get(config.selectedServerId as string) || 'proxy' - ), + dns: this.generateDnsConfig(config, 'proxy-selector'), inbounds: this.generateInbounds(config, resolvedIps), outbounds: this.generateOutbounds(selectedServer, config, idToTagMap), route: this.generateRouteConfig(config, idToTagMap), @@ -747,6 +841,19 @@ export class ProxyManager extends EventEmitter implements IProxyManager { }, }; + // 路由规则若指向「已被跳过/不存在的出站」(如缺 libcronet 被跳过的 naive 节点),sing-box 会以 + // "outbound not found" 启动失败。统一把这类死引用修正为 selector(修 review H2:app/custom 分流 + // 指向被跳过 naive 节点的情况)。 + const validTags = new Set( + singboxConfig.outbounds.map((o) => o.tag).filter((t): t is string => !!t) + ); + for (const rule of singboxConfig.route?.rules ?? []) { + const r = rule as { action?: string; outbound?: string }; + if (r.action === 'route' && r.outbound && !validTags.has(r.outbound)) { + r.outbound = 'proxy-selector'; + } + } + // 调试日志 console.log('[ProxyManager] Generated inbounds count:', singboxConfig.inbounds.length); console.log('[ProxyManager] Generated outbounds count:', singboxConfig.outbounds.length); @@ -1148,7 +1255,7 @@ export class ProxyManager extends EventEmitter implements IProxyManager { route_exclude_address: excludeAddr, }; - // 兼容 sing-box 1.12.x 版本 (即 Windows 上的 1.12.13),必须在 inbound 定义 sniff 否则无法域名分流。 + // 兼容 sing-box 1.12.x 版本(打包核心现已全部 ≥1.13.13,此分支仅为向后兼容旧 userData 核心保留),必须在 inbound 定义 sniff 否则无法域名分流。 // 对于 1.13.0+,嗅探逻辑已经统一由后方 route.rules 承担,但在入站开启会报错,因此需精准版本判断。 const inboundVersionMatch = this.coreVersion.match(/^(\d+\.\d+)/); const inboundVersionNum = inboundVersionMatch ? parseFloat(inboundVersionMatch[1]) : 1.13; @@ -1173,35 +1280,12 @@ export class ProxyManager extends EventEmitter implements IProxyManager { return inbounds; } - /** - * 递归获取代理链中的所有前置节点 - */ - private getDetourChain(server: ServerConfig, allServers: ServerConfig[]): ServerConfig[] { - const chain: ServerConfig[] = []; - const visitedIds = new Set(); - visitedIds.add(server.id); - - let currentServer = server; - while (currentServer.detour) { - if (visitedIds.has(currentServer.detour)) { - console.warn( - `[ProxyManager] Detected proxy chain loop: ${currentServer.name} -> ${currentServer.detour}` - ); - break; - } - - const detourServer = allServers.find((s) => s.id === currentServer.detour); - if (!detourServer) { - console.warn(`[ProxyManager] Detour server not found: ${currentServer.detour}`); - break; - } - - chain.push(detourServer); - visitedIds.add(detourServer.id); - currentServer = detourServer; + /** 节点是否可用:naive 需要 libcronet 核心库,缺库时不可用(会被跳过、分流/选中回退到 selector)。 */ + private isNodeUsable(server: ServerConfig): boolean { + if (server.protocol.toLowerCase() === 'naive' && !resourceManager.hasCronetLib()) { + return false; } - - return chain; + return true; } private generateOutbounds( @@ -1212,84 +1296,80 @@ export class ProxyManager extends EventEmitter implements IProxyManager { const outbounds: SingBoxOutbound[] = []; if (config) { - // 1. 生成主选节点的 Outbound 及其前置节点 - const mainChain = this.getDetourChain(selectedServer, config.servers); - - // 添加前置节点 - for (const detourServer of mainChain.reverse()) { - const detourOutbound = this.generateProxyOutbound(detourServer, idToTagMap); - detourOutbound.tag = idToTagMap.get(detourServer.id) || `proxy-${detourServer.id}`; - // 避免重复添加 - if (!outbounds.some((o) => o.tag === detourOutbound.tag)) { - outbounds.push(detourOutbound); - } - } - - // 添加主节点 - const mainOutbound = this.generateProxyOutbound(selectedServer, idToTagMap); - // 主节点默认使用 'proxy' tag,为了兼容老的路由规则 - // 但对于拓扑显示,我们希望看到名称,所以这里我们保留一个名为 'proxy' 的 outbound - // 并在最后将其 tag 设为服务器名称(或者通过 detour 链处理) - // 这里的策略是:主选节点 tag 设为人类可读名称,并同步给路由规则使用。 - const selectedServerTag = idToTagMap.get(selectedServer.id) || 'proxy'; - mainOutbound.tag = selectedServerTag; - - if (selectedServer.detour && config.servers.some((s) => s.id === selectedServer.detour)) { - mainOutbound.detour = idToTagMap.get(selectedServer.detour); - } - outbounds.push(mainOutbound); - - // 2. 生成自定义规则中指定的目标节点的 Outbound - // 遍历所有启用且指定了 targetServerId 的规则(包括 customRules 和 appRules) - const targetServerIds = new Set(); - if (config.customRules) { - for (const rule of config.customRules) { - if (rule.enabled && rule.action === 'proxy' && rule.targetServerId) { - targetServerIds.add(rule.targetServerId); - } + // 生成【全部】节点的 Outbound:selector 需要列出所有可切换节点;detour 前置节点亦在 config.servers + // 中,一并生成、通过 detour 字段链接。单个节点配置异常不应拖垮整体配置,逐节点 try/catch 跳过。 + // (app/custom 分流规则指向的固定节点 tag 不变,仍直接命中其节点出站,不经 selector。) + for (const server of config.servers) { + const tag = idToTagMap.get(server.id) || `proxy-${server.id}`; + if (outbounds.some((o) => o.tag === tag)) continue; // 去重 + // 不可用节点跳过:naive 缺 libcronet 时,sing-box 启动会预初始化全部出站、缺库的 naive 会让 + // 整个代理启动 FATAL(连非 naive 节点也用不了)。跳过后,路由层对该节点 tag 的死引用会在 + // generateSingBoxConfig 末尾被统一修正为 selector(见 H2 修复)。 + if (!this.isNodeUsable(server)) { + this.logToManager( + 'warn', + `跳过不可用节点「${server.name}」:NaiveProxy 缺少 libcronet 核心库` + ); + continue; } - } - if (config.appRules) { - for (const rule of config.appRules) { - if (rule.enabled && rule.action === 'proxy' && rule.targetServerId) { - targetServerIds.add(rule.targetServerId); + try { + const ob = this.generateProxyOutbound(server, idToTagMap); + ob.tag = tag; + if (server.detour && config.servers.some((s) => s.id === server.detour)) { + // 环检测:沿 detour 链行进,若回到本节点即成环 → 不设 detour,避免 sing-box 报循环引用启动失败 + const seen = new Set([server.id]); + let cur: string | undefined = server.detour; + let looped = false; + while (cur) { + if (seen.has(cur)) { + looped = true; + break; + } + seen.add(cur); + cur = config.servers.find((s) => s.id === cur)?.detour; + } + if (looped) { + this.logToManager('warn', `检测到代理链成环,已跳过 detour: ${server.name}`); + } else { + ob.detour = idToTagMap.get(server.detour); + } } + outbounds.push(ob); + } catch (e: any) { + this.logToManager( + 'warn', + `生成节点出站失败,已跳过: ${server.name} (${e?.message ?? e})` + ); } } - for (const targetId of Array.from(targetServerIds)) { - // 如果目标节点就是主节点,不需要额外添加(主节点已有 'proxy' tag) - if (targetId === selectedServer.id) continue; - - // 查找目标服务器配置 - const targetServer = config.servers.find((s) => s.id === targetId); - if (!targetServer) continue; - - // 获取目标节点的前置链 - const targetChain = this.getDetourChain(targetServer, config.servers); - - // 添加目标节点的前置节点 - for (const detourServer of targetChain.reverse()) { - const detourOutbound = this.generateProxyOutbound(detourServer, idToTagMap); - detourOutbound.tag = idToTagMap.get(detourServer.id) || `proxy-${detourServer.id}`; - // 避免重复添加 - if (!outbounds.some((o) => o.tag === detourOutbound.tag)) { - outbounds.push(detourOutbound); + // 全局 TLS 分片(PR-6):开启后对所有已生成的 TCP-TLS 节点出站切分 ClientHello,抗 SNI-DPI。 + // 跳过 hy2/tuic(QUIC 内 TLS、无 TCP ClientHello,死配置)与 naive(Cronet 自管 TLS,拒绝 + // fragment 字段 → 启动 FATAL)。 + if (config.tlsFragment) { + for (const ob of outbounds) { + if (ob.tls && ob.type !== 'hysteria2' && ob.type !== 'tuic' && ob.type !== 'naive') { + ob.tls.fragment = true; } } + } - // 添加目标节点本身 - const targetOutbound = this.generateProxyOutbound(targetServer, idToTagMap); - targetOutbound.tag = idToTagMap.get(targetServer.id) || `proxy-${targetServer.id}`; // 使用节点名称作为 Tag - if (targetServer.detour && config.servers.some((s) => s.id === targetServer.detour)) { - targetOutbound.detour = idToTagMap.get(targetServer.detour); - } - - // 避免重复添加 - if (!outbounds.some((o) => o.tag === targetOutbound.tag)) { - outbounds.push(targetOutbound); - } + // selector:列出已生成的全部节点 tag,default 指向当前选中节点;interrupt_exist_connections 由用户 + // 开关决定(默认 false=优雅切换,现有连接保留至自然关闭)。clash_api `PUT /proxies/proxy-selector` + // 据此热切换、无需重启 sing-box(详见 switchMode)。路由的 final 与「→代理」规则统一指向本 selector。 + const nodeTags = outbounds.map((o) => o.tag).filter((t): t is string => !!t); + // 所有节点生成失败 → 空 selector 会让 sing-box 启动报含糊错误;这里提前给出清晰原因 + if (nodeTags.length === 0) { + throw new Error('没有可用的代理节点出站(所有节点配置生成失败)'); } + const selectedServerTag = idToTagMap.get(selectedServer.id) || 'proxy'; + outbounds.push({ + type: 'selector', + tag: 'proxy-selector', + outbounds: nodeTags, + default: nodeTags.includes(selectedServerTag) ? selectedServerTag : nodeTags[0], + interrupt_exist_connections: config.interruptConnectionsOnSwitch === true, + }); } else { // Fallback if config is missing (shouldn't happen) outbounds.push(this.generateProxyOutbound(selectedServer, idToTagMap)); @@ -1324,11 +1404,8 @@ export class ProxyManager extends EventEmitter implements IProxyManager { // 为每个使用 Shadow-TLS 的节点插入内层 SS outbound const stlsOutbounds: SingBoxOutbound[] = []; for (const ob of outbounds) { - // 根据 tag 找到对应的 ServerConfig - const srv = - ob.tag === 'proxy' - ? selectedServer - : config?.servers.find((s) => `proxy-${s.id}` === ob.tag); + // 根据 tag(节点名称)反查对应的 ServerConfig;selector/direct/block 等非节点出站匹配不到 → 跳过 + const srv = config?.servers.find((s) => idToTagMap.get(s.id) === ob.tag); if (srv?.shadowTlsSettings) { // 创建独立的外层 ShadowTLS outbound const stlsTag = `stls-out-${srv.id}`; @@ -1501,6 +1578,15 @@ export class ProxyManager extends EventEmitter implements IProxyManager { enabled: true, server_name: server.tlsSettings?.serverName || server.address, }; + + // HTTP/3:naive 经 quic:true 走 h3(QUIC/UDP) 拨号传输,对应服务端 `--listen=quic://`。 + // 注意这只改变"拨号传输":naive 仍只过 TCP(HTTP CONNECT)、不能中继客户端 UDP(除非 + // udp_over_tcp,FlowZ 不下发)。客户端 QUIC 若走到 naive,由 blockQuic(udp443 reject 逼回退 TCP) + // 或 sing-box 出站层("UDP is not supported by outbound")处理;该拨号本身受 fwmark 保护、 + // 绕过 route 规则,不被 reject 误杀(已实测)。 + if (server.naiveSettings?.useHttp3) { + outbound.quic = true; + } } // SOCKS 特定配置 @@ -1620,9 +1706,66 @@ export class ProxyManager extends EventEmitter implements IProxyManager { outbound.transport = this.generateTransportConfig(server); } + // PR-6 抗封增强(ECH / TLS 分片 / Multiplex / Hy2 端口跳跃)统一后处理 + this.applyAntiCensorshipOptions(outbound, server); + return outbound; } + /** + * PR-6 抗封增强统一后处理:ECH、每节点 TLS 分片、Multiplex(reality+vision 跳过)、Hy2 端口跳跃。 + * 放在 outbound 构建完成后统一处理,避免散落到各协议的 tls/transport 构建点。 + */ + private applyAntiCensorshipOptions(outbound: SingBoxOutbound, server: ServerConfig): void { + const protocolLower = server.protocol.toLowerCase(); + + // fragment 仅对「标准 sing-box TCP-TLS 栈」有意义,以下协议必须排除(否则死配置或直接启动 FATAL): + // · hy2/tuic:TLS 在 QUIC 内、无 TCP ClientHello(死配置); + // · naive:TLS 由 Cronet 自管,naive 出站直接拒绝 fragment 字段(实测 + // "fragment is not supported on naive outbound" → 启动 FATAL),无论 h2/h3。 + // 注:ECH 不受此限——QUIC 与 naive(Cronet) 均原生支持 ECH。 + const fragmentUnsupported = + protocolLower === 'hysteria2' || protocolLower === 'tuic' || protocolLower === 'naive'; + + // ECH(隐藏 SNI)+ 每节点 TLS 分片(抗 SNI-DPI):需已有 tls 块 + if (outbound.tls) { + if (server.tlsSettings?.ech) outbound.tls.ech = { enabled: true }; + if (server.tlsSettings?.fragment && !fragmentUnsupported) outbound.tls.fragment = true; + } + + // Multiplex(vless/trojan/vmess/shadowsocks);vision flow(xtls-rprx-vision) 自带流分帧、与 mux + // 不兼容(与是否 reality 无关,普通 TLS+vision 同样不兼容)→ 跳过 + const mux = server.multiplexSettings; + const hasVisionFlow = (server.flow || '').toLowerCase().includes('vision'); + if ( + mux?.enabled && + ['vless', 'trojan', 'vmess', 'shadowsocks'].includes(protocolLower) && + !hasVisionFlow + ) { + outbound.multiplex = { + enabled: true, + protocol: mux.protocol || 'h2mux', + ...(mux.maxConnections ? { max_connections: mux.maxConnections } : {}), + ...(mux.minStreams ? { min_streams: mux.minStreams } : {}), + ...(mux.padding ? { padding: true } : {}), + }; + } + + // Hysteria2 端口跳跃(serverPorts 为逗号分隔的范围串,如 "20000:30000",支持多段) + if (protocolLower === 'hysteria2' && server.hysteria2Settings?.serverPorts) { + const ports = server.hysteria2Settings.serverPorts + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (ports.length > 0) { + outbound.server_ports = ports; + if (server.hysteria2Settings.hopInterval) { + outbound.hop_interval = server.hysteria2Settings.hopInterval; + } + } + } + } + /** * 生成传输层配置 */ @@ -1642,6 +1785,15 @@ export class ProxyManager extends EventEmitter implements IProxyManager { }; } + // httpupgrade:较 ws 更隐蔽的 HTTP Upgrade 传输(复用 ws 的 path / Host) + if (server.network === 'httpupgrade') { + return { + type: 'httpupgrade', + path: server.wsSettings?.path || '/', + host: server.wsSettings?.headers?.['Host'] || server.tlsSettings?.serverName, + }; + } + return undefined; } @@ -1655,9 +1807,28 @@ export class ProxyManager extends EventEmitter implements IProxyManager { const rules: SingBoxRouteRule[] = []; const proxyMode = (config.proxyMode || 'smart').toLowerCase(); - const selectedServerTag = idToTagMap.get(config.selectedServerId as string) || 'proxy'; - // 获取当前选中的服务器,用于排除代理服务器域名 - const selectedServer = config.servers.find((s) => s.id === config.selectedServerId); + // 主代理出站统一走 selector(proxy-selector):clash_api 热切换即改 selector 指向、路由无需重生成。 + // 具体 targetServerId 的 app/custom 分流在各自逻辑里直指节点 tag,不经此变量。 + const selectedServerTag = 'proxy-selector'; + + // blockQuic(节点无关):开启时对"将走代理"的 QUIC(UDP443) 执行 reject,逼浏览器回退 TCP。 + // 「禁 QUIC」即禁 QUIC,与选中节点的协议/中继能力无关,对所有节点一视同仁。两点实测保证安全: + // · 节点自身的 UDP 拨号(naive-h3/hy2/tuic dial server)无害——拨号是 sing-box 进程自有 socket, + // 受 fwmark/auto_detect_interface 保护、绕过 route 规则;netns TUN 抓包实测:带 reject udp443 + // 时 hy2 拨号包仍正常逸出(证伪旧假设"reject 经 strict_route 回流误杀拨号")。 + // · 不下发"全 UDP reject"——只禁 QUIC(443)。非 QUIC 的代理向 UDP 若节点不能中继(naive/ssh/http), + // 由 sing-box 出站层自动拒绝(实测日志 "UDP is not supported by outbound",不漏 direct、不黑洞), + // 无需路由层按节点固化。这也使路由配置与选中节点解耦 → 支持 selector 跨协议无缝热切换。 + // 节点无关:只要开了 blockQuic 且存在代理路径(非 direct 模式、有节点)就拦——不依赖 selectedServer + // 解析成功(避免 selectedServerId 失效但 selector default 仍出流量时 QUIC 漏过)。 + const blockProxyQuic = + config.blockQuic === true && proxyMode !== 'direct' && config.servers.length > 0; + + // 给定域名匹配器,返回应配对的 udp443 reject 规则(smart 模式放在每条 →代理 规则之前),否则 null。 + const proxyUdpRejectFor = (matcher: Record): SingBoxRouteRule | null => + blockProxyQuic + ? ({ ...matcher, network: ['udp'], port: [443], action: 'reject' } as any) + : null; const versionArr = this.coreVersion.split('.'); const versionNum = parseFloat(versionArr[0] + '.' + (versionArr[1] || '0')); @@ -1770,40 +1941,45 @@ export class ProxyManager extends EventEmitter implements IProxyManager { action: 'reject', } as any); - // 排除代理服务器域名,确保代理服务器的连接走直连 - // 这必须放在其他规则之前,否则可能被 geosite-cn 匹配导致死循环 - if (selectedServer?.address) { - const proxyHosts = [selectedServer.address]; - if (selectedServer.tlsSettings?.serverName) { - proxyHosts.push(selectedServer.tlsSettings.serverName); - } - const uniqueHosts = Array.from(new Set(proxyHosts)); - - const ips: string[] = []; - const domains: string[] = []; - + // 排除全部代理节点的域名/IP,确保到任一节点的连接走直连(防回流死循环 + 兼容无缝切换/代理链)。 + // CDN 安全:域名节点用纯域名规则(domain + domain_suffix,靠 sniff 出的 SNI 精确匹配节点域名), + // 不预解析为共享 CDN IP(共享 IP 加直连会误伤同 IP 的被墙站点、且抗不住 IP 轮换); + // 去掉过宽的 domain_keyword(会误匹配任意"含该域名串"的无关域名)。 + // 仅用户显式填的 IP-literal 节点用 ip_cidr 排除(专用 IP、非共享,安全)。 + // 扩展到全部节点(不止选中):切节点 / detour 前置代理无需重生成配置即被豁免。 + // 必须放在其他规则之前,否则可能被 geosite-cn 匹配导致死循环。 + { const isIpv4 = (host: string) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(host); const isIpv6 = (host: string) => /^[0-9a-fA-F:]+$/.test(host) && host.includes(':'); - uniqueHosts.forEach((host) => { - if (isIpv4(host)) ips.push(`${host}/32`); - else if (isIpv6(host)) ips.push(`${host}/128`); - else domains.push(host); - }); + const ipSet = new Set(); + const domainSet = new Set(); + for (const s of config.servers) { + const hosts = [s.address, s.tlsSettings?.serverName].filter( + (h): h is string => !!h && h.length > 0 + ); + for (const host of hosts) { + if (isIpv4(host)) ipSet.add(`${host}/32`); + else if (isIpv6(host)) ipSet.add(`${host}/128`); + else domainSet.add(host); + } + } - if (domains.length > 0) { + if (domainSet.size > 0) { + const domains = Array.from(domainSet); rules.push({ + // domain(精确,= 节点 SNI) + domain_suffix(仅 .${d},匹配子域)。不放裸 d 进 domain_suffix: + // 那是 raw 后缀匹配,会把共享 apex 下别的真实站点也沉降到直连。 domain: domains, - domain_suffix: domains.flatMap((d) => [d, `.${d}`]), - domain_keyword: domains, + domain_suffix: domains.map((d) => `.${d}`), action: 'route', outbound: 'direct', }); } - if (ips.length > 0) { + if (ipSet.size > 0) { rules.push({ - ip_cidr: ips, + ip_cidr: Array.from(ipSet), action: 'route', outbound: 'direct', }); @@ -1870,7 +2046,28 @@ export class ProxyManager extends EventEmitter implements IProxyManager { idToTagMap, selectedServerTag ); - rules.push(...customRules); + // 走代理的自定义规则同样要配对 udp443 reject(终止规则、在末尾兜底前命中)。逐条插入: + // 代理向规则前先放一条同匹配器的 udp443 reject;direct/block 规则不配对。 + for (const cr of customRules) { + if ( + cr.action === 'route' && + cr.outbound && + cr.outbound !== 'direct' && + cr.outbound !== 'block' + ) { + const matcher: Record = {}; + if (cr.domain) matcher.domain = cr.domain; + if (cr.domain_suffix) matcher.domain_suffix = cr.domain_suffix; + if (cr.domain_keyword) matcher.domain_keyword = cr.domain_keyword; + if (cr.rule_set) matcher.rule_set = cr.rule_set; + if (cr.ip_cidr) matcher.ip_cidr = cr.ip_cidr; + if (Object.keys(matcher).length > 0) { + const r = proxyUdpRejectFor(matcher); + if (r) rules.push(r); + } + } + rules.push(cr); + } if (customRuleSets.length > 0) { if (!routeConfig.rule_set) { @@ -1910,8 +2107,16 @@ export class ProxyManager extends EventEmitter implements IProxyManager { outbound = 'block'; } + // 走代理的 app 分流也要配对 udp443 reject(这些是终止规则、在末尾兜底之前命中,否则 blockQuic + // 对该应用的 QUIC 失效)。direct/block 不配对。 + const appOutIsProxy = outbound !== 'direct' && outbound !== 'block'; + // a. 基于进程名的规则(最精准,适用于 macOS/Windows TUN 模式) if (preset.processNames && preset.processNames.length > 0) { + if (appOutIsProxy) { + const r = proxyUdpRejectFor({ process_name: preset.processNames }); + if (r) rules.push(r); + } rules.push({ process_name: preset.processNames, action: 'route', @@ -1926,6 +2131,10 @@ export class ProxyManager extends EventEmitter implements IProxyManager { ]; if (ruleSets.length > 0) { + if (appOutIsProxy) { + const r = proxyUdpRejectFor({ rule_set: ruleSets }); + if (r) rules.push(r); + } rules.push({ rule_set: ruleSets, action: 'route', @@ -2015,13 +2224,22 @@ export class ProxyManager extends EventEmitter implements IProxyManager { // 针对 Google 核心服务(搜索/YouTube/Gmail 等)的关键词兜底规则(仅在未专门设置应用分流时作为备份) // 注意:这些规则在 AppRules 之后,所以不会覆盖用户手动指定的节点 + const googleKeywords = ['google', 'gmail', 'youtube', 'gstatic', 'googleapis', 'googlevideo']; + + // 代理向 UDP(smart):在每条"→代理"规则之前配对一条 reject,使该走代理的 UDP 在被路由到代理 + // 前就 reject——不能中继的节点拦全部 UDP,能中继+blockQuic 仅拦 QUIC(UDP443)。下方 CN 直连规则 + // 不配对,故 CN/直连 UDP 不受影响(兜底见 generateRouteConfig 末尾)。 + const googleUdpReject = proxyUdpRejectFor({ domain_keyword: googleKeywords }); + if (googleUdpReject) rules.push(googleUdpReject); rules.push({ - domain_keyword: ['google', 'gmail', 'youtube', 'gstatic', 'googleapis', 'googlevideo'], + domain_keyword: googleKeywords, action: 'route', outbound: selectedServerTag, }); // 国外域名走代理 + const foreignUdpReject = proxyUdpRejectFor({ rule_set: 'geosite-geolocation-!cn' }); + if (foreignUdpReject) rules.push(foreignUdpReject); rules.push({ rule_set: 'geosite-geolocation-!cn', action: 'route', @@ -2120,38 +2338,11 @@ export class ProxyManager extends EventEmitter implements IProxyManager { } } - // 【UDP 黑洞防范兜底】:对于不支持 UDP 的代理协议(如 SSH, HTTP, AnyTLS), - // 凡是没有被上面的直连规则(如国内 IP/域名、局域网等)匹配的剩余 UDP 流量, - // 原本会分配给 final 出站。但由于这些代理协议在 sing-box 中不支持 UDP, - // 流量会穿透 final 跌落到系统 direct,导致被墙的 UDP 请求(如 QUIC)静默丢包假死 30 秒。 - // 在这里统一 reject 剩余 UDP,促使浏览器立即 fallback 到 TCP 走代理。 - if (proxyMode !== 'direct' && selectedServer) { - const tcpOnlyProtocols = ['ssh', 'http', 'anytls']; - if (tcpOnlyProtocols.includes(selectedServer.protocol.toLowerCase())) { - rules.push({ - network: ['udp'], - action: 'reject', - } as any); - } - - // T2-4: 用户开启"阻止 QUIC"时 reject 入站 UDP 443,让浏览器 QUIC 立即回退 TCP, - // 消除节点 UDP relay 不通时的 QUIC 黑洞(网页打开一会卡死)。协议三分类: - // - ssh/http/anytls:上面已全量 reject UDP,此处对其为冗余 no-op; - // - hysteria2/tuic:节点自身走 QUIC dial-server,会因 strict_route 回流被这条 reject - // 误杀,必须按协议 guard 跳过; - // - vless/vmess/trojan/shadowsocks/naive/socks:本条真正生效对象(均 TCP dial-server; - // naive 在 sing-box 是 H2/TLS/TCP、不支持 h3/QUIC,故安全;若将来支持 naive-h3 需更新此表)。 - const udpTransportProtocols = ['hysteria2', 'tuic']; - if ( - config.blockQuic === true && - !udpTransportProtocols.includes(selectedServer.protocol.toLowerCase()) - ) { - rules.push({ - network: ['udp'], - port: [443], - action: 'reject', - } as any); - } + // 【代理向 QUIC 兜底】:放在所有直连/分流规则之后,拦截"会落到 final(代理)"的剩余 QUIC(udp443)。 + // global 模式拦全部代理向 QUIC;smart 模式拦未被上方 →代理 配对 reject 命中的(CN 已直连豁免)。 + // 只拦 QUIC——非 QUIC 的代理向 UDP 若节点不能中继,由 sing-box 出站层自动拒绝(见上方 blockProxyQuic)。 + if (blockProxyQuic) { + rules.push({ network: ['udp'], port: [443], action: 'reject' } as any); } return routeConfig; @@ -2461,7 +2652,9 @@ export class ProxyManager extends EventEmitter implements IProxyManager { if (!caps.includes('cap_net_admin')) { this.logToManager('info', 'Linux 核心缺失网络权限,正在请求提权...'); // 使用 pkexec 调用 setcap 赋权 - execSync(`pkexec setcap 'cap_net_admin,cap_net_bind_service,cap_net_raw=+ep' "${this.singboxPath}"`); + execSync( + `pkexec setcap 'cap_net_admin,cap_net_bind_service,cap_net_raw=+ep' "${this.singboxPath}"` + ); this.logToManager('info', 'Linux 核心提权成功'); } } catch (err) { @@ -3327,6 +3520,10 @@ export class ProxyManager extends EventEmitter implements IProxyManager { if (!this.autoRestartEnabled || !this.currentConfig) { return false; } + // 核心更新待验证窗口:禁止自动重启,使新核心首次异常退出立即上报 error → 触发回滚 + if (this.autoRestartSuppressed) { + return false; + } const now = Date.now(); @@ -3339,6 +3536,11 @@ export class ProxyManager extends EventEmitter implements IProxyManager { return this.restartCount < ProxyManager.MAX_RESTART_COUNT; } + /** 核心更新待验证窗口内由 CoreUpdateService 置 true:抑制自动重启,首次失败即上报触发回滚。 */ + setAutoRestartSuppressed(suppressed: boolean): void { + this.autoRestartSuppressed = suppressed; + } + /** * 尝试自动重启 */ diff --git a/src/main/services/ResourceManager.ts b/src/main/services/ResourceManager.ts index dd962e96..36a4ae16 100644 --- a/src/main/services/ResourceManager.ts +++ b/src/main/services/ResourceManager.ts @@ -240,9 +240,11 @@ export class ResourceManager { const userDataPath = app.getPath('userData'); const updateDir = path.join(userDataPath, 'core_update'); const targetPath = path.join(updateDir, 'sing-box'); - + // 检查是否已经有可写核心 if (await this.fileExists(targetPath)) { + // 已有可写核心:仍需确保 libcronet 在旁(naive 出站靠 purego 同目录/系统库路径加载) + await this.ensureCronetBeside(updateDir); return targetPath; } @@ -258,9 +260,54 @@ export class ResourceManager { await fs.chmod(targetPath, 0o755); // 赋予可执行权限 } + // naive 节点需要 libcronet 与 sing-box 同目录(purego 加载),随核心一并放过去 + await this.ensureCronetBeside(updateDir); + return targetPath; } + /** 各平台 NaiveProxy 核心库文件名(purego 期望的名字) */ + getCronetLibFilename(): string { + if (this.platform === 'win32') return 'libcronet.dll'; + if (this.platform === 'darwin') return 'libcronet.dylib'; + return 'libcronet.so'; + } + + /** 内置的 libcronet 是否存在(用于 naive 可用性判断) */ + hasCronetLib(): boolean { + try { + // macOS:cronet 由 sing-box 二进制静态编入(CGO,无 .dylib)。打包的 mac-arm64 与 mac-x64 + // 核心(均 ≥1.13.13,with_naive_outbound)都含静态 cronet → naive 两 arch 皆可用、无需外部库。 + if (this.platform === 'darwin') { + return true; + } + // linux/windows:cronet 走 dlopen 动态加载。检查 libcronet 是否在 sing-box 实际加载目录 + // (purego 从二进制同目录加载):Linux/便携=可写核心目录(已由 ensureCronetBeside 拷入), + // 非便携 win=内置 resources 目录。不查内置兜底——否则 beside 拷贝失败仍误判"可用"→ FATAL。 + const coreDir = path.dirname(this.getSingBoxPath()); + return require('fs').existsSync(path.join(coreDir, this.getCronetLibFilename())); + } catch { + return false; + } + } + + /** + * 把内置的 libcronet 复制到与(可写/已更新)核心同一目录,供 naive 出站(purego) 加载。 + * 供 ensureWritableCore 与核心更新写盘后调用。内置无 libcronet 或已存在则跳过。 + */ + async ensureCronetBeside(coreDir: string): Promise { + const name = this.getCronetLibFilename(); + const src = path.join(this.getPlatformResourceDir(), name); + const dst = path.join(coreDir, name); + try { + if ((await this.fileExists(src)) && !(await this.fileExists(dst))) { + await fs.copyFile(src, dst); + } + } catch { + // 复制失败不应阻断启动;naive 不可用时 sing-box 会自报 cronet 错误 + } + } + /** * 获取资源信息(用于调试) */ diff --git a/src/main/services/SubscriptionService.ts b/src/main/services/SubscriptionService.ts index 6b2e8400..ff84da37 100644 --- a/src/main/services/SubscriptionService.ts +++ b/src/main/services/SubscriptionService.ts @@ -21,27 +21,55 @@ type SingboxTls = { alpn?: string[]; utls?: { enabled?: boolean; fingerprint?: string }; reality?: { enabled?: boolean; public_key?: string; short_id?: string }; + ech?: { enabled?: boolean }; + fragment?: boolean; }; type SingboxTransport = { type?: string; path?: string; + host?: string; headers?: Record; service_name?: string; }; +type SingboxMultiplex = { + enabled?: boolean; + protocol?: string; + max_connections?: number; + min_streams?: number; + padding?: boolean; +}; type SingboxOutbound = { type: string; tag: string; server?: string; server_port?: number; + server_ports?: string[]; + hop_interval?: string; uuid?: string; flow?: string; + username?: string; password?: string; method?: string; plugin?: string; plugin_opts?: string; obfs?: { type?: string; password?: string }; + // naive:是否启用 HTTP/3 (QUIC) 传输 + quic?: boolean; + // vmess:security=加密方式(auto/none/aes-128-gcm/...),alter_id=0 走 AEAD + alter_id?: number; + security?: string; + // tuic + congestion_control?: string; + udp_relay_mode?: string; + zero_rtt_handshake?: boolean; + heartbeat?: string; + // anytls + idle_session_check_interval?: string; + idle_session_timeout?: string; + min_idle_session?: number; tls?: SingboxTls; transport?: SingboxTransport; + multiplex?: SingboxMultiplex; }; export class SubscriptionService { @@ -75,26 +103,40 @@ export class SubscriptionService { /** * 将 sing-box outbounds 数组转换为 ServerConfig 列表 - * 支持: shadowsocks, vless, trojan, hysteria2 + * 支持: shadowsocks, vless, trojan, hysteria2, naive, vmess, tuic, anytls, socks */ private parseSingboxOutbounds( outbounds: SingboxOutbound[], subscriptionId: string ): ServerConfig[] { - const SUPPORTED = new Set(['shadowsocks', 'vless', 'trojan', 'hysteria2']); + const SUPPORTED = new Set([ + 'shadowsocks', + 'vless', + 'trojan', + 'hysteria2', + 'naive', + 'vmess', + 'tuic', + 'anytls', + 'socks', + ]); const servers: ServerConfig[] = []; const now = new Date().toISOString(); for (const ob of outbounds) { if (!SUPPORTED.has(ob.type)) continue; - if (!ob.server || !ob.server_port) continue; + // 支持仅含 server_ports(端口跳跃、无 server_port)的 Hy2 节点:从首个范围的低位端口推导 port + const effectivePort = + ob.server_port ?? + (ob.server_ports?.[0] ? parseInt(ob.server_ports[0].split(':')[0], 10) : undefined); + if (!ob.server || !effectivePort) continue; try { const base: Partial = { id: randomUUID(), - name: ob.tag || `${ob.server}:${ob.server_port}`, + name: ob.tag || `${ob.server}:${effectivePort}`, address: ob.server, - port: ob.server_port, + port: effectivePort, subscriptionId, createdAt: now, updatedAt: now, @@ -109,6 +151,8 @@ export class SubscriptionService { allowInsecure: ob.tls.insecure ?? false, alpn: ob.tls.alpn, fingerprint: ob.tls.utls?.fingerprint, + ech: ob.tls.ech?.enabled === true ? true : undefined, + fragment: ob.tls.fragment === true ? true : undefined, }; if (hasReality && ob.tls.reality) { base.realitySettings = { @@ -121,7 +165,7 @@ export class SubscriptionService { // Transport if (ob.transport?.type) { const t = ob.transport; - const netType = t.type as 'ws' | 'grpc' | 'http' | 'tcp'; + const netType = t.type as 'ws' | 'grpc' | 'http' | 'httpupgrade' | 'tcp'; base.network = netType; if (netType === 'ws') { base.wsSettings = { path: t.path, headers: t.headers }; @@ -129,9 +173,23 @@ export class SubscriptionService { base.grpcSettings = { serviceName: t.service_name }; } else if (netType === 'http') { base.httpSettings = { path: t.path }; + } else if (netType === 'httpupgrade') { + // httpupgrade 复用 ws 设置承载 path/Host + base.wsSettings = { path: t.path, headers: t.host ? { Host: t.host } : t.headers }; } } + // Multiplex(vless/trojan/vmess/ss) + if (ob.multiplex?.enabled) { + base.multiplexSettings = { + enabled: true, + protocol: (ob.multiplex.protocol as 'smux' | 'yamux' | 'h2mux') || 'h2mux', + maxConnections: ob.multiplex.max_connections, + minStreams: ob.multiplex.min_streams, + padding: ob.multiplex.padding, + }; + } + // Protocol-specific if (ob.type === 'shadowsocks') { servers.push({ @@ -164,12 +222,88 @@ export class SubscriptionService { password: ob.password ?? '', security: 'tls', }; + const hy2Settings: NonNullable = {}; if (ob.obfs?.type === 'salamander' && ob.obfs.password) { - hy2.hysteria2Settings = { - obfs: { type: 'salamander', password: ob.obfs.password }, - }; + hy2Settings.obfs = { type: 'salamander', password: ob.obfs.password }; + } + // 端口跳跃:server_ports 形如 ["20000:30000"],存为逗号分隔字符串 + if (ob.server_ports && ob.server_ports.length > 0) { + hy2Settings.serverPorts = ob.server_ports.join(','); + if (ob.hop_interval) hy2Settings.hopInterval = ob.hop_interval; + } + if (Object.keys(hy2Settings).length > 0) { + hy2.hysteria2Settings = hy2Settings; } servers.push(hy2); + } else if (ob.type === 'naive') { + // sing-box naive 的 quic:true 表示走 HTTP/3 (QUIC) 传输(h3 节点) + servers.push({ + ...(base as ServerConfig), + protocol: 'naive', + username: ob.username ?? '', + password: ob.password ?? '', + naiveSettings: ob.quic ? { useHttp3: true } : undefined, + }); + } else if (ob.type === 'vmess') { + servers.push({ + ...(base as ServerConfig), + protocol: 'vmess', + uuid: ob.uuid ?? '', + alterId: ob.alter_id ?? 0, + vmessSecurity: ob.security || 'auto', + }); + } else if (ob.type === 'tuic') { + const tuicSettings: NonNullable = {}; + if ( + ob.congestion_control === 'bbr' || + ob.congestion_control === 'cubic' || + ob.congestion_control === 'new_reno' + ) { + tuicSettings.congestionControl = ob.congestion_control; + } + if (ob.udp_relay_mode === 'native' || ob.udp_relay_mode === 'quic') { + tuicSettings.udpRelayMode = ob.udp_relay_mode; + } + if (ob.zero_rtt_handshake !== undefined) { + tuicSettings.zeroRttHandshake = ob.zero_rtt_handshake; + } + if (ob.heartbeat) tuicSettings.heartbeat = ob.heartbeat; + servers.push({ + ...(base as ServerConfig), + protocol: 'tuic', + uuid: ob.uuid ?? '', + password: ob.password ?? '', + security: 'tls', + tuicSettings: Object.keys(tuicSettings).length > 0 ? tuicSettings : undefined, + }); + } else if (ob.type === 'anytls') { + const anyTlsSettings: NonNullable = {}; + if (ob.idle_session_check_interval) { + anyTlsSettings.idleSessionCheckInterval = ob.idle_session_check_interval; + } + if (ob.idle_session_timeout) { + anyTlsSettings.idleSessionTimeout = ob.idle_session_timeout; + } + if (ob.min_idle_session !== undefined) { + anyTlsSettings.minIdleSession = ob.min_idle_session; + } + servers.push({ + ...(base as ServerConfig), + protocol: 'anytls', + password: ob.password ?? '', + security: 'tls', + anyTlsSettings: Object.keys(anyTlsSettings).length > 0 ? anyTlsSettings : undefined, + }); + } else if (ob.type === 'socks') { + // socks 无 TLS:覆盖 base 可能因 transport 推断的 network,固定为 tcp/none + servers.push({ + ...(base as ServerConfig), + protocol: 'socks', + username: ob.username, + password: ob.password, + network: 'tcp', + security: 'none', + }); } } catch (e: any) { this.logManager.addLog( diff --git a/src/renderer/components/settings/advanced-settings.tsx b/src/renderer/components/settings/advanced-settings.tsx index 2b064327..72d60385 100644 --- a/src/renderer/components/settings/advanced-settings.tsx +++ b/src/renderer/components/settings/advanced-settings.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState } from 'react'; import { Card, CardContent } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; @@ -22,14 +22,6 @@ export function AdvancedSettings() { const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); - // 当前选中节点为 QUIC 协议(hy2/tuic)时,"阻止 QUIC"会误杀其传输,禁用该开关 - const isQuicNode = useMemo(() => { - const selectedProtocol = config?.servers - ?.find((s) => s.id === config?.selectedServerId) - ?.protocol?.toLowerCase(); - return selectedProtocol === 'hysteria2' || selectedProtocol === 'tuic'; - }, [config?.selectedServerId, config?.servers]); - const handleSavePorts = async () => { if (!config) return; @@ -311,7 +303,6 @@ export function AdvancedSettings() { { const updatedConfig = { ...config, blockQuic: checked as boolean }; saveConfig(updatedConfig); @@ -325,6 +316,43 @@ export function AdvancedSettings() { {t('settings.advanced.blockQuicDesc')}

+
+ { + const updatedConfig = { + ...config, + interruptConnectionsOnSwitch: checked as boolean, + }; + saveConfig(updatedConfig); + }} + /> + +
+

+ {t('settings.advanced.interruptOnSwitchDesc')} +

+ +
+ { + const updatedConfig = { ...config, tlsFragment: checked as boolean }; + saveConfig(updatedConfig); + }} + /> + +
+

+ {t('settings.advanced.tlsFragmentDesc')} +

+ {/* 自动换节点 */}
+ + {/* 核心更新:仅在兼容版本带内自动更新 */} +
+ { + saveConfig({ + ...config, + restrictCoreUpdateToCompatibleMinor: checked as boolean, + }); + }} + /> + +
+

+ {t('settings.advanced.restrictCoreUpdateDesc' as any) || + '仅自动更新到与当前配置生成器兼容的 sing-box 版本带(如 1.13.x);跨版本带(如 1.14)不自动更新、转为提示随 App 升级,避免配置不兼容。手动更新不受此限制。'} +

diff --git a/src/renderer/components/settings/naive-form.tsx b/src/renderer/components/settings/naive-form.tsx index 22ab5efa..606271ae 100644 --- a/src/renderer/components/settings/naive-form.tsx +++ b/src/renderer/components/settings/naive-form.tsx @@ -14,6 +14,7 @@ import { FormDescription, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, @@ -32,6 +33,7 @@ const createNaiveSchema = (t: any) => password: z.string().min(1, t('servers.passwordRequired')), tlsServerName: z.string().optional(), tlsFingerprint: z.string().optional(), + useHttp3: z.boolean().optional(), }); type NaiveFormValues = z.infer>; @@ -54,6 +56,7 @@ export function NaiveForm({ serverConfig, onSubmit }: NaiveFormProps) { password: serverConfig.password || '', tlsServerName: serverConfig.tlsSettings?.serverName || '', tlsFingerprint: serverConfig.tlsSettings?.fingerprint || 'none', + useHttp3: serverConfig.naiveSettings?.useHttp3 ?? false, }; } return { @@ -63,6 +66,7 @@ export function NaiveForm({ serverConfig, onSubmit }: NaiveFormProps) { password: '', tlsServerName: '', tlsFingerprint: 'none', + useHttp3: false, }; }; @@ -85,6 +89,7 @@ export function NaiveForm({ serverConfig, onSubmit }: NaiveFormProps) { allowInsecure: false, fingerprint: values.tlsFingerprint || 'none', }, + naiveSettings: values.useHttp3 ? { useHttp3: true } : undefined, }; await onSubmit(config); }; @@ -219,6 +224,24 @@ export function NaiveForm({ serverConfig, onSubmit }: NaiveFormProps) { )} /> + + ( + +
+ + {t('servers.naive.useHttp3', 'HTTP/3 (QUIC)')} + + {t('servers.naive.useHttp3Desc')} +
+ + + +
+ )} + /> diff --git a/src/renderer/i18n/locales/en-US.json b/src/renderer/i18n/locales/en-US.json index 775500b0..8e3b1f6d 100644 --- a/src/renderer/i18n/locales/en-US.json +++ b/src/renderer/i18n/locales/en-US.json @@ -234,7 +234,13 @@ "bypassLAN": "Bypass LAN", "bypassLANDesc": "Force requests destined for the local network to bypass the proxy (disable to allow proxying for 192.168.x etc.)", "blockQuic": "Block QUIC (force TCP)", - "blockQuicDesc": "Reject QUIC (UDP 443) so browsers fall back to TCP. Fixes pages stalling when the node's UDP relay is unreliable. Off by default; not applicable to Hysteria2/TUIC nodes.", + "blockQuicDesc": "Reject proxy-bound QUIC (UDP 443) so browsers fall back to TCP. Fixes pages stalling when a node's UDP relay is unreliable. Off by default; applies to all nodes.", + "interruptOnSwitch": "Interrupt existing connections on switch", + "interruptOnSwitchDesc": "Default is graceful switching: switching nodes does not restart the proxy and existing connections persist until they close naturally; new connections use the new node. When enabled, switching forcibly drops existing connections and rebuilds them on the new node immediately.", + "tlsFragment": "TLS fragmentation (anti-SNI blocking)", + "tlsFragmentDesc": "Splits the TLS ClientHello across multiple TCP packets so the SNI is fragmented, evading SNI-keyword DPI blocking. Off by default; may slightly increase handshake latency on some networks.", + "restrictCoreUpdate": "Auto-update core only within compatible version band", + "restrictCoreUpdateDesc": "Only auto-update sing-box to versions compatible with the current config generator (e.g. 1.13.x). Updates crossing a version band (e.g. 1.14) are not applied automatically and prompt an app upgrade instead, avoiding config incompatibility. Manual updates are not restricted.", "terminalProxy": "Terminal Proxy Settings", "terminalProxyDesc": "Copy the following commands to set proxy in terminal (proxy must be started first)", "gitProxy": "Git Proxy Settings", @@ -519,7 +525,9 @@ "alpnDesc": "Application-Layer Protocol Negotiation, e.g. h3", "naive": { "versionWarning": "⚠️ Naive protocol requires sing-box v1.13+ core. If you are using v1.12, please go to 'Settings -> About' to update your core.", - "goUpdate": "Update Core" + "goUpdate": "Update Core", + "useHttp3": "HTTP/3 (QUIC)", + "useHttp3Desc": "This node dials over QUIC/UDP (server must listen on quic://, with HTTP/3 enabled). Its own QUIC dial is protected and not killed by \"Block QUIC\"; browser QUIC to this node is still handled per the \"Block QUIC\" setting." } }, "rules": { diff --git a/src/renderer/i18n/locales/zh-CN.json b/src/renderer/i18n/locales/zh-CN.json index 3c21ad9a..b5faf894 100644 --- a/src/renderer/i18n/locales/zh-CN.json +++ b/src/renderer/i18n/locales/zh-CN.json @@ -233,7 +233,13 @@ "bypassLAN": "绕过局域网 (Bypass LAN)", "bypassLANDesc": "默认将发往局域网的请求强制直连(关闭此选项后,192.168.x 等内网 IP 才可能走代理)", "blockQuic": "阻止 QUIC(强制 TCP)", - "blockQuicDesc": "reject QUIC(UDP 443),让浏览器回退 TCP,解决节点 UDP relay 不通导致的网页卡顿/断流。默认关;对 Hysteria2/TUIC 节点不适用。", + "blockQuicDesc": "reject 代理向 QUIC(UDP 443),让浏览器回退 TCP,解决节点 UDP relay 不通导致的网页卡顿/断流。默认关;对所有节点生效。", + "interruptOnSwitch": "切换节点时断开现有连接", + "interruptOnSwitchDesc": "默认优雅切换:切换节点不重启代理、现有连接保留至自然关闭,新连接走新节点。开启后切换时强制断开现有连接、立即在新节点重建。", + "tlsFragment": "TLS 分片(抗 SNI 封锁)", + "tlsFragmentDesc": "对所有 TLS 节点切分 ClientHello,使 SNI 跨多个 TCP 包、规避基于 SNI 关键词的 DPI 阻断。默认关;个别网络下可能轻微增加握手延迟。", + "restrictCoreUpdate": "仅在兼容版本带内自动更新内核", + "restrictCoreUpdateDesc": "仅自动更新到与当前配置生成器兼容的 sing-box 版本带(如 1.13.x);跨版本带(如 1.14)不自动更新、转为提示随 App 升级,避免配置不兼容。手动更新不受此限制。", "terminalProxy": "终端代理设置", "terminalProxyDesc": "复制以下命令到终端中设置代理(需要先启动代理)", "gitProxy": "Git 代理设置", @@ -518,7 +524,9 @@ "alpnDesc": "应用层协议协商,例如 h3", "naive": { "versionWarning": "⚠️ Naive 协议需要 sing-box v1.13+ 核心支持。如果您当前使用的是 v1.12 核心,请前往“设置 -> 关于”点击更新核心。", - "goUpdate": "去更新核心" + "goUpdate": "去更新核心", + "useHttp3": "HTTP/3 (QUIC)", + "useHttp3Desc": "该节点用 QUIC/UDP 拨号(对应服务端 quic:// 监听,需服务端开启 HTTP/3)。其自身的 QUIC 拨号受保护、不被“阻止 QUIC”误杀;浏览器到该节点的 QUIC 仍按“阻止 QUIC”设置处理。" } }, "rules": { diff --git a/src/shared/types.ts b/src/shared/types.ts index 8847ec0e..04db2f64 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -21,7 +21,7 @@ export type Protocol = | 'socks' | 'http' | 'ssh'; -export type Network = 'tcp' | 'ws' | 'grpc' | 'http'; +export type Network = 'tcp' | 'ws' | 'grpc' | 'http' | 'httpupgrade'; export type Hysteria2Network = 'tcp' | 'udp'; export type Security = 'none' | 'tls' | 'reality'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; @@ -37,6 +37,8 @@ export interface TlsSettings { allowInsecure?: boolean; alpn?: string[]; fingerprint?: string; + ech?: boolean; // Encrypted Client Hello(隐藏 SNI);sing-box tls.ech.enabled + fragment?: boolean; // TLS ClientHello 分片,抗 SNI-DPI;sing-box tls.fragment } export interface RealitySettings { @@ -75,6 +77,17 @@ export interface Hysteria2Settings { downMbps?: number; obfs?: Hysteria2ObfsSettings; network?: Hysteria2Network; + serverPorts?: string; // 端口跳跃范围,如 "20000:30000";sing-box server_ports + hopInterval?: string; // 端口跳跃间隔,如 "30s";sing-box hop_interval +} + +// Multiplex 多路复用设置(vless/trojan/vmess/shadowsocks);注意 reality+vision(xtls-rprx-vision) 不兼容 +export interface MultiplexSettings { + enabled?: boolean; + protocol?: 'smux' | 'yamux' | 'h2mux'; // 默认 h2mux + maxConnections?: number; + minStreams?: number; + padding?: boolean; // 流量填充,增强抗特征 } // TUIC 协议设置 @@ -85,6 +98,11 @@ export interface TuicSettings { heartbeat?: string; } +// Naive 协议设置 +export interface NaiveSettings { + useHttp3?: boolean; // 使用 HTTP/3 (QUIC) 传输;sing-box naive outbound 的 quic 字段 +} + // Shadowsocks 协议设置 export interface ShadowsocksSettings { method: string; @@ -163,6 +181,7 @@ export interface ServerConfig { // Naive 特定 username?: string; + naiveSettings?: NaiveSettings; // VMess 特定 alterId?: number; @@ -177,6 +196,9 @@ export interface ServerConfig { // AnyTLS 特定 anyTlsSettings?: AnyTlsSettings; + // Multiplex 多路复用(vless/trojan/vmess/ss;reality+vision 不兼容,生成侧 guard) + multiplexSettings?: MultiplexSettings; + // Shadowsocks 特定 shadowsocksSettings?: ShadowsocksSettings; @@ -313,6 +335,7 @@ export interface UserConfig { autoPrivacyMode?: boolean; // 自动进入隐私模式 privacyPassword?: string; // 隐私模式解锁密码 autoSwitchNode?: boolean; // 节点故障时自动切换到可用节点 + interruptConnectionsOnSwitch?: boolean; // 切换节点时中断现有连接、强制在新节点重建(默认 false=优雅切换,现有连接保留至自然关闭) // 窗口尺寸(仅在 rememberWindowSize 启用时使用) windowBounds?: { width: number; height: number }; @@ -335,7 +358,11 @@ export interface UserConfig { mixedPort?: number; // 混合端口(可选,同时支持 HTTP 和 SOCKS5,0 或 undefined 表示禁用) allowLan?: boolean; // 局域网共享代理(允许其他设备连接) bypassLAN?: boolean; // 绕过局域网(将内网 IP 设置为直连) - blockQuic?: boolean; // 阻止 QUIC(reject 入站 UDP 443 强制浏览器回退 TCP);默认关;hy2/tuic 节点自动跳过 + blockQuic?: boolean; // 阻止 QUIC(对代理向 UDP 443 执行 reject,逼浏览器回退 TCP);默认关;节点无关,对所有协议一视同仁 + tlsFragment?: boolean; // 全局 TLS 分片:对所有 TLS 节点切分 ClientHello 抗 SNI-DPI;默认关 + // 核心更新:仅在配置生成器已验证的 sing-box minor 版本带内自动更新(默认 true)。关闭后允许自动 + // 更新跨越 minor(如 1.13→1.14),但跨 minor 的 schema 变更可能导致配置不兼容、需手动处理。 + restrictCoreUpdateToCompatibleMinor?: boolean; bypassProcesses?: string[]; // 排除进程(指定进程直连,不走代理) // 日志设置