diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 5127ea19ef..230cb1332d 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -248,14 +248,12 @@ jobs: mv DevSidecar-${{ steps.package-info.outputs.version }}-x86_64.AppImage DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.AppImage; mv DevSidecar-${{ steps.package-info.outputs.version }}-x64.tar.gz DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.tar.gz; mv DevSidecar-${{ steps.package-info.outputs.version }}-x86_64.rpm DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.rpm; - mv DevSidecar-${{ steps.package-info.outputs.version }}-x64.pkg.tar.xz DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.pkg.tar.xz; # mv DevSidecar-${{ steps.package-info.outputs.version }}-x86_64.flatpak DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.flatpak; #------------------------------------------------------------------------------------------------------------------------- mv DevSidecar-${{ steps.package-info.outputs.version }}-arm64.deb DevSidecar-${{ steps.package-info.outputs.version }}-linux-arm64.deb; mv DevSidecar-${{ steps.package-info.outputs.version }}-arm64.AppImage DevSidecar-${{ steps.package-info.outputs.version }}-linux-arm64.AppImage; mv DevSidecar-${{ steps.package-info.outputs.version }}-arm64.tar.gz DevSidecar-${{ steps.package-info.outputs.version }}-linux-arm64.tar.gz; mv DevSidecar-${{ steps.package-info.outputs.version }}-aarch64.rpm DevSidecar-${{ steps.package-info.outputs.version }}-linux-arm64.rpm; - mv DevSidecar-${{ steps.package-info.outputs.version }}-arm64.pkg.tar.xz DevSidecar-${{ steps.package-info.outputs.version }}-linux-arm64.pkg.tar.xz; # arm64 flatpak is on Linux ARM64 runner #------------------------------------------------------------------------------------------------------------------------- mv DevSidecar-${{ steps.package-info.outputs.version }}-armv7l.deb DevSidecar-${{ steps.package-info.outputs.version }}-linux-armv7l.deb; @@ -332,13 +330,7 @@ jobs: path: packages/gui/dist_electron/DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.rpm name: "DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.rpm" if-no-files-found: error - - name: "Upload DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.pkg.tar.xz" - uses: actions/upload-artifact@v4.4.0 - if: ${{ matrix.os == 'ubuntu' }} - with: - path: packages/gui/dist_electron/DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.pkg.tar.xz - name: "DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.pkg.tar.xz" - if-no-files-found: error + # - name: "Upload DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.flatpak" # uses: actions/upload-artifact@v4.4.0 # if: ${{ matrix.os == 'ubuntu' }} @@ -376,13 +368,6 @@ jobs: path: packages/gui/dist_electron/DevSidecar-${{ steps.package-info.outputs.version }}-linux-arm64.rpm name: "DevSidecar-${{ steps.package-info.outputs.version }}-linux-arm64.rpm" if-no-files-found: error - - name: "Upload DevSidecar-${{ steps.package-info.outputs.version }}-linux-arm64.pkg.tar.xz" - uses: actions/upload-artifact@v4.4.0 - if: ${{ matrix.os == 'ubuntu' }} - with: - path: packages/gui/dist_electron/DevSidecar-${{ steps.package-info.outputs.version }}-linux-arm64.pkg.tar.xz - name: "DevSidecar-${{ steps.package-info.outputs.version }}-linux-arm64.pkg.tar.xz" - if-no-files-found: error # ------------------------------------------------------------------------------------------------------------------------- - name: "Upload DevSidecar-${{ steps.package-info.outputs.version }}-linux-armv7l.deb" @@ -622,11 +607,6 @@ jobs: with: name: "DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.rpm" path: release - - name: "Download DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.pkg.tar.xz" - uses: actions/download-artifact@v4.1.8 - with: - name: "DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.pkg.tar.xz" - path: release #- name: "Download DevSidecar-${{ steps.package-info.outputs.version }}-linux-x86_64.flatpak" # uses: actions/download-artifact@v4.1.8 # with: @@ -653,11 +633,6 @@ jobs: with: name: "DevSidecar-${{ steps.package-info.outputs.version }}-linux-arm64.rpm" path: release - - name: "Download DevSidecar-${{ steps.package-info.outputs.version }}-linux-arm64.pkg.tar.xz" - uses: actions/download-artifact@v4.1.8 - with: - name: "DevSidecar-${{ steps.package-info.outputs.version }}-linux-arm64.pkg.tar.xz" - path: release # ------------------------------------------------------------------------------------------------------------------------- - name: "Download DevSidecar-${{ steps.package-info.outputs.version }}-linux-armv7l.deb" uses: actions/download-artifact@v4.1.8 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/doc/wiki/\345\212\240\351\200\237\346\234\215\345\212\241\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/doc/wiki/\345\212\240\351\200\237\346\234\215\345\212\241\344\275\277\347\224\250\350\257\264\346\230\216.md" index c46b72f04b..c6cf4ba127 100644 --- "a/doc/wiki/\345\212\240\351\200\237\346\234\215\345\212\241\344\275\277\347\224\250\350\257\264\346\230\216.md" +++ "b/doc/wiki/\345\212\240\351\200\237\346\234\215\345\212\241\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -1,179 +1,173 @@ # 1. 加速服务: + 1. 什么是 `加速服务`? + `加速服务` 即 `代理服务`,它通过中间人攻击的方式,将网络请求拦截下来,并经过DNS加速、篡改、重定向、代理等一系列的功能,达到加速访问、或访问原本无法访问的站点等目的。 -1. 什么是 `加速服务`? - - - `加速服务` 即 `代理服务`,它通过中间人攻击的方式,将网络请求拦截下来,并经过DNS加速、修改、重定向、代理等一系列的功能,达到加速访问、或访问原本无法访问的站点等目的。
-2. 如何启动加速服务:
- - 点击首页的【代理服务】右侧的开关按钮,即可启动/关闭加速服务。
- - 点击首页的【系统代理】右侧的开关按钮,即可将dev-sidecar设置/不设置为系统默认代理。(系统只能有一个默认代理,在将dev-sidecar与其他网络辅助软件共用时请谨慎开启本开关)
- - 点击首页的【NPM加速】和【Git.exe代理】右侧的开关按钮,即可启动/关闭dev-sidecar为对应软件提供的加速服务。如果你的电脑上并未安装NPM或Git,则这两个按钮将不可用,这是正常情况。 - + 2. 启动加速服务:点击首页的 `代理服务` 右侧的开关按钮,即可启动加速服务。 # 2. 根证书使用说明: - -1. 什么是根证书:TODO -2. [为什么需要安装根证书这么高风险性的步骤](https://github.com/docmirror/dev-sidecar/blob/master/doc/caroot.md) -3. 如何安装根证书:参见dev-sidecar【首页】的【安装根证书】按钮(注意Firefox浏览器还需要一次手动导入根证书) + 1. 什么是根证书? + 2. [为什么需要安装根证书这么高风险性的步骤](https://github.com/docmirror/dev-sidecar/blob/master/doc/caroot.md) + 3. 如何安装根证书: # 3. 模式: - -1. 安全模式:TODO -2. 默认模式:TODO -3. 增强模式(彩蛋):TODO + 1. 安全模式: + 2. 默认模式: + 3. 增强模式(彩蛋): # 4. 拦截功能使用和配置说明: - -## 4.1. 拦截器类型: - -### 1)请求拦截器: -| 请求拦截器名称 | 拦截器配置名 | 请求拦截优先级 | 作用 | -| ----------------- | -------------- | ------------- | --------- | -| OPTIONS请求拦截器 | options | 101 | 直接响应200,不发送该OPTIONS请求 | -| 快速成功拦截器 | success | 102 | 直接响应200,不发送该请求 | -| 快速失败拦截器 | abort | 103 | 直接响应403,不发送该请求 | -| 缓存请求拦截器 | cacheXxx | 104 | 如果缓存还生效,直接响应304,不发送该请求
如果缓存已过期或无缓存,则发送请求
注:只对GET请求生效! | -| 重定向拦截器 | redirect | 105 | 重定向到指定地址,直接响应302,不发送该请求 | -| 请求篡改拦截器 | requestReplace | 111 | 篡改请求头,达到想要的目的 | -| 代理拦截器 | proxy | 121 | 将请求转发到指定地址 | -| SNI拦截器 | sni | 122 | 设置 `servername`,用于避开GFW | - -### 2)响应拦截器: -| 响应拦截器名称 | 拦截器配置名 | 响应拦截优先级 | 作用 | -| ---------------- | --------------- | ------------- | --------- | -| OPTIONS响应拦截器 | options | 201 | 设置跨域所需的响应头,避免被浏览器的跨域策略阻拦 | -| 缓存响应拦截器 | cacheXxx | 202 | 设置缓存所需的响应头,使浏览器缓存当前请求
注:只对GET请求生效! | -| 响应篡改拦截器 | responseReplace | 203 | 篡改响应头,避免被浏览器的安全策略阻拦 | -| 脚本拦截器 | script | 211 | 注入JavaScript脚本到页面中,如:Github油猴脚本 | - -## 4.2. 拦截配置说明书: - -TODO:内容待完善 + 1. 请求拦截器: + | 请求拦截器名称 | 拦截器配置名 | 请求拦截优先级 | 快速响应码 | + | ----------------- | -------------- | ------------- | --------- | + | OPTIONS请求拦截器 | options | 101 | 200 | + | 快速成功拦截器 | success | 102 | 200 | + | 快速失败拦截器 | abort | 103 | 403 | + | 缓存请求拦截器 | cacheXxx | 104 | 304 | + | 重定向拦截器 | redirect | 105 | 302 | + | 请求篡改拦截器 | requestReplace | 111 | | + | 代理拦截器 | proxy | 121 | | + | SNI拦截器 | sni | 122 | | + + 2. 响应拦截器: + | 响应拦截器名称 | 拦截器配置名 | 响应拦截优先级 | + | ---------------- | --------------- | ------------- | + | OPTIONS响应拦截器 | options | 201 | + | 缓存响应拦截器 | cacheXxx | 202 | + | 响应篡改拦截器 | responseReplace | 203 | + | 脚本拦截器 | script | 211 | # 5. 域名白名单: -选择哪些域名不会被dev-sidecar处理。 - -**注意:** 该设置与【系统代理-自定义排除域名】的区别在于: - -1. 前者只是被dev-sidecar自身忽略,后者则是写入系统设置、不会被(任何的)系统代理处理,在手动修改系统代理设置时务必小心后者可能残留的作用! -2. 在条目较多时,前者的性能不如后者,可能产生明显延迟。
- -在config.json的 `proxy.excludeIpList:object` 中设置,**该字段**格式如下:
-> 注意:这里点号用来作为JSON object嵌套关系的缩写,冒号指明该条目的类型(主要用来区分object和list),并没有哪一个Object的key为 `proxy.excludeIpList`。为避免歧义,配置中object和list的key总不应包含点号。下同) +# 6. DNS服务管理: +## 6.1. 配置 `DNS-over-HTTPS` 的DNS服务: ```json { - "proxy": { - "excludeIpList": { - "example1.com": true, - "example2.com": false, - "example3.com": null, - "example4.com": { - "desc1": "域名对应字段设置为false时会被处理,null会移除现有设置(多用于远程配置)", - "desc2": "其他情况下就和设置true一样,不会被处理。因而你可以像这样插入注释", - "desc3": "同样的技巧可以用在其他本应设置一个bool值的地方", - "desc4": "原则上来说config.json不支持//形式的注释,但下文为了方便阅读,还是这么写了" - } - } + "cloudflare": { + "type": "https", + "server": "https://1.1.1.1/dns-query", + "cacheSize": 1000 + } +} +``` +或 +```json +{ + "cloudflare": { + "server": "https://1.1.1.1/dns-query", // 地址上带有 `https://`,type可以不配置 + "cacheSize": 1000 } } ``` -# 6. DNS服务管理: - -用来配置在dev-sidecar中需要的指定DNS,出于保密和可靠起见建议使用DoH和DoT。
-在 `server.dns.provider:object` 中设置,**其中的每个条目** 格式如下: - -## 6.1. 配置 `DNS-over-HTTPS`(简称DoH): -> 注:并非被所有DNS支持,但是保证只要能使用就一定匿名且可靠的DNS服务。 +## 6.2. 配置 `DNS-over-TLS` 的DNS服务: ```json -"cloudflare": { - "type": "https", // 如果server上以"https://"开头指明了协议,就不需要写type了 - "server": "https://1.1.1.1/dns-query", - "cacheSize": 1000 +{ + "cloudflareTLS": { + "type": "tls", + "server": "1.1.1.1", + "port": 853, // 不配置时,默认端口为:853 + "servername": "cloudflare-dns.com", // SNI + "cacheSize": 1000 + } } ``` +或 -## 6.2. 配置 `DNS-over-TLS`(简称DoT): -> 并非被所有DNS支持,但是保证只要能使用就一定匿名且可靠的DNS服务。 +```json +{ + "cloudflareTLS": { + "server": "tls://1.1.1.1", + "port": 853, // 不配置时,默认端口为:853 + "servername": "cloudflare-dns.com", // SNI + //"sni": "cloudflare-dns.com", // SNI缩写配置 + "cacheSize": 1000 + } +} +``` +或 ```json -"cloudflareTLS": { - "type": "tls", // 如果server上以"tls://"开头指明了协议,就不需要写type了 - "server": "1.1.1.1", - "port": 853, // 不配置时,默认端口为:853 - "servername": "cloudflare-dns.com", // 需要伪造成的SNI - //"sni": "cloudflare-dns.com", // SNI缩写配置 - "cacheSize": 1000 +{ + "cloudflareTLS": { + "server": "tls://1.1.1.1:853", + "servername": "cloudflare-dns.com", // SNI + //"sni": "cloudflare-dns.com", // SNI缩写配置 + "cacheSize": 1000 + } } ``` ## 6.3. 配置 `TCP` 的DNS服务: -> 并非被所有DNS支持,该方法既不保密也不可靠 ```json -"googleTCP": { - "type": "tcp", // 如果server上以"tcp://"开头指明了协议,就不需要写type了 - "server": "8.8.8.8", - "port": 53, // 不配置时,默认端口为:53 - "cacheSize": 1000 +{ + "googleTCP": { + "type": "tcp", + "server": "8.8.8.8", + "port": 53, // 不配置时,默认端口为:53 + "cacheSize": 1000, + } } ``` - -## 6.4. 配置 `UDP` 的DNS服务: -> 所有DNS服务器均支持UDP方式,但该方法既不保密也不可靠 +或 ```json -"google": { - "type": "udp", // 如果server上以"udp://"开头指明了协议,就不需要写type了 - "server": "8.8.8.8", - "port": 53, // 不配置时,默认端口为:53 - "cacheSize": 1000 +{ + "googleTCP": { + "server": "tcp://8.8.8.8", + "port": 53, // 不配置时,默认端口为:53 + "cacheSize": 1000, + } } ``` - -# 7. DNS设置: - -选择哪些域名需要使用指定的DNS(需要先在【DNS服务管理】中设置)获取IP。
-在config.json中的 `server.dns.mapping:key-value` 中设置,**其中的每个条目**格式如下: +或 ```json -"*.example.com": "your-dns-name" +{ + "googleTCP": { + "server": "tcp://8.8.8.8:53", + "cacheSize": 1000, + } +} ``` -# 8. IP预设置: - -为一些DNS无法获取的域名手动设置ip,起到类似于hosts的作用(仅在dev-sidecar开启时生效)。
-在config.json中的 `server.preSetIpList:object` 中设置,**其中的每个条目**格式如下: +## 6.4. 配置 `UDP` 的DNS服务: ```json { - "example.com": { - "1.1.1.1": true, // 如果有多个IP,可以继续添加 - "1.0.0.1": false, // 指定为false时,不使用该IP - "2.2.2.2": { - "desc": "这样可以合法的在配置中插入注释。上面使用的//注释方式在文件中是不允许的" - } + "google": { + "type": "udp", + "server": "8.8.8.8", + "port": 53, // 不配置时,默认端口为:53 + "cacheSize": 1000, } } ``` +或 -# 9. IP测速: - -用来对从指定的DNS与IP预设置中获取到的IP测试TCP延迟,也可以用来测试DoH和DoT服务器的可用性,后者操作如下:先在【DNS服务管理】中配置好需要测试的DNS设置,然后在【IP测速】里添加一个没有设置【IP预设置】的辅助域名,并选择使用需检测的DNS进行解析。
-对于DoH/DoT而言,由于答案不能被篡改和窃听,所以辅助域名要么获得真实IP(说明可用)要么没有收到答案(说明不可用)。该方法不适用于常规TCP/UDP的DNS,因为它们没有加密,即使收到答案也可能被篡改而不可用)。
-在config.json中的 `server.dns.speedTest:object`中设置,**该条目** 格式如下: +```json +{ + "google": { + "server": "udp://8.8.8.8", + "port": 53, // 不配置时,默认端口为:53 + "cacheSize": 1000, + } +} +``` +或 ```json -"speedTest": { - "hostnameList": [ - "example1.com", - "example2.com" - ], - "dnsProviders": [ - "your-DNS-name-used-in-test1", - "your-DNS-name-used-in-test2" - ] +{ + "google": { + "server": "udp://8.8.8.8:53", + "cacheSize": 1000, + } } ``` + +# 7. DNS设置: + +# 8. IP预设置: + +# 9. IP测速: 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..10bd767e26 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, setting }) + } 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/core/test/instanceTest.js b/packages/core/test/instanceTest.js index 978e1bd0f0..cfd82d9544 100644 --- a/packages/core/test/instanceTest.js +++ b/packages/core/test/instanceTest.js @@ -6,24 +6,39 @@ const os = require('node:os') const instance = require('../src/modules/instance') const event = require('../src/event') -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) -describe('instance', function () { +describe('instance', () => { let tmpDir + let oldHome + let oldUserProfile - beforeEach(function () { + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ds-core-instance-')) + // getUserBasePath 优先读 USERPROFILE(Windows),其次 HOME(Linux/macOS) + oldHome = process.env.HOME + oldUserProfile = process.env.USERPROFILE process.env.HOME = tmpDir + process.env.USERPROFILE = tmpDir fs.mkdirSync(path.join(tmpDir, '.dev-sidecar'), { recursive: true }) }) - afterEach(function () { - delete process.env.HOME + afterEach(() => { + if (oldHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = oldHome + } + if (oldUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = oldUserProfile + } fs.rmSync(tmpDir, { recursive: true, force: true }) }) - describe('lock', function () { - it('should acquire and release the lock', async function () { + describe('lock', () => { + it('should acquire and release the lock', async () => { assert.isFalse(await instance.isLocked()) const release = await instance.acquireLock() assert.isTrue(await instance.isLocked()) @@ -31,7 +46,7 @@ describe('instance', function () { assert.isFalse(await instance.isLocked()) }) - it('should reject second acquire while lock is held', async function () { + it('should reject second acquire while lock is held', async () => { const release = await instance.acquireLock() try { await instance.acquireLock() @@ -42,7 +57,7 @@ describe('instance', function () { await release() }) - it('should take over a stale lock left by a crashed process', async function () { + it('should take over a stale lock left by a crashed process', async () => { const lockPath = instance.getLockPath() fs.mkdirSync(lockPath, { recursive: true }) const past = new Date(Date.now() - 30000) @@ -53,7 +68,7 @@ describe('instance', function () { await release() }) - it('should not take over a fresh lock', async function () { + it('should not take over a fresh lock', async () => { const lockPath = instance.getLockPath() fs.mkdirSync(lockPath, { recursive: true }) assert.isTrue(await instance.isLocked()) @@ -66,18 +81,18 @@ describe('instance', function () { }) }) - describe('instance info', function () { - it('should write and read instance info', function () { + describe('instance info', () => { + it('should write and read instance info', () => { const payload = { type: 'cli', pid: 123, command: 'node --daemon', startTime: '2026-01-01T00:00:00.000Z' } instance.writeInstance(payload) assert.deepEqual(instance.readInstance(), payload) }) - it('should return null when running.json is missing', function () { + it('should return null when running.json is missing', () => { assert.isNull(instance.readInstance()) }) - it('should preserve app.instance when updateStatus writes', async function () { + it('should preserve app.instance when updateStatus writes', async () => { instance.writeInstance({ type: 'cli', pid: 123 }) instance.updateStatus('server.enabled', true) await sleep(400) @@ -87,8 +102,8 @@ describe('instance', function () { }) }) - describe('updateStatus', function () { - it('should debounce multiple updates into one write', async function () { + describe('updateStatus', () => { + it('should debounce multiple updates into one write', async () => { instance.updateStatus('server.enabled', true) instance.updateStatus('proxy.enabled', true) instance.updateStatus('plugin.git.enabled', true) @@ -101,14 +116,14 @@ describe('instance', function () { assert.isTrue(data.app.status.plugin.git.enabled) }) - it('should set nested keys via dot path', async function () { + it('should set nested keys via dot path', async () => { instance.updateStatus('plugin.node.enabled', true) await sleep(400) const data = JSON.parse(fs.readFileSync(instance.getRunningJsonPath(), 'utf-8')) assert.isTrue(data.app.status.plugin.node.enabled) }) - it('should ignore invalid keys', async function () { + it('should ignore invalid keys', async () => { instance.updateStatus('', true) instance.updateStatus(null, true) await sleep(400) @@ -120,8 +135,8 @@ describe('instance', function () { }) }) - describe('watchStatusEvents', function () { - it('should sync *.enabled events to running.json', async function () { + describe('watchStatusEvents', () => { + it('should sync *.enabled events to running.json', async () => { const release = await instance.acquireLock() try { event.fire('status', { key: 'server.enabled', value: true }) @@ -135,7 +150,7 @@ describe('instance', function () { } }) - it('should filter non-enabled events (e.g. free_eye.result)', async function () { + it('should filter non-enabled events (e.g. free_eye.result)', async () => { const release = await instance.acquireLock() try { event.fire('status', { key: 'server.enabled', value: true }) diff --git a/packages/gui/electron-builder.config.cjs b/packages/gui/electron-builder.config.cjs index e45f4af3dd..f68200d58d 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'] }, @@ -84,12 +86,6 @@ module.exports = { { target: 'tar.gz', arch: ['x64', 'arm64', 'armv7l'] }, { 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: '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/bridge/update/backend.js b/packages/gui/src/bridge/update/backend.js index 0d5a3ec27a..b6820321f7 100644 --- a/packages/gui/src/bridge/update/backend.js +++ b/packages/gui/src/bridge/update/backend.js @@ -1,5 +1,6 @@ import fs from 'node:fs' import path from 'node:path' +import tls from 'node:tls' import { fileURLToPath } from 'node:url' import DevSidecar from '@docmirror/dev-sidecar' import AdmZip from 'adm-zip' @@ -22,6 +23,67 @@ const isLinux = process.platform === 'linux' const curVersion = pkg.version const isCurrentPreRelease = curVersion.includes('-') +let cachedRootCaCert = null +/** + * 更新检查/下载会走本地 MITM 代理,需要信任 dev-sidecar 自己的根证书; + * 同时也保留 Node 内置根证书,保证直连 GitHub 时也能正常校验。 + */ +function getTrustedCaList () { + const caList = [...tls.rootCertificates] + if (cachedRootCaCert != null) { + if (cachedRootCaCert !== false) { + caList.push(cachedRootCaCert) + } + return caList + } + + try { + const certPath = DevSidecar.api?.config?.get()?.server?.setting?.rootCaFile?.certPath + if (certPath && fs.existsSync(certPath)) { + cachedRootCaCert = fs.readFileSync(certPath, 'utf8') + caList.push(cachedRootCaCert) + } else { + cachedRootCaCert = false + } + } catch (e) { + log.warn('读取 dev-sidecar 根证书失败:', e) + cachedRootCaCert = false + } + return caList +} + +/** + * 获取当前平台/架构对应的资源命名。 + * - partArch: 增量包命名用(win-x64 / win-arm64 / win-ia32) + * - fullArch: 完整安装包命名用(windows-x86_64 / windows-arm64 / linux-armv7l 等) + */ +function getPlatformAssetInfo () { + const arch = process.arch + let partArch + let fullArch + if (arch === 'ia32') { + partArch = 'ia32' + fullArch = 'ia32' + } else if (arch === 'arm64') { + partArch = 'arm64' + fullArch = 'arm64' + } else if (arch === 'arm') { + partArch = 'x64' // 无 arm 增量包时兜底 + fullArch = 'armv7l' + } else { + partArch = 'x64' + fullArch = 'x86_64' + } + + if (isMac) { + return { platform: 'macos', partPlatform: 'mac', partArch, fullArch } + } + if (isLinux) { + return { platform: 'linux', partPlatform: 'linux', partArch, fullArch } + } + return { platform: 'windows', partPlatform: 'win', partArch, fullArch } +} + function extractVersion (versionData) { const candidates = [versionData.tag_name, versionData.name] for (const candidate of candidates) { @@ -38,7 +100,7 @@ function extractVersion (versionData) { function downloadFile (uri, filePath, onProgress, onSuccess, onError) { log.info('download url', uri) - progress(request(uri), { + progress(request(uri, { ca: getTrustedCaList() }), { // throttle: 2000, // Throttle the progress event to 2000ms, defaults to 1000ms // delay: 1000, // Only start to emit after 1000ms delay, defaults to 0ms // lengthHeader: 'x-transfer-length' // Length header to use, defaults to content-length @@ -54,7 +116,7 @@ function downloadFile (uri, filePath, onProgress, onSuccess, onError) { }) .on('end', () => { // Do something after request finishes - onSuccess() + onSuccess(filePath) }) .pipe(fs.createWriteStream(filePath)) } @@ -98,7 +160,7 @@ function updateHandle (app, api, win, beforeQuit, quit, log) { // 检查更新 const releasesApiUrl = 'https://api.github.com/repos/docmirror/dev-sidecar/releases' async function checkForUpdatesFromGitHub () { - request(releasesApiUrl, { headers: { 'User-Agent': `DS/${curVersion}`, 'Server-Name': 'baidu.com' } }, (error, response, body) => { + request(releasesApiUrl, { headers: { 'User-Agent': `DS/${curVersion}`, 'Server-Name': 'baidu.com' }, ca: getTrustedCaList() }, (error, response, body) => { try { if (error) { log.error('检查更新失败:', error) @@ -162,11 +224,20 @@ function updateHandle (app, api, win, beforeQuit, quit, log) { log.info(`检查更新:发现新版本 '${onlineVersion}',当前版本号为 '${curVersion}'`) // 查找当前平台+架构对应的增量更新包 - const arch = process.arch === 'ia32' ? 'ia32' : process.arch === 'arm64' ? 'arm64' : 'x64' - const platformPrefix = isMac ? `update-mac-${arch}-` : isLinux ? `update-linux-${arch}-` : `update-win-${arch}-` - const partAsset = versionData.assets.find(a => a.name && a.name.startsWith(platformPrefix) && a.name.endsWith('.zip')) + const assetInfo = getPlatformAssetInfo() + const partPrefix = `update-${assetInfo.partPlatform}-${assetInfo.partArch}-` + const partAsset = versionData.assets.find(a => a.name && a.name.startsWith(partPrefix) && a.name.endsWith('.zip')) const partPackage = partAsset ? partAsset.browser_download_url : null + // 查找当前平台+架构对应的完整安装包 + const fullPrefix = `DevSidecar-${onlineVersion}-${assetInfo.platform}-${assetInfo.fullArch}.` + const fullSuffix = assetInfo.platform === 'windows' ? '.exe' : assetInfo.platform === 'macos' ? '.dmg' : '.AppImage' + const fullAsset = versionData.assets.find(a => a.name && a.name.startsWith(fullPrefix) && a.name.endsWith(fullSuffix)) + const fullPackage = fullAsset ? fullAsset.browser_download_url : null + const fullPackageName = fullAsset ? fullAsset.name : null + + log.info(`平台资源匹配: ${JSON.stringify({ assetInfo, partPackage, fullPackageName })}`) + win.webContents.send('update', { key: 'available', value: { @@ -175,6 +246,8 @@ function updateHandle (app, api, win, beforeQuit, quit, log) { ? (versionData.body.replace(/\r\n/g, '\n').replace(/https:\/\/github.com\/docmirror\/dev-sidecar/g, '').replace(/(?<=(^|\n))[ \t]*(?:#[ #]*)?#\s*/g, '') || '无') : '无', partPackage, + fullPackage, + fullPackageName, }, }) } else { @@ -238,14 +311,49 @@ function updateHandle (app, api, win, beforeQuit, quit, log) { downloadFile(value.partPackage, filePath, (data) => { win.webContents.send('update', { key: 'progress', value: Number.parseInt(data) }) - }, () => { + }, (downloadedPath) => { // 文件下载完成 win.webContents.send('update', { key: 'progress', value: 100 }) - log.info('升级包下载成功:', filePath) - partPackagePath = filePath + log.info('升级包下载成功:', downloadedPath) + partPackagePath = downloadedPath + win.webContents.send('update', { + key: 'downloaded', + value, + filePath: downloadedPath, + updateType: 'part', + }) + }, (error) => { + sendUpdateMessage({ key: 'error', value: error, error }) + }) + } + + // 下载完整安装包(按平台+架构自动匹配后的安装文件) + function downloadFull (app, value) { + if (!value.fullPackage) { + sendUpdateMessage({ key: 'error', value: new Error('未找到对应的完整安装包'), error: '未找到对应的完整安装包' }) + return + } + const appPath = appPathUtil.getAppRootPath(app) + const fileDir = path.join(appPath, 'update') + log.info('download full dir:', fileDir) + try { + fs.accessSync(fileDir, fs.constants.F_OK) + } catch { + fs.mkdirSync(fileDir) + } + const fileName = value.fullPackageName || `${value.version}-${getPlatformAssetInfo().platform}-${getPlatformAssetInfo().fullArch}` + const filePath = path.join(fileDir, fileName) + + downloadFile(value.fullPackage, filePath, (data) => { + win.webContents.send('update', { key: 'progress', value: Number.parseInt(data) }) + }, (downloadedPath) => { + win.webContents.send('update', { key: 'progress', value: 100 }) + log.info('完整安装包下载成功:', downloadedPath) win.webContents.send('update', { key: 'downloaded', value, + filePath: downloadedPath, + updateType: 'full', }) }, (error) => { sendUpdateMessage({ key: 'error', value: error, error }) @@ -337,6 +445,10 @@ function updateHandle (app, api, win, beforeQuit, quit, log) { // 下载增量更新版本 log.info('autoUpdater downloadPart') downloadPart(app, arg.value) + } else if (arg.key === 'downloadFull') { + // 下载完整安装包 + log.info('autoUpdater downloadFull') + downloadFull(app, arg.value) } }) // 通过main进程发送事件给renderer进程,提示更新信息 diff --git a/packages/gui/src/bridge/update/front.js b/packages/gui/src/bridge/update/front.js index 05e6ac28a2..42af184e74 100644 --- a/packages/gui/src/bridge/update/front.js +++ b/packages/gui/src/bridge/update/front.js @@ -20,6 +20,10 @@ function install (app, api) { // 增量更新 api.ipc.send('update', { key: 'downloadPart', value }) }, + downloadFull (value) { + // 完整安装包更新 + api.ipc.send('update', { key: 'downloadFull', value }) + }, doUpdateNow () { api.ipc.send('update', { key: 'doUpdateNow' }) }, @@ -35,8 +39,16 @@ function install (app, api) { updateParams.checking = false noNewVersion() } else if (type === 'downloaded') { - // 更新包已下载完成,让用户确认是否更新 + // 更新包已下载完成 updateParams.downloading = false + if (message.updateType === 'full') { + // 完整安装包:自动打开,由用户完成安装 + if (message.filePath) { + api.ipc.openPath(message.filePath) + } + app.config.globalProperties.$message.success(message.filePath ? '完整安装包已下载并自动打开,请按提示完成安装' : '完整安装包已下载完成') + return + } console.log('updateParams', updateParams) newUpdateIsReady(message.value) } else if (type === 'progress') { @@ -95,7 +107,7 @@ function install (app, api) { async function downloadNewVersion (value) { const platform = await api.info.getSystemPlatform() console.log(`download new version: ${JSON.stringify(value)}, platform: ${platform}`) - if (platform === 'linux') { + if (platform === 'linux' && !value.fullPackage) { goManualUpdate(value) return } @@ -103,6 +115,10 @@ function install (app, api) { // 有增量更新 ZIP,走增量更新流程 updateParams.downloading = true api.update.downloadPart(value) + } else if (value.fullPackage) { + // 没有增量包时,自动下载当前平台/架构对应的完整安装包 + updateParams.downloading = true + api.update.downloadFull(value) } else { goManualUpdate(value) } @@ -117,13 +133,15 @@ function install (app, api) { } const hasPartPackage = !!value.partPackage - const platform = api.info ? null : null // placeholder, will resolve below + const hasFullPackage = !!value.fullPackage + const okText = hasPartPackage ? '自动更新(实验性)' : hasFullPackage ? '自动下载安装包' : '手动下载' + const okType = hasPartPackage ? 'danger' : hasFullPackage ? 'primary' : 'default' app.config.globalProperties.$confirm({ title: `发现新版本:v${value.version}`, cancelText: '暂不升级', - okText: hasPartPackage ? '自动更新(实验性)' : '手动下载', - okType: hasPartPackage ? 'danger' : 'default', + okText, + okType, width: 700, content: (h) => { const children = [] @@ -146,20 +164,25 @@ function install (app, api) { h('span', { style: { fontWeight: 'bold' } }, '更新方式说明:'), h('ul', { style: { marginTop: '6px', paddingLeft: '20px' } }, [ h('li', {}, [h('b', {}, '自动更新(实验性)'), ' — 下载增量包自动覆盖,重启即完成。仅 Windows/macOS 支持']), - h('li', {}, [h('b', {}, '手动下载'), ' — 跳转 GitHub Releases 下载完整安装包,覆盖安装即可']), + h('li', {}, [h('b', {}, '自动下载安装包'), ' — 下载当前平台/架构对应的完整安装包']), ]), ]), ) } - if (!hasPartPackage) { + if (!hasPartPackage && hasFullPackage) { children.push( - h('div', { style: { marginTop: '12px' } }, '当前平台不支持增量更新,点击"手动下载"前往 GitHub Releases。'), + h('div', { style: { marginTop: '12px', color: '#888' } }, `已自动识别当前平台/架构,将下载安装包:${value.fullPackageName || '对应完整安装包'},下载完成后自动打开供你安装。`), + ) + } + if (!hasPartPackage && !hasFullPackage) { + children.push( + h('div', { style: { marginTop: '12px' } }, '当前平台/架构暂未匹配到安装包,点击"手动下载"前往 GitHub Releases。'), ) } return h('div', {}, children) }, onOk () { - if (hasPartPackage) { + if (hasPartPackage || hasFullPackage) { downloadNewVersion(value) } else { openGithubUrl() @@ -177,7 +200,7 @@ function install (app, api) { cancelText: '暂不升级', okText: '立即升级', width: 700, - content: (h) => { + content: (_h) => { if (value.releaseNotes) { const notes = [] if (typeof value.releaseNotes === 'string') { 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/components/container.vue b/packages/gui/src/view/components/container.vue index 9964a456b6..3700109795 100644 --- a/packages/gui/src/view/components/container.vue +++ b/packages/gui/src/view/components/container.vue @@ -1,9 +1,9 @@