From c613c33ae63bffe828bbfa0dbd4ca31b92e29262 Mon Sep 17 00:00:00 2001 From: 8odream Date: Sat, 15 Aug 2026 22:00:16 +0800 Subject: [PATCH 1/2] =?UTF-8?q?-=20feat:=20=E6=96=B0=E5=A2=9E=E6=B5=81?= =?UTF-8?q?=E9=87=8F=E7=9B=91=E6=8E=A7=E4=B8=8E=E6=97=A5=E5=BF=97=E5=BC=80?= =?UTF-8?q?=E5=85=B3=EF=BC=8C=E5=AE=8C=E5=96=84=20overwall=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E9=80=89=E6=8B=A9=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=89=93=E5=8C=85=E9=97=AE=E9=A2=98"=20-=20feat(gui):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B5=81=E9=87=8F=E7=9B=91=E6=8E=A7=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=EF=BC=8Cadblock=20=E9=9B=B6=20IP=20=E4=B8=8E=E5=B7=B2?= =?UTF-8?q?=E6=8B=A6=E6=88=AA=E5=9F=9F=E5=90=8D=E4=B8=8D=E8=AE=A1=E5=85=A5?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=BB=9F=E8=AE=A1=20-=20feat(core):=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20DEV=5FSIDECAR=5FLOG=5FDISABLED=20=E5=AE=8C?= =?UTF-8?q?=E5=85=A8=E5=85=B3=E9=97=AD=E6=97=A5=E5=BF=97=EF=BC=8C=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E5=A2=9E=E5=8A=A0=E5=BC=80=E5=85=B3=20-=20fe?= =?UTF-8?q?at(overwall):=20=E6=9C=8D=E5=8A=A1=E5=99=A8=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20ID=20=E6=A0=87=E8=AF=86=EF=BC=8C=E5=8F=AF=E6=8C=89=E5=9F=9F?= =?UTF-8?q?=E5=90=8D=E9=80=89=E6=8B=A9=E7=9B=AE=E6=A0=87=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=20-=20feat(speed):=20=E6=B5=8B=E9=80=9F=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E9=9B=B6=20IP=EF=BC=8C=E7=AD=89=E5=BE=85=20Cloudflare?= =?UTF-8?q?=20=E8=B7=AF=E7=94=B1=E5=B0=B1=E7=BB=AA=E5=90=8E=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=20-=20fix(gui):=20=E4=BF=AE=E5=A4=8D=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E5=90=8E=E4=BB=BB=E5=8A=A1=E6=A0=8F=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E4=BB=8D=E4=B8=BA=20Electron=20=E9=BB=98=E8=AE=A4=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=EF=BC=88signAndEditExecutable=20=E6=94=B9=E4=B8=BA=20?= =?UTF-8?q?true=EF=BC=8C=E7=A1=AE=E4=BF=9D=E5=86=99=E5=85=A5=20exe=20?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E4=B8=8E=E7=89=88=E6=9C=AC=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=EF=BC=89=20-=20fix(build):=20=E5=B9=B3=E5=8F=B0=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E5=8F=AF=E9=80=89=E4=BE=9D=E8=B5=96=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E4=B8=BA=E7=9B=B4=E6=8E=A5=E4=BE=9D=E8=B5=96=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=AE=89=E8=A3=85=E5=90=8E=20sysproxy/shutdown-handle?= =?UTF-8?q?r=20=E6=A8=A1=E5=9D=97=E7=BC=BA=E5=A4=B1=20-=20fix(proxy):=20DS?= =?UTF-8?q?-Proxy-Request=20=E5=93=8D=E5=BA=94=E5=A4=B4=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E4=B8=8A=E6=B8=B8=E8=B7=AF=E5=BE=84=20-=20fi?= =?UTF-8?q?x(system-proxy):=20=E4=BC=98=E5=8C=96=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E5=BC=80=E5=85=B3=E4=B8=8E=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E9=80=BB=E8=BE=91=20-=20chore(script):=20=E7=BB=9F=E4=B8=80=20?= =?UTF-8?q?dev.ps1=20=E5=90=AF=E5=81=9C/=E6=A3=80=E6=9F=A5=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内容使用DeepSeekV4Pro0813 high+DSH完成 --- _script/dev.ps1 | 144 ++++++ _script/electron-dev.mjs | 14 +- packages/core/src/config-api.js | 6 +- packages/core/src/config/index.js | 6 + packages/core/src/expose.js | 82 ++-- .../core/src/modules/plugin/node/config.js | 13 +- .../core/src/modules/plugin/node/index.js | 130 ++++-- .../src/modules/plugin/overwall/config.js | 2 + packages/core/src/modules/server/index.js | 37 +- .../core/src/shell/scripts/set-system-env.js | 74 ++-- .../shell/scripts/set-system-proxy/index.js | 146 +++--- .../core/src/utils/util.log-or-console.js | 11 +- packages/core/src/utils/util.logger.js | 20 +- packages/gui/electron-builder.config.cjs | 12 +- packages/gui/package.json | 2 + packages/gui/src/background.js | 43 +- packages/gui/src/bridge/api/backend.js | 11 +- packages/gui/src/utils/util.log-env.js | 20 + packages/gui/src/view/pages/index.vue | 42 +- packages/gui/src/view/pages/plugin/node.vue | 36 +- .../gui/src/view/pages/plugin/overwall.vue | 129 +++++- packages/gui/src/view/pages/server.vue | 32 +- packages/gui/src/view/pages/setting.vue | 9 + packages/gui/src/view/pages/traffic.vue | 417 ++++++++++++++++++ packages/gui/src/view/router/index.js | 2 + packages/gui/src/view/router/menu.js | 1 + packages/mitmproxy/src/index.js | 5 + packages/mitmproxy/src/lib/cloudflareRoute.js | 301 +++++++++++++ packages/mitmproxy/src/lib/dns/util.ip.js | 28 ++ .../src/lib/proxy/middleware/overwall.js | 119 +++-- .../proxy/mitmproxy/createConnectHandler.js | 4 + .../proxy/mitmproxy/createRequestHandler.js | 40 +- .../src/lib/proxy/mitmproxy/dnsLookup.js | 104 ++++- .../src/lib/proxy/mitmproxy/index.js | 26 +- .../mitmproxy/src/lib/speed/SpeedTester.js | 110 ++++- packages/mitmproxy/src/lib/speed/config.js | 2 + packages/mitmproxy/src/lib/speed/index.js | 10 +- .../src/lib/traffic/TrafficMonitor.js | 332 ++++++++++++++ .../src/lib/traffic/processResolver.js | 120 +++++ pnpm-lock.yaml | 12 +- 40 files changed, 2345 insertions(+), 309 deletions(-) create mode 100644 _script/dev.ps1 create mode 100644 packages/gui/src/utils/util.log-env.js create mode 100644 packages/gui/src/view/pages/traffic.vue create mode 100644 packages/mitmproxy/src/lib/cloudflareRoute.js create mode 100644 packages/mitmproxy/src/lib/traffic/TrafficMonitor.js create mode 100644 packages/mitmproxy/src/lib/traffic/processResolver.js diff --git a/_script/dev.ps1 b/_script/dev.ps1 new file mode 100644 index 0000000000..d5419d68e0 --- /dev/null +++ b/_script/dev.ps1 @@ -0,0 +1,144 @@ +#Requires -Version 5.1 +<# + dev-sidecar 开发环境统一启动/检查脚本 + + 用法(在仓库根目录或任意目录执行): + pwsh -File _script\dev.ps1 -Action start # 启动开发环境并等待端口就绪 + pwsh -File _script\dev.ps1 -Action check # 检查端口监听状态 + pwsh -File _script\dev.ps1 -Action stop # 停止开发环境(按端口结束进程) + pwsh -File _script\dev.ps1 -Action restart # 停止后重新启动 + + 可选参数: + -Port 8081 # 前端 dev server 端口,默认 8081 + -Foreground # start 时在前台运行,日志直接输出到当前终端(Ctrl+C 停止) + -SkipKill # start/restart 时不先结束已占用端口的进程 +#> +param( + [ValidateSet('start', 'check', 'stop', 'restart')] + [string]$Action = 'start', + + [int]$Port = 8081, + + [switch]$Foreground, + + [switch]$SkipKill +) + +$ErrorActionPreference = 'SilentlyContinue' + +$repoRoot = Split-Path -Parent $PSScriptRoot +$guiDir = Join-Path $repoRoot 'packages\gui' +$electronDev = Join-Path $PSScriptRoot 'electron-dev.mjs' +$proxyHttpPort = 31180 +$proxyHttpsPort = 31181 +$ports = @($Port, $proxyHttpPort, $proxyHttpsPort) + +function Get-Listeners([int[]]$TargetPorts) { + Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | + Where-Object { $TargetPorts -contains $_.LocalPort } +} + +function Show-Status([string]$Title) { + Write-Host "== $Title ==" + $listeners = @(Get-Listeners $ports) + if ($listeners.Count -gt 0) { + $listeners | Sort-Object LocalPort | ForEach-Object { + $procName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName + Write-Host (" {0}:{1,-6} PID {2,-7} {3}" -f $_.LocalAddress, $_.LocalPort, $_.OwningProcess, $procName) + } + } else { + Write-Host ' no listeners' + } + Write-Host '' +} + +function Stop-DevSidecar([int[]]$TargetPorts) { + Write-Host '== stopping dev-sidecar ==' + $listeners = @(Get-Listeners $TargetPorts) + $procIds = @($listeners | Select-Object -ExpandProperty OwningProcess -Unique) + if ($procIds.Count -eq 0) { + Write-Host ' no listeners' + } else { + foreach ($procId in $procIds) { + Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue + Write-Host " killed PID $procId" + } + } + Start-Sleep -Milliseconds 600 + Write-Host '' +} + +function Wait-Ports([int[]]$TargetPorts, [int]$TimeoutSec = 120) { + $deadline = (Get-Date).AddSeconds($TimeoutSec) + $ready = @{} + + while ((Get-Date) -lt $deadline) { + $listeners = @(Get-Listeners $TargetPorts) + foreach ($p in $TargetPorts) { + if ($listeners.LocalPort -contains $p) { + $ready[$p] = $true + } + } + + if ($ready.Count -ge $TargetPorts.Count) { + break + } + + Start-Sleep -Seconds 1 + } + + if ($ready.Count -lt $TargetPorts.Count) { + $missing = @($TargetPorts | Where-Object { -not $ready.ContainsKey($_) }) + Write-Host "等待端口超时,未就绪端口: $($missing -join ', ')" + } else { + Write-Host "端口已全部就绪: $($TargetPorts -join ', ')" + } +} + +function Start-DevSidecar([int]$DevPort) { + $node = (Get-Command node -ErrorAction Stop).Source + + if ($Foreground) { + Write-Host "== starting dev-sidecar in foreground (Ctrl+C to stop) ==" + Push-Location $guiDir + try { + & $node $electronDev '--port' "$DevPort" + } finally { + Pop-Location + } + return + } + + Write-Host "== starting dev-sidecar ==" + $proc = Start-Process -FilePath $node -ArgumentList @($electronDev, '--port', "$DevPort") -WorkingDirectory $guiDir -WindowStyle Hidden -PassThru + Write-Host " launcher PID: $($proc.Id)" + Wait-Ports $ports + Show-Status 'port status after start' +} + +switch ($Action) { + 'check' { + Show-Status "port status (dev: $Port, http proxy: $proxyHttpPort, https proxy: $proxyHttpsPort)" + break + } + + 'stop' { + Stop-DevSidecar $ports + Show-Status 'port status after stop' + break + } + + 'start' { + if (-not $SkipKill) { + Stop-DevSidecar $ports + } + Start-DevSidecar $Port + break + } + + 'restart' { + Stop-DevSidecar $ports + Start-DevSidecar $Port + break + } +} diff --git a/_script/electron-dev.mjs b/_script/electron-dev.mjs index 1c2d67ff15..149891f7a8 100644 --- a/_script/electron-dev.mjs +++ b/_script/electron-dev.mjs @@ -6,7 +6,17 @@ import { setTimeout as delay } from 'node:timers/promises' const guiDir = process.cwd() const require = createRequire(import.meta.url) -const devServerUrl = 'http://localhost:8080' +function resolveDevServer () { + const argv = process.argv.slice(2) + const portIndex = argv.indexOf('--port') + const port = portIndex >= 0 ? Number.parseInt(argv[portIndex + 1], 10) : 8080 + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`无效的端口号: ${argv[portIndex + 1]}`) + } + return { port, url: `http://localhost:${port}` } +} + +const { port: devServerPort, url: devServerUrl } = resolveDevServer() const state = { closing: false, devServer: null, @@ -86,7 +96,7 @@ async function main () { const vueCliServiceBin = resolveVueCliServiceBin() const electronBin = resolveElectronBin() - state.devServer = spawnCommand(process.execPath, [vueCliServiceBin, 'serve', '--port', '8080']) + state.devServer = spawnCommand(process.execPath, [vueCliServiceBin, 'serve', '--port', String(devServerPort)]) state.devServer.on('exit', (code, signal) => { if (!state.closing) { void shutdown(code ?? (signal ? 1 : 0)) diff --git a/packages/core/src/config-api.js b/packages/core/src/config-api.js index c0aed46604..07610048b4 100644 --- a/packages/core/src/config-api.js +++ b/packages/core/src/config-api.js @@ -60,7 +60,11 @@ const configApi = { if (remoteConfigUrl.startsWith('https://raw.githubusercontent.com/')) { headers['Server-Name'] = 'baidu.com' } - request(remoteConfigUrl, { headers }, (error, response, body) => { + // 禁用环境变量代理(HTTPS_PROXY/HTTP_PROXY): + // 当用户开启 proxy.setEnv 后,环境变量会指向 dev-sidecar 自己的代理端口, + // 启动时本地代理尚未监听,走代理会导致下载失败(ECONNREFUSED 127.0.0.1:31181), + // 新装用户会因此无法下载远程配置,只能使用内置规则。 + request(remoteConfigUrl, { headers, proxy: null }, (error, response, body) => { if (error) { log.error(`下载远程配置失败: ${remoteConfigUrl}, error:`, error, ', response:', response, ', body:', body) reject(error) diff --git a/packages/core/src/config/index.js b/packages/core/src/config/index.js index 4a430822c1..fa330b5477 100644 --- a/packages/core/src/config/index.js +++ b/packages/core/src/config/index.js @@ -41,6 +41,7 @@ const defaultConfig = { showShutdownTip: true, // 日志相关配置 + logDisabled: false, // 完全禁用日志:控制台不输出,日志文件也不写入 logFileSavePath: path.join(configLoader.getUserBasePath(), '/logs'), // 日志文件保存路径 keepLogFileCount: 15, // 保留日志文件数 maxLogFileSize: 1, // 最大日志文件大小 @@ -99,6 +100,11 @@ const defaultConfig = { // } }, }, + // Cloudflare 路由重定向:命中 Cloudflare IP 段时改写为优选地址 + cloudflareRoute: { + enabled: false, + preferredEndpoint: '', // 优选地址,可填写 IP 或 CNAME 域名 + }, intercept: { enabled: true, }, diff --git a/packages/core/src/expose.js b/packages/core/src/expose.js index a617cd257d..d64eb11395 100644 --- a/packages/core/src/expose.js +++ b/packages/core/src/expose.js @@ -55,80 +55,96 @@ config.resetDefault() const server = modules.server const serverStart = server.start -function newServerStart ({ mitmproxyPath }) { - return serverStart({ mitmproxyPath, plugins: plugin }) +function newServerStart ({ mitmproxyPath, setting }) { + return serverStart({ mitmproxyPath, plugins: plugin, setting }) } server.start = newServerStart -async function startup ({ mitmproxyPath }) { +async function startup ({ mitmproxyPath, setting }) { const conf = config.get() + const tasks = [] + if (conf.server.enabled && !status.server.enabled) { - try { - await server.start({ mitmproxyPath }) - } catch (err) { - log.error('代理服务启动失败:', err) - } + tasks.push((async () => { + try { + await server.start({ mitmproxyPath }) + } catch (err) { + log.error('代理服务启动失败:', err) + } + })()) } if (conf.proxy.enabled && !status.proxy.enabled) { - try { - await proxy.start() - } catch (err) { - log.error('开启系统代理失败:', err) - } + tasks.push((async () => { + try { + await proxy.start() + } catch (err) { + log.error('开启系统代理失败:', err) + } + })()) } + try { - const plugins = [] for (const key in plugin) { if (conf.plugin[key].enabled && !status.plugin[key]?.enabled) { - const start = async () => { + if (key === 'overwall' && setting && setting.overwall !== true) { + log.info(`插件【${key}】未启动:setting.json 未开启 overwall`) + continue + } + tasks.push((async () => { try { await plugin[key].start() log.info(`插件【${key}】已启动`) } catch (err) { log.error(`插件【${key}】启动失败:`, err) } - } - plugins.push(start()) + })()) } } - if (plugins && plugins.length > 0) { - await Promise.all(plugins) - } } catch (err) { log.error('开启插件失败:', err) } + + if (tasks.length > 0) { + // server、系统代理、各插件之间没有相互依赖,并行启动以缩短整体等待时间 + await Promise.all(tasks) + } } async function shutdown () { + const tasks = [] + try { - const plugins = [] for (const key in plugin) { if (status.plugin[key] && status.plugin[key].enabled && plugin[key].close) { - const close = async () => { + tasks.push((async () => { try { await plugin[key].close() log.info(`插件【${key}】已关闭`) } catch (err) { log.error(`插件【${key}】关闭失败:`, err) } - } - plugins.push(close()) + })()) } } - if (plugins.length > 0) { - await Promise.all(plugins) - } } catch (error) { log.error('插件关闭失败:', error) } if (status.proxy.enabled) { - try { - await proxy.close() - log.info('系统代理已关闭') - } catch (err) { - log.error('系统代理关闭失败:', err) - } + tasks.push((async () => { + try { + await proxy.close() + log.info('系统代理已关闭') + } catch (err) { + log.error('系统代理关闭失败:', err) + } + })()) } + + if (tasks.length > 0) { + // 插件关闭(清理 git/npm 配置)与关闭系统代理互不依赖,并行执行 + await Promise.all(tasks) + } + if (status.server.enabled) { try { await server.close() diff --git a/packages/core/src/modules/plugin/node/config.js b/packages/core/src/modules/plugin/node/config.js index 9a02451a51..ab6544a68d 100644 --- a/packages/core/src/modules/plugin/node/config.js +++ b/packages/core/src/modules/plugin/node/config.js @@ -30,15 +30,16 @@ module.exports = { }, }, }, - variables: { - phantomjs_cdnurl: 'https://npmmirror.com/mirrors/phantomjs', - chromedriver_cdnurl: 'https://npmmirror.com/mirrors/chromedriver', - sass_binary_site: 'https://npmmirror.com/mirrors/node-sass', + // npm 11 开始不再接受未知的 npm config,以下镜像变量全部改为直接设置系统环境变量 + variables: {}, + envVariables: { ELECTRON_MIRROR: 'https://npmmirror.com/mirrors/electron/', - NVM_NODEJS_ORG_MIRROR: 'https://npmmirror.com/mirrors/node', + ELECTRON_BUILDER_BINARIES_MIRROR: 'https://npmmirror.com/mirrors/electron-builder-binaries/', + PHANTOMJS_CDNURL: 'https://npmmirror.com/mirrors/phantomjs', CHROMEDRIVER_CDNURL: 'https://npmmirror.com/mirrors/chromedriver', + SASS_BINARY_SITE: 'https://npmmirror.com/mirrors/node-sass', + NVM_NODEJS_ORG_MIRROR: 'https://npmmirror.com/mirrors/node', OPERADRIVER: 'https://npmmirror.com/mirrors/operadriver', - ELECTRON_BUILDER_BINARIES_MIRROR: 'https://npmmirror.com/mirrors/electron-builder-binaries/', PYTHON_MIRROR: 'https://npmmirror.com/mirrors/python', }, } diff --git a/packages/core/src/modules/plugin/node/index.js b/packages/core/src/modules/plugin/node/index.js index 1f060cdaab..1c4cc4f274 100644 --- a/packages/core/src/modules/plugin/node/index.js +++ b/packages/core/src/modules/plugin/node/index.js @@ -1,6 +1,31 @@ +const fs = require('node:fs') +const path = require('node:path') const jsonApi = require('@docmirror/mitmproxy/src/json') const nodeConfig = require('./config') +function getUserNpmrcPath () { + return path.join(process.env.USERPROFILE || process.env.HOME || '', '.npmrc') +} + +function isKeyInUserNpmrc (key) { + try { + if (!fs.existsSync(getUserNpmrcPath())) { + return false + } + const content = fs.readFileSync(getUserNpmrcPath(), 'utf8') + const target = key.toLowerCase() + return content.split(/\r?\n/).some((line) => { + const index = line.indexOf('=') + if (index <= 0) { + return false + } + return line.slice(0, index).trim().toLowerCase() === target + }) + } catch { + return false + } +} + const NodePlugin = function (context) { const { config, shell, event, log } = context const nodeApi = { @@ -43,25 +68,35 @@ const NodePlugin = function (context) { async setNpmEnv (list) { const command = config.get().plugin.node.setting.command || 'npm' - const cmds = [] + // npm config set 支持一次设置多个 key=value,合并成一条命令,避免启动多个 npm 进程拖慢速度 + const setArgs = [] + const deleteKeys = [] for (const item of list) { if (item.value != null && item.value.length > 0 && item.value !== 'default' && item.value !== 'null') { - cmds.push(`${command} config set ${item.key} ${item.value}`) + setArgs.push(`${item.key}=${item.value}`) } else { - cmds.push(`${command} config delete ${item.key}`) + deleteKeys.push(item.key) } } + + const cmds = [] + if (setArgs.length > 0) { + cmds.push(`${command} config set ${setArgs.join(' ')}`) + } + if (deleteKeys.length > 0) { + cmds.push(`${command} config delete ${deleteKeys.join(' ')}`) + } return await shell.exec(cmds, { type: 'cmd' }) }, async unsetNpmEnv (list) { const command = config.get().plugin.node.setting.command || 'npm' - const cmds = [] - for (const item of list) { - cmds.push(`${command} config delete ${item} `) - } - return await shell.exec(cmds, { type: 'cmd' }) + // npm config delete 支持一次删除多个 key,合并成一条命令 + const keys = list.map((item) => { + return typeof item === 'string' ? item : item.key + }).filter(Boolean) + return await shell.exec([`${command} config delete ${keys.join(' ')}`], { type: 'cmd' }) }, async setYarnEnv (list) { @@ -99,16 +134,57 @@ const NodePlugin = function (context) { hadSet: currentMap[key] === map[key], }) } + + // 环境变量形式的镜像配置,直接检查 process.env + const envMap = config.get().plugin.node.envVariables || {} + const defaultEnvMap = nodeConfig.envVariables || {} + for (const key in envMap) { + const oldValue = process.env[key] + list.push({ + key, + value: envMap[key], + oldValue, + exists: oldValue != null, + hadSet: oldValue === envMap[key], + defaultValue: defaultEnvMap[key], + }) + } return list }, async setVariables () { - const list = await nodeApi.getVariables() - const noSetList = list.filter((item) => { - return !item.exists + const nodeConfig = config.get().plugin.node + const envMap = nodeConfig.envVariables || {} + const variables = nodeConfig.variables || {} + + // 清理旧版通过 npm config 写入的镜像变量,避免 npm 未知配置告警。 + // 只有用户级 .npmrc 中确实存在这些 key 时才启动 npm 执行删除,避免每次启动/应用都白跑一遍 npm。 + const staleKeys = Object.keys(envMap).filter((key) => isKeyInUserNpmrc(key)) + if (staleKeys.length > 0) { + try { + await nodeApi.unsetNpmEnv(staleKeys) + } catch (e) { + log.warn('删除旧的 npm config 镜像变量失败:', e) + } + } + + // 每次启动/应用时都强制重写所有镜像环境变量,覆盖手动修改的值 + const envList = Object.keys(envMap).map((key) => { + return { key, value: envMap[key] } }) - if (noSetList.length > 0) { - return nodeApi.setNpmEnv(noSetList) + if (envList.length > 0) { + await shell.setSystemEnv({ list: envList }) + } + + // 其余非环境变量形式的 npm config 变量(当前默认为空),仅在确实配置了 variables 时才查询 npm + if (Object.keys(variables).length > 0) { + const list = await nodeApi.getVariables() + const noSetList = list.filter((item) => { + return !item.hadSet && envMap[item.key] == null + }) + if (noSetList.length > 0) { + await nodeApi.setNpmEnv(noSetList) + } } }, @@ -124,9 +200,10 @@ const NodePlugin = function (context) { async setProxy (ip, port) { const command = config.get().plugin.node.setting.command || 'npm' - const cmds = [ - `${command} config set proxy=http://${ip}:${port - 1}`, - `${command} config set https-proxy=http://${ip}:${port}`, + // npm config set 支持一次设置多个 key=value,合并成一条命令 + const setArgs = [ + `proxy=http://${ip}:${port - 1}`, + `https-proxy=http://${ip}:${port}`, ] const env = [] @@ -140,23 +217,23 @@ const NodePlugin = function (context) { const nodeConfig = config.get().plugin.node const rootCaCertFile = config.get().server.setting.rootCaFile.certPath if (nodeConfig.setting['strict-ssl']) { - cmds.push(`${command} config set strict-ssl false`) + setArgs.push('strict-ssl=false') } if (nodeConfig.setting.cafile) { - cmds.push(`${command} config set cafile "${rootCaCertFile}"`) + setArgs.push(`cafile=${rootCaCertFile}`) } if (nodeConfig.setting.NODE_EXTRA_CA_CERTS) { - cmds.push(`${command} config set NODE_EXTRA_CA_CERTS "${rootCaCertFile}"`) + setArgs.push(`NODE_EXTRA_CA_CERTS=${rootCaCertFile}`) env.push({ key: 'NODE_EXTRA_CA_CERTS', value: rootCaCertFile }) } if (nodeConfig.setting.NODE_TLS_REJECT_UNAUTHORIZED) { - cmds.push(`${command} config set NODE_TLS_REJECT_UNAUTHORIZED 0`) + setArgs.push('NODE_TLS_REJECT_UNAUTHORIZED=0') env.push({ key: 'NODE_TLS_REJECT_UNAUTHORIZED', value: '0' }) } - const ret = await shell.exec(cmds, { type: 'cmd' }) + const ret = await shell.exec([`${command} config set ${setArgs.join(' ')}`], { type: 'cmd' }) if (env.length > 0) { await shell.setSystemEnv({ list: env }) } @@ -169,15 +246,8 @@ const NodePlugin = function (context) { async unsetProxy () { const command = config.get().plugin.node.setting.command || 'npm' - const cmds = [ - `${command} config delete proxy`, - `${command} config delete https-proxy`, - `${command} config delete strict-ssl`, - `${command} config delete cafile`, - `${command} config delete NODE_EXTRA_CA_CERTS`, - `${command} config delete NODE_TLS_REJECT_UNAUTHORIZED`, - ] - const ret = await shell.exec(cmds, { type: 'cmd' }) + // npm config delete 支持一次删除多个 key,合并成一条命令 + const ret = await shell.exec([`${command} config delete proxy https-proxy strict-ssl cafile NODE_EXTRA_CA_CERTS NODE_TLS_REJECT_UNAUTHORIZED`], { type: 'cmd' }) event.fire('status', { key: 'plugin.node.enabled', value: false }) log.info('关闭【NPM】代理成功') return ret diff --git a/packages/core/src/modules/plugin/overwall/config.js b/packages/core/src/modules/plugin/overwall/config.js index 870cae95d5..762c2b947a 100644 --- a/packages/core/src/modules/plugin/overwall/config.js +++ b/packages/core/src/modules/plugin/overwall/config.js @@ -1,9 +1,11 @@ module.exports = { name: '梯子', enabled: false, // 默认关闭梯子 + restartServer: true, // 首页开关切换后需要重启代理服务才能生效 server: {}, serverDefault: { 'ow-prod.docmirror.top': { + id: 0, // 自带默认服务器 ID 固定为 0,用户新增服务器依次为 1、2、3... port: 443, path: 'X2dvX292ZXJfd2FsbF8', password: 'dev_sidecar_is_666', diff --git a/packages/core/src/modules/server/index.js b/packages/core/src/modules/server/index.js index c27a56ad47..5894d28d5b 100644 --- a/packages/core/src/modules/server/index.js +++ b/packages/core/src/modules/server/index.js @@ -19,6 +19,16 @@ function sleep (time) { }, time) }) } + +function onceExit (child) { + return new Promise((resolve) => { + if (child.exitCode != null || child.signalCode != null) { + resolve(true) + return + } + child.once('exit', () => resolve(true)) + }) +} const serverApi = { async startup () { if (config.get().server.startup) { @@ -30,7 +40,7 @@ const serverApi = { return this.close() } }, - async start ({ mitmproxyPath, plugins }) { + async start ({ mitmproxyPath, plugins, setting }) { // 防止重复启动:如果已有子进程存活,直接返回 if (server && server.process && !server.process.killed && server.process.exitCode == null) { log.warn('server is already running, skip start (pid:', server.id, ')') @@ -73,7 +83,11 @@ const serverApi = { plugin.overrideRunningConfig(serverConfig) } } - serverConfig.plugin = allConfig.plugin + serverConfig.plugin = lodash.cloneDeep(allConfig.plugin || {}) + if (setting && setting.overwall !== true && serverConfig.plugin.overwall) { + // setting.json 未开启 overwall 时,梯子插件不生效 + serverConfig.plugin.overwall.enabled = false + } if (allConfig.proxy && allConfig.proxy.enabled) { serverConfig.proxy = allConfig.proxy @@ -137,23 +151,34 @@ const serverApi = { event.fire('error', { key: 'server', value: code, error: msg.event, message: msg.message }) } else if (msg.type === 'speed') { event.fire('speed', msg.event) + } else if (msg.type === 'traffic') { + event.fire('traffic', msg.event) } }) return { port: serverConfig.port } }, async kill () { if (server) { - server.process.kill('SIGINT') - await sleep(1000) + const child = server.process + if (child.exitCode == null && child.signalCode == null) { + const exited = onceExit(child) + child.kill('SIGINT') + const exitedBySigint = await Promise.race([exited, sleep(1000).then(() => false)]) + if (!exitedBySigint) { + log.warn('server process 未在 1 秒内响应 SIGINT,尝试强制结束') + child.kill('SIGKILL') + await Promise.race([exited, sleep(1000).then(() => false)]) + } + } } fireStatus(false) }, async close () { return await serverApi.kill() }, - async restart ({ mitmproxyPath }) { + async restart ({ mitmproxyPath, setting }) { await serverApi.kill() - await serverApi.start({ mitmproxyPath }) + await serverApi.start({ mitmproxyPath, setting }) }, getServer () { return server diff --git a/packages/core/src/shell/scripts/set-system-env.js b/packages/core/src/shell/scripts/set-system-env.js index e9721c72a7..c2e648ad34 100644 --- a/packages/core/src/shell/scripts/set-system-env.js +++ b/packages/core/src/shell/scripts/set-system-env.js @@ -1,59 +1,41 @@ /** * 设置环境变量 */ +const Registry = require('winreg') const Shell = require('../shell') const execute = Shell.execute const executor = { async windows (exec, { list }) { - const psCmdsMachine = [] - const psCmdsUser = [] - for (const item of list) { - const v = item.value == null ? '' : String(item.value) - // escape single quotes for PowerShell single-quoted string - const escaped = v.replace(/'/g, "''") - psCmdsMachine.push(`[Environment]::SetEnvironmentVariable('${item.key}', '${escaped}', 'Machine')`) - psCmdsUser.push(`[Environment]::SetEnvironmentVariable('${item.key}', '${escaped}', 'User')`) + const regKey = new Registry({ + hive: Registry.HKCU, + key: '\\Environment', + }) + + const setItem = (item) => { + const value = item.value == null ? '' : String(item.value) + return new Promise((resolve, reject) => { + regKey.set(item.key, Registry.REG_SZ, value, (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) } - // Wrap whole flow and return structured result so caller can show detailed errors try { - let ret - let appliedScope = null + for (const item of list) { + await setItem(item) + } + + // 广播环境变量变更(setx 一个临时值触发 WM_SETTINGCHANGE) try { - ret = await exec(psCmdsMachine, { type: 'ps' }) - appliedScope = 'Machine:PowerShell' - } catch (eMachine) { - // try User scope via PowerShell - try { - ret = await exec(psCmdsUser, { type: 'ps' }) - appliedScope = 'User:PowerShell' - } catch (eUser) { - // PowerShell attempts failed; fallback to setx via cmd - const cmdsSetxMachine = [] - const cmdsSetxUser = [] - for (const item of list) { - const v = item.value == null ? '' : String(item.value) - // escape backslashes first, then double quotes (cmd.exe quoting order) - const escaped = v.replace(/\\/g, '\\\\').replace(/"/g, '\\"') - cmdsSetxMachine.push(`setx ${item.key} "${escaped}" /M`) - cmdsSetxUser.push(`setx ${item.key} "${escaped}"`) - } - try { - ret = await exec(cmdsSetxMachine, { type: 'cmd' }) - appliedScope = 'Machine:setx' - } catch (eSetxMachine) { - try { - ret = await exec(cmdsSetxUser, { type: 'cmd' }) - appliedScope = 'User:setx' - } catch (eSetxUser) { - // all attempts failed — include all error messages - const combined = [eMachine, eUser, eSetxMachine, eSetxUser].map(e => (e && e.message) || String(e)).join(' | ') - return { success: false, error: 'Failed to set environment variables', details: combined } - } - } - } + await exec('setx DS_REFRESH "1"', { type: 'cmd' }) + } catch { + // 广播失败不影响主流程 } // inject into current process so subsequent exec/child processes can inherit immediately @@ -70,9 +52,9 @@ const executor = { envUpdateError = e.message || String(e) } - return { success: true, scope: appliedScope, output: ret, envUpdateError } - } catch (finalErr) { - return { success: false, error: finalErr.message || String(finalErr), details: finalErr.stack } + return { success: true, scope: 'User:winreg', envUpdateError } + } catch (e) { + return { success: false, error: 'Failed to set environment variables', details: e.message || String(e) } } }, async linux (exec, { port }) { diff --git a/packages/core/src/shell/scripts/set-system-proxy/index.js b/packages/core/src/shell/scripts/set-system-proxy/index.js index 65ba6e536a..dadda3b35c 100644 --- a/packages/core/src/shell/scripts/set-system-proxy/index.js +++ b/packages/core/src/shell/scripts/set-system-proxy/index.js @@ -25,12 +25,81 @@ function getDomesticDomainAllowListTmpFilePath () { return path.join(config.get().server.setting.userBasePath, '/domestic-domain-allowlist.txt') } +// 通过 HKCU\Environment 注册表直接写入/删除环境变量,避免每次 setx 都启动 PowerShell 造成数秒卡顿 +function createEnvRegKey () { + return new Registry({ + hive: Registry.HKCU, + key: '\\Environment', + }) +} + +function setWindowsEnvVariable (regKey, key, value) { + return new Promise((resolve, reject) => { + regKey.set(key, Registry.REG_SZ, value == null ? '' : String(value), (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) +} + +function removeWindowsEnvVariable (regKey, key) { + return new Promise((resolve) => { + regKey.remove(key, (removeErr) => { + resolve(!removeErr) + }) + }) +} + +async function broadcastWindowsEnvChange (exec) { + try { + await exec('setx DS_REFRESH "1"', { type: 'cmd' }) + } catch { + // 广播失败不影响主流程 + } +} + +async function setWindowsEnvVariables (exec, envList) { + if (!envList || envList.length === 0) { + return + } + + const regKey = createEnvRegKey() + for (const item of envList) { + await setWindowsEnvVariable(regKey, item.key, item.value) + process.env[item.key] = String(item.value) + } + await broadcastWindowsEnvChange(exec) +} + +async function removeWindowsEnvVariables (exec, keys) { + if (!keys || keys.length === 0) { + return + } + + const regKey = createEnvRegKey() + let removed = false + for (const key of keys) { + const existed = await removeWindowsEnvVariable(regKey, key) + if (existed) { + delete process.env[key] + removed = true + } + } + if (removed) { + await broadcastWindowsEnvChange(exec) + } +} + async function downloadDomesticDomainAllowListAsync () { loadConfig() const remoteFileUrl = config.get().proxy.remoteDomesticDomainAllowListFileUrl log.info('开始下载远程 domestic-domain-allowlist.txt 文件:', remoteFileUrl) - request(remoteFileUrl, (error, response, body) => { + // 禁用环境变量代理:防止走 dev-sidecar 自己的代理(127.0.0.1:31181)导致启动时下载失败 + request(remoteFileUrl, { proxy: null }, (error, response, body) => { if (error) { log.error(`下载远程 domestic-domain-allowlist.txt 文件失败: ${remoteFileUrl}, error:`, error, ', response:', response, ', body:', body) return @@ -458,18 +527,27 @@ const executor = { log.info('开始设置windows系统代理:', ip, port, setEnv) - // https - let proxyAddr = `https=http://${ip}:${port}` - // http - if (config.get().proxy.proxyHttp) { - proxyAddr = `http=http://${ip}:${port - 1};${proxyAddr}` + const sysproxy = require('@starknt/sysproxy') + const proxyHttp = config.get().proxy.proxyHttp + + // 同时代理 HTTP+HTTPS 时需要两个端口,使用 WinINET 协议格式(协议=地址:端口); + // 只代理 HTTPS 时,地址和端口分开传参,Windows 会按规范分别填入“地址”和“端口”两个框。 + let proxyAddr + if (proxyHttp) { + proxyAddr = `http=${ip}:${port - 1};https=${ip}:${port}` + } else { + proxyAddr = `${ip}:${port}` } // 读取排除域名 const excludeIpStr = getProxyExcludeIpStr(';') // 设置代理,同时设置排除域名 try { - require('@starknt/sysproxy').triggerManualProxyByUrl(true, proxyAddr, excludeIpStr, true) + if (proxyHttp) { + sysproxy.triggerManualProxyByUrl(true, proxyAddr, excludeIpStr, true) + } else { + sysproxy.triggerManualProxy(true, ip, port, excludeIpStr) + } log.info(`设置windows系统代理成功: ${proxyAddr} ......(省略排除IP列表)`) } catch (e1) { log.warn('设置windows系统代理失败:执行 `@starknt/sysproxy` 失败,现尝试通过执行 `sysproxy.exe global ...` 来设置系统代理!\r\n捕获的异常:', e1) @@ -488,23 +566,24 @@ const executor = { if (setEnv) { // 设置全局代理所需的环境变量 try { - await exec(`echo '设置环境变量 HTTPS_PROXY${config.get().proxy.proxyHttp ? '、HTTP_PROXY' : ''}${setCaBundle ? '、REQUEST_CA_BUNDLE' : ''}'`) - - log.info(`开启系统代理的同时设置环境变量:HTTPS_PROXY = "http://${ip}:${port}/"`) - await exec(`setx HTTPS_PROXY "http://${ip}:${port}/"`) + const envList = [] + const httpsProxy = `http://${ip}:${port}/` + log.info(`开启系统代理的同时设置环境变量:HTTPS_PROXY = "${httpsProxy}"`) + envList.push({ key: 'HTTPS_PROXY', value: httpsProxy }) if (config.get().proxy.proxyHttp) { - log.info(`开启系统代理的同时设置环境变量:HTTP_PROXY = "http://${ip}:${port - 1}/"`) - await exec(`setx HTTP_PROXY "http://${ip}:${port - 1}/"`) + const httpProxy = `http://${ip}:${port - 1}/` + log.info(`开启系统代理的同时设置环境变量:HTTP_PROXY = "${httpProxy}"`) + envList.push({ key: 'HTTP_PROXY', value: httpProxy }) } if (setCaBundle) { const caCertPath = config.get().server.setting.rootCaFile.certPath log.info(`开启系统代理的同时设置环境变量:REQUEST_CA_BUNDLE = "${caCertPath}"`) - await exec(`setx REQUEST_CA_BUNDLE "${caCertPath}"`) + envList.push({ key: 'REQUEST_CA_BUNDLE', value: caCertPath }) } - // await addClearScriptIni() + await setWindowsEnvVariables(exec, envList) } catch (e) { log.error('设置环境变量 HTTPS_PROXY、HTTP_PROXY、REQUEST_CA_BUNDLE 失败:', e) } @@ -530,42 +609,9 @@ const executor = { } try { - await exec('echo \'删除环境变量 HTTPS_PROXY、HTTP_PROXY、REQUEST_CA_BUNDLE\'') - const regKey = new Registry({ // new operator is optional - hive: Registry.HKCU, // open registry hive HKEY_CURRENT_USER - key: '\\Environment', // key containing autostart programs - }) - regKey.get('HTTPS_PROXY', (err) => { - if (!err) { - regKey.remove('HTTPS_PROXY', async (err) => { - if (err) { - log.warn('删除环境变量 HTTPS_PROXY 失败:', err) - } else { - await exec('setx DS_REFRESH "1"') - } - }) - } - }) - regKey.get('HTTP_PROXY', (err) => { - if (!err) { - regKey.remove('HTTP_PROXY', async (err) => { - if (err) { - log.warn('删除环境变量 HTTP_PROXY 失败:', err) - } - }) - } - }) - regKey.get('REQUEST_CA_BUNDLE', (err) => { - if (!err) { - regKey.remove('REQUEST_CA_BUNDLE', async (err) => { - if (err) { - log.warn('删除环境变量 REQUEST_CA_BUNDLE 失败:', err) - } - }) - } - }) + await removeWindowsEnvVariables(exec, ['HTTPS_PROXY', 'HTTP_PROXY', 'REQUEST_CA_BUNDLE']) } catch (e) { - log.error('删除环境变量 HTTPS_PROXY、HTTP_PROXY 失败:', e) + log.error('删除环境变量 HTTPS_PROXY、HTTP_PROXY、REQUEST_CA_BUNDLE 失败:', e) } return true diff --git a/packages/core/src/utils/util.log-or-console.js b/packages/core/src/utils/util.log-or-console.js index f547f41991..3fb7afcc51 100644 --- a/packages/core/src/utils/util.log-or-console.js +++ b/packages/core/src/utils/util.log-or-console.js @@ -1,15 +1,18 @@ const dateUtil = require('./util.date') +// DEV_SIDECAR_LOG_DISABLED=true 时完全静默:不输出控制台,也不备份历史日志 +const disabled = process.env.DEV_SIDECAR_LOG_DISABLED === 'true' + // CLI 命令(如 status)会 require core 但不会启动,若默认输出到 console 会污染命令结果。 // 当 DEV_SIDECAR_LOG_TO_CONSOLE=false 时保持静默,但仍备份,待 setLogger 后回放进日志文件 -const silent = process.env.DEV_SIDECAR_LOG_TO_CONSOLE === 'false' +const silent = disabled || process.env.DEV_SIDECAR_LOG_TO_CONSOLE === 'false' let log = silent ? { debug () {}, info () {}, warn () {}, error () {} } : console // 将console中的日志缓存起来,当setLogger时,将控制台的日志写入日志文件 -let backupLogs = [] +let backupLogs = disabled ? null : [] function backup (fun, args) { if (backupLogs === null) { @@ -61,6 +64,10 @@ function _doLog (fun, args) { module.exports = { setLogger (logger) { + if (disabled) { + return + } + if (logger == null) { log.error('logger 不能为空') return diff --git a/packages/core/src/utils/util.logger.js b/packages/core/src/utils/util.logger.js index 6175f18676..9f33fa3526 100644 --- a/packages/core/src/utils/util.logger.js +++ b/packages/core/src/utils/util.logger.js @@ -7,8 +7,18 @@ const configFromFiles = defaultConfig.configFromFiles // 日志级别 const level = process.env.NODE_ENV === 'development' ? 'debug' : 'info' +// 是否完全禁用日志:不输出 stdout,也不写日志文件 +const logDisabled = process.env.DEV_SIDECAR_LOG_DISABLED === 'true' + // 是否同时输出到 stdout。默认开启(GUI/开发调试需要),CLI 守护进程通过环境变量关闭 -const logToConsole = process.env.DEV_SIDECAR_LOG_TO_CONSOLE !== 'false' +const logToConsole = !logDisabled && process.env.DEV_SIDECAR_LOG_TO_CONSOLE !== 'false' + +function createNoopLogger () { + const noop = () => {} + return { debug: noop, info: noop, warn: noop, error: noop, level: 'off', category: 'noop' } +} + +const noopLogger = logDisabled ? createNoopLogger() : null function getDefaultConfigBasePath () { if (configFromFiles.app.logFileSavePath) { @@ -40,6 +50,10 @@ let log = null // 设置一组日志配置 function log4jsConfigure (categories) { + if (logDisabled) { + return + } + if (log != null) { log.error('当前进程已经设置过日志配置,无法再设置更多日志配置:', categories) return @@ -69,6 +83,10 @@ function log4jsConfigure (categories) { module.exports = { getLogger (category) { + if (logDisabled) { + return noopLogger + } + if (!category) { if (log) { log.error('未指定日志类型,无法配置并获取日志对象!!!') diff --git a/packages/gui/electron-builder.config.cjs b/packages/gui/electron-builder.config.cjs index e45f4af3dd..31cb15baef 100644 --- a/packages/gui/electron-builder.config.cjs +++ b/packages/gui/electron-builder.config.cjs @@ -63,8 +63,10 @@ module.exports = { allowToChangeInstallationDirectory: true, }, win: { - icon: 'build/icons/', - signAndEditExecutable: isCI, // 本地开发跳过签名 + icon: 'build/icons/icon.ico', + // 必须为 true 才会用 rcedit 写入 exe 图标和版本信息; + // 未配置证书时 electron-builder 会自动跳过签名,不会失败。 + signAndEditExecutable: true, target: isCI ? [ { target: 'nsis', arch: ['x64'] }, @@ -85,11 +87,7 @@ module.exports = { { target: 'rpm', arch: ['x64', 'arm64', 'armv7l'] }, { target: 'flatpak', arch: ['x64'] }, // Arch Linux (fpm/pacman), 产物为 .pkg.tar.xz, 可直接 pacman -U 安装 - { - target: 'pacman', - arch: ['x64', 'arm64'], - artifactName: 'DevSidecar-${version}-${arch}.pkg.tar.xz', - }, + { target: 'pacman', arch: ['x64', 'arm64'] }, ] : [ { target: 'deb', arch: [localArch] }, diff --git a/packages/gui/package.json b/packages/gui/package.json index 637846724f..e414060e7a 100644 --- a/packages/gui/package.json +++ b/packages/gui/package.json @@ -24,7 +24,9 @@ "@docmirror/dev-sidecar": "workspace:*", "@docmirror/mitmproxy": "workspace:*", "@starknt/shutdown-handler-napi": "^0.0.3", + "@starknt/shutdown-handler-napi-win32-x64-msvc": "^0.0.3", "@starknt/sysproxy": "^0.0.3", + "@starknt/sysproxy-win32-x64-msvc": "^0.0.3", "@vscode/sudo-prompt": "^9.3.2", "adm-zip": "^0.5.17", "ant-design-vue": "^4.2.6", diff --git a/packages/gui/src/background.js b/packages/gui/src/background.js index 0c5b3ede38..0f51e4c0b1 100644 --- a/packages/gui/src/background.js +++ b/packages/gui/src/background.js @@ -1,4 +1,5 @@ 'use strict' +import './utils/util.log-env.js' import path from 'node:path' import { fileURLToPath } from 'node:url' import DevSidecar from '@docmirror/dev-sidecar' @@ -15,6 +16,12 @@ const isWindows = process.platform === 'win32' const isLinux = process.platform === 'linux' const isMac = process.platform === 'darwin' +if (isWindows) { + // 让 Windows 托盘气泡/通知/任务栏显示正常软件名,而不是开发模式默认的 electron.app + app.setName('dev-sidecar') + app.setAppUserModelId('dev-sidecar') +} + // 禁用不需要的 Chromium 组件以减少内存和 CPU 占用 // 这些开关必须在 app.whenReady() 之前设置 @@ -240,18 +247,30 @@ function changeAppConfig (config) { } function loadAppIcon () { - // 优先:从 asar 内读取(fs.readFileSync 被 Electron 补丁支持 asar) - try { - const p = path.join(app.getAppPath(), 'dist', 'icon.png') - if (fs.existsSync(p)) return nativeImage.createFromBuffer(fs.readFileSync(p)) - } catch { /* ignore */ } + const candidates = [] + + // Windows 下优先使用 exe 同款 ico,任务栏图标更清晰 + if (isWindows) { + candidates.push(path.join(process.resourcesPath, 'extra', 'icons', 'icon.ico')) + } + // 优先:从 asar 内读取 + candidates.push(path.join(app.getAppPath(), 'dist', 'icon.png')) // 回退:asar.unpacked 真实路径 - try { - const p = path.join(app.getAppPath(), '..', 'app.asar.unpacked', 'dist', 'icon.png') - if (fs.existsSync(p)) return nativeImage.createFromPath(p) - } catch { /* ignore */ } + candidates.push(path.join(app.getAppPath(), '..', 'app.asar.unpacked', 'dist', 'icon.png')) + // 再回退:extra 资源目录 + candidates.push(path.join(process.resourcesPath, 'extra', 'icons', '512x512.png')) // 开发模式回退 - return nativeImage.createFromPath(path.resolve('public/icon.png')) + candidates.push(path.resolve('public/icon.png')) + + for (const p of candidates) { + try { + if (p && fs.existsSync(p)) { + return nativeImage.createFromPath(p) + } + } catch { /* ignore */ } + } + + return nativeImage.createEmpty() } function createWindow (startHideWindow, autoQuitIfError = true) { @@ -295,9 +314,7 @@ function createWindow (startHideWindow, autoQuitIfError = true) { if (process.env.WEBPACK_DEV_SERVER_URL) { // Load the url of the dev server if in development mode win.loadURL(process.env.WEBPACK_DEV_SERVER_URL) - if (!process.env.IS_TEST) { - setTimeout(openDevTools, 2000) - } + // 默认不自动打开 DevTools,需要调试时可手动按 F12 或通过托盘菜单打开 } else { // Load the index.html when not in development win.loadFile(path.join(app.getAppPath(), 'dist', 'index.html')) diff --git a/packages/gui/src/bridge/api/backend.js b/packages/gui/src/bridge/api/backend.js index f89be23a05..f41b2e6247 100644 --- a/packages/gui/src/bridge/api/backend.js +++ b/packages/gui/src/bridge/api/backend.js @@ -175,7 +175,7 @@ const localApi = { * @returns {Promise} */ startup () { - return DevSidecar.api.startup({ mitmproxyPath }) + return DevSidecar.api.startup({ mitmproxyPath, setting: localApi.setting.load() }) }, server: { /** @@ -183,14 +183,14 @@ const localApi = { * @returns {Promise<{port: *}>} */ start () { - return DevSidecar.api.server.start({ mitmproxyPath }) + return DevSidecar.api.server.start({ mitmproxyPath, setting: localApi.setting.load() }) }, /** * 重启代理服务 * @returns {Promise} */ restart () { - return DevSidecar.api.server.restart({ mitmproxyPath }) + return DevSidecar.api.server.restart({ mitmproxyPath, setting: localApi.setting.load() }) }, }, shell: { @@ -281,6 +281,11 @@ export default { win.webContents.send('speed', event) } }) + DevSidecar.api.event.register('traffic', (event) => { + if (win) { + win.webContents.send('traffic', event) + } + }) // 合并用户配置 DevSidecar.api.config.reload() diff --git a/packages/gui/src/utils/util.log-env.js b/packages/gui/src/utils/util.log-env.js new file mode 100644 index 0000000000..078b1232a4 --- /dev/null +++ b/packages/gui/src/utils/util.log-env.js @@ -0,0 +1,20 @@ +// 必须在加载 core/mitmproxy 日志模块之前 import,确保 DEV_SIDECAR_LOG_DISABLED 尽早生效 +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +if (process.env.DEV_SIDECAR_LOG_DISABLED !== 'true') { + const userBase = path.join(process.env.USERPROFILE || process.env.HOME || '/', '.dev-sidecar') + const configPath = path.join(userBase, 'config.json') + + try { + if (fs.existsSync(configPath)) { + const userConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + if (userConfig && userConfig.app && userConfig.app.logDisabled === true) { + process.env.DEV_SIDECAR_LOG_DISABLED = 'true' + } + } + } catch { + // 配置文件不存在或格式异常时,按默认(允许日志)处理 + } +} diff --git a/packages/gui/src/view/pages/index.vue b/packages/gui/src/view/pages/index.vue index e426f69151..9b19e0eb20 100644 --- a/packages/gui/src/view/pages/index.vue +++ b/packages/gui/src/view/pages/index.vue @@ -62,6 +62,15 @@ export default { await this.doCheckRootCa() await this.reloadConfig() this.status = this.$status + // setting.json 未开启 overwall 时,梯子开关不显示,同时确保梯子配置不生效 + if (!this.setting.overwall && this.config?.plugin?.overwall?.enabled) { + this.config.plugin.overwall.enabled = false + const saveRet = await this.$api.config.save(lodash.cloneDeep(this.config)) + this.config = saveRet.allConfig + if (this.status.server?.enabled) { + await this.$api.server.restart() + } + } this.switchBtns = this.createSwitchBtns() // 合并全局更新参数到 data() 中的默认值,避免 $global.update 尚未 // 初始化时(update/front.js 在 mount 之后才执行)覆盖为 undefined @@ -203,19 +212,25 @@ export default { btns.server = this.createSwitchBtn('server', '代理服务', this.$api.server, status) btns.proxy = this.createSwitchBtn('proxy', '系统代理', this.$api.proxy, status) lodash.forEach(status.plugin, (item, key) => { + // setting.json 未开启 overwall 时,首页不显示“梯子”开关 + if (key === 'overwall' && !this.setting.overwall) { + return + } if (this.config.plugin[key].statusOff) { return } - btns[key] = this.createSwitchBtn(key, this.config.plugin[key].name, this.$api.plugin[key], status.plugin, this.config.plugin[key].tip) + btns[key] = this.createSwitchBtn(key, this.config.plugin[key].name, this.$api.plugin[key], status.plugin, this.config.plugin[key].tip, this.config.plugin[key]) }) return btns }, - createSwitchBtn (key, label, apiTarget, statusParent, tip) { + createSwitchBtn (key, label, apiTarget, statusParent, tip, pluginConfig) { return { loading: false, key, label, tip, + isPlugin: pluginConfig != null, + restartServer: pluginConfig != null && pluginConfig.restartServer === true, status: () => { return statusParent[key].enabled }, @@ -238,12 +253,25 @@ export default { } }, - onSwitchClick (btn, openApi, closeApi, checked) { - if (checked) { - return this.apiCall(btn, openApi) - } else { - return this.apiCall(btn, closeApi) + async onSwitchClick (btn, openApi, closeApi, checked) { + const ret = checked + ? await this.apiCall(btn, openApi) + : await this.apiCall(btn, closeApi) + + // 插件快捷开关需要持久化 enabled 状态,并与对应设置页保持同步 + if (btn.isPlugin) { + try { + this.config.plugin[btn.key].enabled = checked + const saveRet = await this.$api.config.save(lodash.cloneDeep(this.config)) + this.config = saveRet.allConfig + if (btn.restartServer && this.status.server && this.status.server.enabled) { + await this.$api.server.restart() + } + } catch (e) { + console.error('保存插件开关状态失败:', e) + } } + return ret }, onServerClick (checked) { return this.onSwitchClick(this.server, this.$api.server.start, this.$api.server.close, checked) diff --git a/packages/gui/src/view/pages/plugin/node.vue b/packages/gui/src/view/pages/plugin/node.vue index d77fdff9a4..4fdbeeb3d6 100644 --- a/packages/gui/src/view/pages/plugin/node.vue +++ b/packages/gui/src/view/pages/plugin/node.vue @@ -54,6 +54,33 @@ export default defineComponent({ console.log('type', type) await this.$api.plugin.node.setRegistry({ registry, type }) }, + restoreVariableDefault (item) { + if (item && item.defaultValue != null) { + item.value = item.defaultValue + } + }, + async applyBefore () { + // 将页面上编辑后的镜像变量值保存到 config,再交给 saveConfig 持久化 + if (this.npmVariables && this.npmVariables.length > 0) { + const envVariables = {} + for (const item of this.npmVariables) { + if (item.key) { + envVariables[item.key] = item.value + } + } + this.config.plugin.node.envVariables = envVariables + } + }, + async applyAfter () { + // 应用按钮点击后,应用当前配置里的镜像环境变量 + try { + await this.$api.plugin.node.setVariables() + await this.ready() + } catch (e) { + console.error('set variables error:', e) + this.$message.warning('镜像变量设置失败,请查看日志') + } + }, setNpmVariableAll () { this.saveConfig().then(() => { this.$api.plugin.node.setVariables() @@ -137,13 +164,18 @@ export default defineComponent({ - - + + + + + 默认 + + diff --git a/packages/gui/src/view/pages/plugin/overwall.vue b/packages/gui/src/view/pages/plugin/overwall.vue index 39218de649..5de309d1f8 100644 --- a/packages/gui/src/view/pages/plugin/overwall.vue +++ b/packages/gui/src/view/pages/plugin/overwall.vue @@ -29,6 +29,30 @@ export default defineComponent({ } }, + computed: { + serverOptions () { + const options = [] + const defaultServer = this.config?.plugin?.overwall?.serverDefault || {} + for (const [domain, cfg] of Object.entries(defaultServer)) { + options.push({ + id: cfg && cfg.id != null ? Number.parseInt(cfg.id, 10) : 0, + domain, + label: `ID 0(默认) - ${domain}`, + }) + } + for (const item of this.servers || []) { + if (item.key) { + options.push({ + id: item.id, + domain: item.key, + label: `ID ${item.id} - ${item.key}`, + }) + } + } + return options + }, + }, + created () { console.log('status:', this.status) }, @@ -46,26 +70,55 @@ export default defineComponent({ } }, ready () { - this.initTarget() this.initServer() + this.initTarget() }, async applyBefore () { this.submitTarget() this.submitServer() }, + preferredServerId () { + return this.serverOptions.some((item) => item.id === 1) + ? 1 + : (this.serverOptions.some((item) => item.id === 0) ? 0 : null) + }, + nextServerId () { + const ids = (this.servers || []).map((item) => item.id).filter((id) => id != null) + return ids.length > 0 ? Math.max(...ids) + 1 : 1 + }, + onTargetValueChange (item) { + if (item.value === 'true' && (item.serverId == null || item.serverId === '')) { + item.serverId = 'default' + } + }, initTarget () { this.targets = [] - const targetsMap = this.config.plugin.overwall.targets + const targetsMap = this.config.plugin.overwall.targets || {} for (const key in targetsMap) { const value = targetsMap[key] - this.targets.push({ - key: key || '', - value: value === true ? 'true' : 'false', - }) + if (value && typeof value === 'object') { + this.targets.push({ + key: key || '', + value: value.enabled === false ? 'false' : 'true', + serverId: value.serverId != null ? Number.parseInt(value.serverId, 10) : 'default', + }) + } else if (value === false || value === 'false') { + this.targets.push({ + key: key || '', + value: 'false', + serverId: 'default', + }) + } else { + this.targets.push({ + key: key || '', + value: 'true', + serverId: 'default', + }) + } } }, addTarget () { - this.targets.unshift({ key: '', value: 'true' }) + this.targets.unshift({ key: '', value: 'true', serverId: 'default' }) this.focusFirst(this.$refs.targets) }, deleteTarget (item, index) { @@ -77,7 +130,16 @@ export default defineComponent({ if (item.key) { const hostname = this.handleHostname(item.key) if (hostname) { - map[hostname] = (item.value === 'true') + if (item.value === 'true') { + // 选择“默认”时不写死服务器,交给中间件动态选择:优先 ID1,否则 ID0 + if (item.serverId === 'default' || item.serverId == null || item.serverId === '') { + map[hostname] = true + } else { + map[hostname] = { enabled: true, serverId: Number.parseInt(item.serverId, 10) } + } + } else { + map[hostname] = false + } } } } @@ -86,12 +148,19 @@ export default defineComponent({ initServer () { this.servers = [] - const targetsMap = this.config.plugin.overwall.server - for (const key in targetsMap) { - const value = targetsMap[key] + const serverMap = this.config.plugin.overwall.server || {} + for (const key in serverMap) { + const value = serverMap[key] || {} + const id = value.id != null ? Number.parseInt(value.id, 10) : this.nextServerId() this.servers.push({ + id, key, - value, + value: { + type: value.type || 'path', + port: value.port, + path: value.path, + password: value.password, + }, }) } if (this.servers.length === 0) { @@ -102,7 +171,11 @@ export default defineComponent({ this.servers.splice(index, 1) }, addServer (needFocus = true) { - this.servers.unshift({ key: '', value: { type: 'path' } }) + this.servers.unshift({ + id: this.nextServerId(), + key: '', + value: { type: 'path', port: 443, path: '', password: '' }, + }) if (needFocus) { this.focusFirst(this.$refs.servers) } @@ -113,7 +186,13 @@ export default defineComponent({ if (item.key) { const hostname = this.handleHostname(item.key) if (hostname) { - map[hostname] = item.value + map[hostname] = { + id: item.id != null ? Number.parseInt(item.id, 10) : this.nextServerId(), + type: item.value.type || 'path', + port: item.value.port, + path: item.value.path, + password: item.value.password, + } } } } @@ -173,23 +252,31 @@ export default defineComponent({
- PAC没有拦截到的域名,可以在此处定义;配置为禁用时,将不使用梯子 + PAC没有拦截到的域名,可以在此处定义;配置为启用时,可为该域名选择代理服务器,默认优先使用 ID 1,若 ID 1 不存在则自动使用 ID 0;配置为禁用时,将不使用梯子 - + - + {{ item2.label }} + + + 默认(优先 ID1,否则 ID0) + + {{ server.label }} + + + @@ -207,13 +294,16 @@ export default defineComponent({ - + + ID {{ item.id }} + + - + @@ -224,6 +314,7 @@ export default defineComponent({
+ ID 0 为内置默认服务器;在此处新增的服务器 ID 会依次为 1、2、3...
您可以在此处配置自己的代理服务器地址。
警告:请勿使用来源不明的服务器地址,有安全风险!
diff --git a/packages/gui/src/view/pages/server.vue b/packages/gui/src/view/pages/server.vue index ce4412a575..c74c68c250 100644 --- a/packages/gui/src/view/pages/server.vue +++ b/packages/gui/src/view/pages/server.vue @@ -3,7 +3,7 @@ import { defineComponent } from 'vue'; import _ from 'lodash' import JsonEditor from '@/view/components/JsonEditor.vue' -import { CheckOutlined, InfoCircleOutlined, PlusOutlined, MinusOutlined, SyncOutlined, ReloadOutlined } from '@ant-design/icons-vue' +import { CheckOutlined, CloudOutlined, InfoCircleOutlined, PlusOutlined, MinusOutlined, SyncOutlined, ReloadOutlined } from '@ant-design/icons-vue' import Plugin from '../mixins/plugin' export default defineComponent({ @@ -12,6 +12,7 @@ export default defineComponent({ components: { JsonEditor, CheckOutlined, + CloudOutlined, InfoCircleOutlined, PlusOutlined, MinusOutlined, @@ -211,6 +212,13 @@ export default defineComponent({ reSpeedTest () { this.$api.server.reSpeedTest() }, + hasCf (item) { + if (!item) { + return false + } + const list = (item.alive || []).concat(item.backupList || []) + return list.some((element) => element.cf === true) + }, registerSpeedTestEvent () { const listener = async (event, message) => { console.log('get speed event', event, message) @@ -510,6 +518,7 @@ export default defineComponent({