diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..f511e7afaf --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,48 @@ +# Copilot instructions for dev-sidecar + +## Commands + +- Dependency management: + - Install dependencies for the entire workspace from the repository root using `pnpm install`. Avoid using `npm install` for dependency installation in this project. + - If `pnpm install` reports dependency conflicts or version mismatches, update the relevant `package.json` entries to compatible versions (or adjust `peerDependencies`), then re-run `pnpm install`. + +- Linting: + - Lint the repository from the root with `pnpm lint`. + - Auto-fix lint issues from the root with `pnpm lint:fix`. + - If `pnpm lint` or `pnpm lint:fix` fails, review the reported errors and manually fix the files indicated by the linter, then re-run the command. + +- Testing: + - Run package tests where they live: + - `pnpm --filter @docmirror/dev-sidecar test` + - `pnpm --filter @docmirror/mitmproxy test` + - Run a single test file by passing it after `--`, for example: + - `pnpm --filter @docmirror/dev-sidecar test -- test/regex.test.js` + - `pnpm --filter @docmirror/mitmproxy test -- test/proxyTest.js` + +- GUI development and packaging (from `packages/gui`): + - `npm run electron` + - `npm run electron:build` + - `npm run serve` + - `npm run lint` + - For GUI debugging: run `npm run electron` and open the Electron developer tools (application menu View → Toggle Developer Tools or the platform shortcut) to inspect renderer pages, console logs, and IPC traffic. + +## High-level architecture + +- This is a pnpm workspace monorepo with four packages: + - `packages/core`: shared app logic, config, shell helpers, system proxy handling, and plugin/module code. + - `packages/mitmproxy`: the HTTP(S) proxy, DNS, interception, PAC, and response/request rewrite layer. + - `packages/gui`: the Electron + Vue 2 desktop app. + - `packages/cli`: a small CLI entrypoint that loads user config and starts the proxy service. +- `packages/core/src/index.js` exposes the main API and owns process-level error handling plus config/state wiring. +- `packages/mitmproxy/src/index.js` creates the proxy server(s), applies proxy options, and reports status/errors back to the host process. +- `packages/gui/src/background.js` is the Electron main process: it loads config, creates the main window, tray, IPC bridges, and Windows-specific power-monitor behavior. +- Renderer code lives under `packages/gui/src/view/`; IPC/bridge code lives under `packages/gui/src/bridge/`. +- The CLI reads `packages/cli/src/user_config.json5`, prints a banner, and starts the core API with the mitmproxy service path. + +## Repo-specific conventions + +- Most runtime code in `core`, `mitmproxy`, and `cli` uses CommonJS; GUI code is the Electron/Vue app and is organized around the Electron main process plus renderer/bridge split. +- Keep GUI source imports aligned with the existing file layout and `.js` module naming used in the Electron entrypoints. +- Preserve the startup/shutdown flow: ensure the sequence remains where `core` initializes and manages the proxy lifecycle, `mitmproxy` performs network interception, and the GUI communicates exclusively through IPC bridges and the `core` API; avoid direct cross-component calls that bypass these boundaries. +- Native/module setup matters here: the repo uses a root `.npmrc` with a PhantomJS mirror and C++17 build flags for native modules. +- The project documents Node 22.x, Python 3.11/setuptools, and VS 2022 C++ tooling as the expected local environment for Windows builds. diff --git a/packages/core/package.json b/packages/core/package.json index f634204541..7fbfb0b603 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,6 +23,7 @@ "lodash": "^4.18.1", "log4js": "^6.9.1", "node-powershell": "^4.0.0", + "request": "^2.88.2", "spawn-sync": "^2.0.0", "winreg": "^1.2.5" }, diff --git a/packages/core/src/modules/plugin/free-eye/client.js b/packages/core/src/modules/plugin/free-eye/client.js index 70f6a6ec13..e7f08410c0 100644 --- a/packages/core/src/modules/plugin/free-eye/client.js +++ b/packages/core/src/modules/plugin/free-eye/client.js @@ -44,7 +44,7 @@ function resolveTestsDir (customDir) { return fs.existsSync(candidate) ? candidate : fallbackDir } -async function loadAllTests (globalConfig, testsDir) { +async function loadAllTests (testsDir, globalConfig) { const tests = [] const resolvedDir = resolveTestsDir(testsDir) if (!fs.existsSync(resolvedDir)) { @@ -85,37 +85,16 @@ function getNextTest (todoTests, doneTests) { } async function runTests (options = {}) { - const { configPath, testsDir } = options - const preferredConfigPath = configPath && configPath.length > 0 - ? (path.isAbsolute(configPath) ? configPath : path.join(PLUGIN_ROOT, configPath)) - : null - const fallbackConfigPath = path.join(PLUGIN_ROOT, 'config.json') - - const configCandidates = Array.from(new Set([preferredConfigPath, fallbackConfigPath].filter(Boolean))) - - let globalConfig - let lastError - for (const candidatePath of configCandidates) { - try { - if (!fs.existsSync(candidatePath)) { - lastError = new Error(`Config file not found: ${candidatePath}`) - continue - } - const configData = fs.readFileSync(candidatePath, 'utf8') - globalConfig = JSON.parse(configData) - break - } catch (error) { - lastError = new Error(`Error reading config file (${candidatePath}): ${error.message}`) - } - } + const { testsDir, config } = options + const globalConfig = (config && typeof config === 'object') ? config : null if (!globalConfig) { - throw lastError || new Error('Unable to load FreeEye config.') + throw new Error('FreeEye runtime config is required.') } const globalResults = {} const summaries = [] - const todoTests = await loadAllTests(globalConfig, testsDir) + const todoTests = await loadAllTests(testsDir, globalConfig) console.log( `Loaded ${todoTests.length} tests: ${ todoTests.map(t => t.getTestTag()).join(' ')}`, diff --git a/packages/core/src/modules/plugin/free-eye/config.js b/packages/core/src/modules/plugin/free-eye/config.js index b784841546..138224b8a6 100644 --- a/packages/core/src/modules/plugin/free-eye/config.js +++ b/packages/core/src/modules/plugin/free-eye/config.js @@ -1,4 +1,4 @@ -import mainConfig from '../../../config.js' +import mainConfig from '../../../config/index.js' export default { name: '网络检测', @@ -6,13 +6,13 @@ export default { enabled: false, tip: '运行网络检测来评估当前网络环境', startup: {}, - // FreeEye 自带一套 tests(位于本目录的 checkpoints/ 和 config.json), + // FreeEye 自带一套 tests(位于本目录的 checkpoints/), // 这里保留最小配置以便在 dev-sidecar 中显示和切换插件。 setting: { testsDir: 'checkpoints', // 默认网络请求超时时间(秒),插件内部的测试可以参考或覆盖 defaultTimeout: 3, + // 复用主配置里的 free_eye 默认值,避免重复维护两份配置 + config: (mainConfig.configFromFiles ? mainConfig.configFromFiles.plugin.free_eye : mainConfig.plugin.free_eye), }, - // 复用主配置里的 free_eye 默认值,避免重复维护两份配置 - ...mainConfig.plugin.free_eye, } diff --git a/packages/core/src/modules/plugin/free-eye/index.js b/packages/core/src/modules/plugin/free-eye/index.js index 50fc8f4c95..5777de939d 100644 --- a/packages/core/src/modules/plugin/free-eye/index.js +++ b/packages/core/src/modules/plugin/free-eye/index.js @@ -2,7 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import clientModule from './client.js' -const runTests = (clientModule && (clientModule.runTests || (clientModule.default && clientModule.default.runTests))) +const runTests = clientModule.runTests import freeEyeConfig from './config.js' const PLUGIN_STATUS_KEY = 'plugin.free_eye' @@ -85,13 +85,9 @@ const FreeEyePlugin = function (context) { const executeTests = async () => { const currentConfig = config.get() - const pluginConfig = currentConfig.plugin.free_eye || {} - const setting = pluginConfig.setting || {} - const configFilePath = resolvePath(setting.testsConfigFile, 'config.json') - const testsDir = resolvePath(setting.testsDir, 'checkpoints') - log.info(`FreeEye tests triggering, config=${configFilePath}, testsDir=${testsDir}`) + const setting = currentConfig.plugin.free_eye.setting || {} try { - const { result, logs } = await captureLogs(() => runTests({ configPath: configFilePath, testsDir })) + const { result, logs } = await captureLogs(() => runTests(setting)) const payload = { finishedAt: new Date().toISOString(), totalTests: result.totalTests, 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 d58c3c5337..630152ca34 100644 --- a/packages/core/src/shell/scripts/set-system-proxy/index.js +++ b/packages/core/src/shell/scripts/set-system-proxy/index.js @@ -5,6 +5,7 @@ const fs = require('node:fs') const path = require('node:path') const request = require('request') const Registry = require('winreg') +const sudoPrompt = require('@vscode/sudo-prompt') const log = require('../../../utils/util.log.core') const Shell = require('../../shell') const extraPath = require('../extra-path') @@ -183,6 +184,151 @@ function getProxyExcludeIpStr (split) { return excludeIpStr } +function parseMacNetworkServiceByDevice (networkServiceOrder, device) { + if (!networkServiceOrder || !device) { + return null + } + const lines = networkServiceOrder.split(/\r?\n/) + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(`Device: ${device}`)) { + for (let j = i - 1; j >= 0; j--) { + const serviceLine = lines[j].trim() + const markerIndex = serviceLine.indexOf(') ') + if (serviceLine.startsWith('(') && markerIndex > 0) { + return serviceLine.slice(markerIndex + 2).trim() + } + } + } + } + return null +} + +function parseMacRouteDevice (routeOutput) { + if (!routeOutput) { + return null + } + const routeLines = routeOutput.split(/\r?\n/) + for (const routeLine of routeLines) { + const trimmedLine = routeLine.trim() + if (trimmedLine.startsWith('interface:')) { + return trimmedLine.slice('interface:'.length).trim() || null + } + } + return null +} + +function pickMacNetworkService (listAllNetworkServicesOutput) { + if (!listAllNetworkServicesOutput) { + return null + } + const services = listAllNetworkServicesOutput + .split(/\r?\n/) + .map(item => item.replace(/^\*/, '').trim()) + .filter(item => item && !item.startsWith('An asterisk (*) denotes')) + if (services.length === 0) { + return null + } + const preferredServices = ['Wi-Fi', 'WiFi', 'Ethernet'] + for (const preferredService of preferredServices) { + const matched = services.find(item => item === preferredService) + if (matched) { + return matched + } + } + return services[0] +} + +async function getMacNetworkService (exec) { + try { + const routeOutput = await exec('route -n get 0.0.0.0') + const device = parseMacRouteDevice(routeOutput) + if (device) { + log.info('macOS 代理服务检测:当前网络设备:', device) + try { + const networkServiceOrder = await exec('networksetup -listnetworkserviceorder') + const matchedService = parseMacNetworkServiceByDevice(networkServiceOrder, device) + if (matchedService) { + log.info('macOS 代理服务检测:通过设备名匹配到网络服务:', matchedService) + return matchedService + } + log.warn('macOS 代理服务检测:未通过设备名匹配到网络服务,尝试备用方法') + } catch (e) { + log.warn('macOS 代理服务检测:获取网络服务列表失败:', e.message, ',尝试备用方法') + } + } else { + log.warn('macOS 代理服务检测:未检测到当前网络设备,尝试备用方法') + } + } catch (e) { + log.warn('macOS 代理服务检测:获取路由信息失败:', e.message, ',尝试备用方法') + } + + try { + const allServicesOutput = await exec('networksetup -listallnetworkservices') + const fallbackService = pickMacNetworkService(allServicesOutput) + if (fallbackService) { + log.info('macOS 代理服务检测:通过服务列表备用方法找到网络服务:', fallbackService) + return fallbackService + } + log.warn('macOS 代理服务检测:未通过服务列表找到可用网络服务') + } catch (e) { + log.warn('macOS 代理服务检测:获取所有网络服务列表失败:', e.message) + } + + throw new Error('未找到可用的 macOS 网络服务,无法设置系统代理') +} + +// macOS exit code 14 = "You don't have permission to change the system preferences." +const MACOS_NETWORKSETUP_PERMISSION_ERROR_CODE = 14 + +/** + * POSIX single-quote escaping: wraps `arg` in single quotes, escaping any + * embedded single quotes with the '\''-idiom. This prevents shell + * metacharacter expansion regardless of the character set of the value. + * @param {string|number} arg + * @returns {string} + */ +function shellEscapeArg (arg) { + return "'" + String(arg).replace(/'/g, "'\\''") + "'" +} + +/** + * Strict-validate a proxy host (IPv4 / IPv6 / hostname) and throw if the + * value looks suspicious. This is a defence-in-depth guard for the sudo + * execution path; the primary protection is `shellEscapeArg`. + */ +function validateProxyIp (ip) { + if (typeof ip !== 'string' || !/^[\w.\-:[\]]+$/.test(ip)) { + throw new Error(`无效的代理 IP 地址: ${ip}`) + } +} + +/** + * Strict-validate a TCP port number. + */ +function validateProxyPort (port) { + const n = Number(port) + if (!Number.isInteger(n) || n < 1 || n > 65535) { + throw new Error(`无效的代理端口号: ${port}`) + } +} + +function sudoExecMac (cmd) { + return new Promise((resolve, reject) => { + log.info('以管理员权限执行命令:', cmd) + sudoPrompt.exec(cmd, { name: 'dev-sidecar' }, (error, stdout, stderr) => { + if (stderr) { + log.warn('以管理员权限执行命令,stderr:', stderr) + } + if (error) { + log.error('以管理员权限执行命令失败:', error) + reject(error) + } else { + resolve(stdout) + } + }) + }) +} + const executor = { async windows (exec, params = {}) { const { ip, port, setEnv } = params @@ -324,51 +470,56 @@ const executor = { } }, async mac (exec, params = {}) { - // exec = _exec - let wifiAdaptor = await exec('sh -c "networksetup -listnetworkserviceorder | grep `route -n get 0.0.0.0 | grep \'interface\' | cut -d \':\' -f2` -B 1 | head -n 1 "') - wifiAdaptor = wifiAdaptor.trim() - wifiAdaptor = wifiAdaptor.substring(wifiAdaptor.indexOf(' ')).trim() + const wifiAdaptor = await getMacNetworkService(exec) const { ip, port } = params + + let cmds if (ip != null) { // 设置代理 // 延迟加载config loadConfig() // https - await exec(`networksetup -setsecurewebproxy "${wifiAdaptor}" ${ip} ${port}`) + cmds = [`networksetup -setsecurewebproxy "${wifiAdaptor}" ${ip} ${port}`] // http if (config.get().proxy.proxyHttp) { - await exec(`networksetup -setwebproxy "${wifiAdaptor}" ${ip} ${port - 1}`) + cmds.push(`networksetup -setwebproxy "${wifiAdaptor}" ${ip} ${port - 1}`) } else { - await exec(`networksetup -setwebproxystate "${wifiAdaptor}" off`) + cmds.push(`networksetup -setwebproxystate "${wifiAdaptor}" off`) } // 设置排除域名 const excludeIpStr = getProxyExcludeIpStr('" "') - await exec(`networksetup -setproxybypassdomains "${wifiAdaptor}" "${excludeIpStr}"`) - - // const setEnv = `cat <> ~/.zshrc - // export http_proxy="http://${ip}:${port}" - // export https_proxy="http://${ip}:${port}" - // ENDOF - // source ~/.zshrc - // ` - // await exec(setEnv) + cmds.push(`networksetup -setproxybypassdomains "${wifiAdaptor}" "${excludeIpStr}"`) } else { // 关闭代理 - // https - await exec(`networksetup -setsecurewebproxystate "${wifiAdaptor}" off`) - // http - await exec(`networksetup -setwebproxystate "${wifiAdaptor}" off`) - - // const removeEnv = ` - // sed -ie '/export http_proxy/d' ~/.zshrc - // sed -ie '/export https_proxy/d' ~/.zshrc - // source ~/.zshrc - // ` - // await exec(removeEnv) + // https + http + cmds = [ + `networksetup -setsecurewebproxystate "${wifiAdaptor}" off`, + `networksetup -setwebproxystate "${wifiAdaptor}" off`, + ] + } + + // 先尝试直接执行;若因权限不足(exit code 14)失败,弹出系统授权对话框后重试 + try { + for (const cmd of cmds) { + await exec(cmd) + } + } catch (e) { + if (e.code === MACOS_NETWORKSETUP_PERMISSION_ERROR_CODE) { + log.warn('networksetup 命令需要管理员权限(exit code 14),正在弹出系统授权对话框...') + await sudoExecMac(cmds.join(' && ')) + log.info('以管理员权限执行 networksetup 命令成功') + } else { + throw e + } } }, } -module.exports = async function (args) { +const setSystemProxy = async function (args) { return execute(executor, args) } + +module.exports = setSystemProxy +module.exports.parseMacNetworkServiceByDevice = parseMacNetworkServiceByDevice +module.exports.parseMacRouteDevice = parseMacRouteDevice +module.exports.pickMacNetworkService = pickMacNetworkService diff --git a/packages/core/src/shell/shell.js b/packages/core/src/shell/shell.js index 7010f983c6..6d35d90701 100644 --- a/packages/core/src/shell/shell.js +++ b/packages/core/src/shell/shell.js @@ -97,7 +97,9 @@ function childExec (composeCmds, options = {}) { if (options.printErrorLog !== false) { log.error('cmd 命令执行错误:\n===>\ncommands:', composeCmds, '\n error:', error, '\n<===') } - reject(new Error(stderr)) + const err = new Error(`${stderr || error.message} (command: ${composeCmds})`) + err.code = error.code + reject(err) } else { // log.info('cmd 命令完成:', stdout) resolve(stdout.replace('Active code page: 65001\r\n', '')) diff --git a/packages/core/test/setSystemProxyMacTest.js b/packages/core/test/setSystemProxyMacTest.js new file mode 100644 index 0000000000..a7abbe3c54 --- /dev/null +++ b/packages/core/test/setSystemProxyMacTest.js @@ -0,0 +1,79 @@ +const assert = require('node:assert') +const setSystemProxy = require('../src/shell/scripts/set-system-proxy') + +// eslint-disable-next-line no-undef +describe('set-system-proxy mac helpers', () => { + // eslint-disable-next-line no-undef + it('should parse service by device from listnetworkserviceorder output', () => { + const networkServiceOrder = ` +(1) Wi-Fi +(Hardware Port: Wi-Fi, Device: en0) +(2) Thunderbolt Bridge +(Hardware Port: Thunderbolt Bridge, Device: bridge0) +`.trim() + const service = setSystemProxy.parseMacNetworkServiceByDevice(networkServiceOrder, 'en0') + assert.strictEqual(service, 'Wi-Fi') + assert.strictEqual(setSystemProxy.parseMacNetworkServiceByDevice('', 'en0'), null) + assert.strictEqual(setSystemProxy.parseMacNetworkServiceByDevice(networkServiceOrder, ''), null) + }) + + // eslint-disable-next-line no-undef + it('should parse route device from route output', () => { + const routeOutput = ` +route to: default +interface: en0 +flags: +`.trim() + const device = setSystemProxy.parseMacRouteDevice(routeOutput) + assert.strictEqual(device, 'en0') + assert.strictEqual(setSystemProxy.parseMacRouteDevice(''), null) + assert.strictEqual(setSystemProxy.parseMacRouteDevice(null), null) + }) + + // eslint-disable-next-line no-undef + it('should fallback to preferred Wi-Fi service when available', () => { + const listAllNetworkServicesOutput = ` +USB 10/100/1000 LAN +Wi-Fi +Thunderbolt Bridge +`.trim() + const service = setSystemProxy.pickMacNetworkService(listAllNetworkServicesOutput) + assert.strictEqual(service, 'Wi-Fi') + }) + + // eslint-disable-next-line no-undef + it('should fallback to first service when preferred service is unavailable', () => { + const listAllNetworkServicesOutput = ` +USB 10/100/1000 LAN +Thunderbolt Bridge +`.trim() + const service = setSystemProxy.pickMacNetworkService(listAllNetworkServicesOutput) + assert.strictEqual(service, 'USB 10/100/1000 LAN') + }) + + // eslint-disable-next-line no-undef + it('should support disabled service prefix and empty input', () => { + const listAllNetworkServicesOutput = ` +*Wi-Fi +Thunderbolt Bridge +`.trim() + const service = setSystemProxy.pickMacNetworkService(listAllNetworkServicesOutput) + assert.strictEqual(service, 'Wi-Fi') + assert.strictEqual(setSystemProxy.pickMacNetworkService(''), null) + assert.strictEqual(setSystemProxy.pickMacNetworkService(null), null) + }) + + // eslint-disable-next-line no-undef + it('should ignore the "An asterisk" header line produced by networksetup -listallnetworkservices', () => { + const fullOutput = `An asterisk (*) denotes that a network service is disabled. +Ethernet +Wi-Fi +Thunderbolt Bridge` + assert.strictEqual(setSystemProxy.pickMacNetworkService(fullOutput), 'Wi-Fi') + + const fullOutputEthernetOnly = `An asterisk (*) denotes that a network service is disabled. +Ethernet +Thunderbolt Bridge` + assert.strictEqual(setSystemProxy.pickMacNetworkService(fullOutputEthernetOnly), 'Ethernet') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e038560e55..996411db7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: node-powershell: specifier: ^4.0.0 version: 4.0.0 + request: + specifier: ^2.88.2 + version: 2.88.2 spawn-sync: specifier: ^2.0.0 version: 2.0.0 @@ -11602,8 +11605,7 @@ snapshots: extsprintf@1.3.0: {} - extsprintf@1.4.1: - optional: true + extsprintf@1.4.1: {} fast-deep-equal@3.1.3: {} @@ -14994,7 +14996,7 @@ snapshots: dependencies: assert-plus: 1.0.0 core-util-is: 1.0.2 - extsprintf: 1.3.0 + extsprintf: 1.4.1 verror@1.10.1: dependencies: