diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 006b89b036..31ed608449 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -206,3 +206,5 @@ export enum UpgradeChannel { export const defaultTimeout = 10 * 1000 * 60 export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network'] + +export const defaultByPassRules = 'localhost,127.0.0.1,::1' diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 39f677a6b8..e4db5ec210 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -90,7 +90,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { installPath: path.dirname(app.getPath('exe')) })) - ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => { + ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string, bypassRules?: string) => { let proxyConfig: ProxyConfig if (proxy === 'system') { @@ -101,6 +101,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { proxyConfig = { mode: 'direct' } } + if (bypassRules) { + proxyConfig.proxyBypassRules = bypassRules + } + await proxyManager.configureProxy(proxyConfig) }) diff --git a/src/main/services/ProxyManager.ts b/src/main/services/ProxyManager.ts index b6b68ff2ec..be76ba2bb6 100644 --- a/src/main/services/ProxyManager.ts +++ b/src/main/services/ProxyManager.ts @@ -7,14 +7,63 @@ import https from 'https' import { getSystemProxy } from 'os-proxy-config' import { ProxyAgent } from 'proxy-agent' import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici' +import { defaultByPassRules } from '@shared/config/constant' const logger = loggerService.withContext('ProxyManager') +let byPassRules = defaultByPassRules.split(',') + +const isByPass = (hostname: string) => { + return byPassRules.includes(hostname) +} + +class SelectiveDispatcher extends Dispatcher { + private proxyDispatcher: Dispatcher + private directDispatcher: Dispatcher + + constructor(proxyDispatcher: Dispatcher, directDispatcher: Dispatcher) { + super() + this.proxyDispatcher = proxyDispatcher + this.directDispatcher = directDispatcher + } + + dispatch(opts: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers) { + if (opts.origin) { + const url = new URL(opts.origin) + // 检查是否为 localhost 或本地地址 + if (isByPass(url.hostname)) { + return this.directDispatcher.dispatch(opts, handler) + } + } + + return this.proxyDispatcher.dispatch(opts, handler) + } + + async close(): Promise { + try { + await this.proxyDispatcher.close() + } catch (error) { + logger.error('Failed to close dispatcher:', error as Error) + this.proxyDispatcher.destroy() + } + } + + async destroy(): Promise { + try { + await this.proxyDispatcher.destroy() + } catch (error) { + logger.error('Failed to destroy dispatcher:', error as Error) + } + } +} export class ProxyManager { private config: ProxyConfig = { mode: 'direct' } private systemProxyInterval: NodeJS.Timeout | null = null private isSettingProxy = false + private proxyDispatcher: Dispatcher | null = null + private proxyAgent: ProxyAgent | null = null + private originalGlobalDispatcher: Dispatcher private originalSocksDispatcher: Dispatcher // for http and https @@ -44,7 +93,8 @@ export class ProxyManager { await this.configureProxy({ mode: 'system', - proxyRules: currentProxy?.proxyUrl.toLowerCase() + proxyRules: currentProxy?.proxyUrl.toLowerCase(), + proxyBypassRules: this.config.proxyBypassRules }) }, 1000 * 60) } @@ -57,7 +107,8 @@ export class ProxyManager { } async configureProxy(config: ProxyConfig): Promise { - logger.debug(`configureProxy: ${config?.mode} ${config?.proxyRules}`) + logger.info(`configureProxy: ${config?.mode} ${config?.proxyRules} ${config?.proxyBypassRules}`) + if (this.isSettingProxy) { return } @@ -65,11 +116,6 @@ export class ProxyManager { this.isSettingProxy = true try { - if (config?.mode === this.config?.mode && config?.proxyRules === this.config?.proxyRules) { - logger.debug('proxy config is the same, skip configure') - return - } - this.config = config this.clearSystemProxyMonitor() if (config.mode === 'system') { @@ -81,7 +127,8 @@ export class ProxyManager { this.monitorSystemProxy() } - this.setGlobalProxy() + byPassRules = config.proxyBypassRules?.split(',') || defaultByPassRules.split(',') + this.setGlobalProxy(this.config) } catch (error) { logger.error('Failed to config proxy:', error as Error) throw error @@ -115,12 +162,12 @@ export class ProxyManager { } } - private setGlobalProxy() { - this.setEnvironment(this.config.proxyRules || '') - this.setGlobalFetchProxy(this.config) - this.setSessionsProxy(this.config) + private setGlobalProxy(config: ProxyConfig) { + this.setEnvironment(config.proxyRules || '') + this.setGlobalFetchProxy(config) + this.setSessionsProxy(config) - this.setGlobalHttpProxy(this.config) + this.setGlobalHttpProxy(config) } private setGlobalHttpProxy(config: ProxyConfig) { @@ -129,21 +176,18 @@ export class ProxyManager { http.request = this.originalHttpRequest https.get = this.originalHttpsGet https.request = this.originalHttpsRequest - - axios.defaults.proxy = undefined - axios.defaults.httpAgent = undefined - axios.defaults.httpsAgent = undefined + try { + this.proxyAgent?.destroy() + } catch (error) { + logger.error('Failed to destroy proxy agent:', error as Error) + } + this.proxyAgent = null return } // ProxyAgent 从环境变量读取代理配置 const agent = new ProxyAgent() - - // axios 使用代理 - axios.defaults.proxy = false - axios.defaults.httpAgent = agent - axios.defaults.httpsAgent = agent - + this.proxyAgent = agent http.get = this.bindHttpMethod(this.originalHttpGet, agent) http.request = this.bindHttpMethod(this.originalHttpRequest, agent) @@ -176,16 +220,19 @@ export class ProxyManager { callback = args[1] } + // filter localhost + if (url) { + const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname + if (isByPass(hostname)) { + return originalMethod(url, options, callback) + } + } + // for webdav https self-signed certificate if (options.agent instanceof https.Agent) { ;(agent as https.Agent).options.rejectUnauthorized = options.agent.options.rejectUnauthorized } - - // 确保只设置 agent,不修改其他网络选项 - if (!options.agent) { - options.agent = agent - } - + options.agent = agent if (url) { return originalMethod(url, options, callback) } @@ -198,22 +245,33 @@ export class ProxyManager { if (config.mode === 'direct' || !proxyUrl) { setGlobalDispatcher(this.originalGlobalDispatcher) global[Symbol.for('undici.globalDispatcher.1')] = this.originalSocksDispatcher + axios.defaults.adapter = 'http' + this.proxyDispatcher?.close() + this.proxyDispatcher = null return } + // axios 使用 fetch 代理 + axios.defaults.adapter = 'fetch' + const url = new URL(proxyUrl) if (url.protocol === 'http:' || url.protocol === 'https:') { - setGlobalDispatcher(new EnvHttpProxyAgent()) + this.proxyDispatcher = new SelectiveDispatcher(new EnvHttpProxyAgent(), this.originalGlobalDispatcher) + setGlobalDispatcher(this.proxyDispatcher) return } - global[Symbol.for('undici.globalDispatcher.1')] = socksDispatcher({ - port: parseInt(url.port), - type: url.protocol === 'socks4:' ? 4 : 5, - host: url.hostname, - userId: url.username || undefined, - password: url.password || undefined - }) + this.proxyDispatcher = new SelectiveDispatcher( + socksDispatcher({ + port: parseInt(url.port), + type: url.protocol === 'socks4:' ? 4 : 5, + host: url.hostname, + userId: url.username || undefined, + password: url.password || undefined + }), + this.originalSocksDispatcher + ) + global[Symbol.for('undici.globalDispatcher.1')] = this.proxyDispatcher } private async setSessionsProxy(config: ProxyConfig): Promise { diff --git a/src/preload/index.ts b/src/preload/index.ts index bc1d8b383e..a548ae8b21 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -41,7 +41,8 @@ export function tracedInvoke(channel: string, spanContext: SpanContext | undefin const api = { getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info), reload: () => ipcRenderer.invoke(IpcChannel.App_Reload), - setProxy: (proxy: string | undefined) => ipcRenderer.invoke(IpcChannel.App_Proxy, proxy), + setProxy: (proxy: string | undefined, bypassRules?: string) => + ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules), checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate), showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog), setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang), diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index e1f1aebf5e..a48696f3e7 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -27,7 +27,16 @@ const logger = loggerService.withContext('useAppInit') export function useAppInit() { const dispatch = useAppDispatch() - const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings() + const { + proxyUrl, + proxyBypassRules, + language, + windowStyle, + autoCheckUpdate, + proxyMode, + customCss, + enableDataCollection + } = useSettings() const { minappShow } = useRuntime() const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel() const avatar = useLiveQuery(() => db.settings.get('image://avatar')) @@ -77,13 +86,13 @@ export function useAppInit() { useEffect(() => { if (proxyMode === 'system') { - window.api.setProxy('system') + window.api.setProxy('system', proxyBypassRules) } else if (proxyMode === 'custom') { - proxyUrl && window.api.setProxy(proxyUrl) + proxyUrl && window.api.setProxy(proxyUrl, proxyBypassRules) } else { window.api.setProxy('') } - }, [proxyUrl, proxyMode]) + }, [proxyUrl, proxyMode, proxyBypassRules]) useEffect(() => { i18n.changeLanguage(language || navigator.language || defaultLanguage) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 4829ea49cf..04cdec7246 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3238,6 +3238,7 @@ }, "proxy": { "address": "Proxy Address", + "bypass": "Bypass Rules", "mode": { "custom": "Custom Proxy", "none": "No Proxy", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index b57fc3d260..0ed513d6e3 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -3238,6 +3238,7 @@ }, "proxy": { "address": "プロキシアドレス", + "bypass": "バイパスルール", "mode": { "custom": "カスタムプロキシ", "none": "プロキシを使用しない", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 6d8cdfdecf..665a80a565 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -3238,6 +3238,7 @@ }, "proxy": { "address": "Адрес прокси", + "bypass": "Правила обхода", "mode": { "custom": "Пользовательский прокси", "none": "Не использовать прокси", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8d5f7e053f..63174f25eb 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3238,6 +3238,7 @@ }, "proxy": { "address": "代理地址", + "bypass": "代理绕过规则", "mode": { "custom": "自定义代理", "none": "不使用代理", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 15aa9ecd8e..e83594eea1 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3238,6 +3238,7 @@ }, "proxy": { "address": "代理伺服器位址", + "bypass": "代理略過規則", "mode": { "custom": "自訂代理伺服器", "none": "不使用代理伺服器", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index c868ab8b18..91aae56868 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -3236,6 +3236,7 @@ }, "proxy": { "address": "Διεύθυνση διαμεσολάβησης", + "bypass": "Κανόνες Παράκαμψης", "mode": { "custom": "προσαρμοσμένη προξενική", "none": "χωρίς πρόξενο", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index b6b1a2487f..5300a03e6b 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -3236,6 +3236,7 @@ }, "proxy": { "address": "Dirección del proxy", + "bypass": "Reglas de omisión", "mode": { "custom": "Proxy personalizado", "none": "No usar proxy", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index d56d952a53..38b7c92e6e 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -3236,6 +3236,7 @@ }, "proxy": { "address": "Adresse du proxy", + "bypass": "Règles de contournement", "mode": { "custom": "Proxy personnalisé", "none": "Ne pas utiliser de proxy", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 59247634eb..4aeb1c3e11 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -3236,6 +3236,7 @@ }, "proxy": { "address": "Endereço do proxy", + "bypass": "Regras de Contorno", "mode": { "custom": "Proxy Personalizado", "none": "Não Usar Proxy", diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index 3d80985276..9f1f82f56c 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -10,6 +10,7 @@ import { setEnableSpellCheck, setLanguage, setNotificationSettings, + setProxyBypassRules as _setProxyBypassRules, setProxyMode, setProxyUrl as _setProxyUrl, setSpellCheckLanguages @@ -17,7 +18,7 @@ import { import { LanguageVarious } from '@renderer/types' import { NotificationSource } from '@renderer/types/notification' import { isValidProxyUrl } from '@renderer/utils' -import { defaultLanguage } from '@shared/config/constant' +import { defaultByPassRules, defaultLanguage } from '@shared/config/constant' import { Flex, Input, Switch, Tooltip } from 'antd' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -29,6 +30,7 @@ const GeneralSettings: FC = () => { const { language, proxyUrl: storeProxyUrl, + proxyBypassRules: storeProxyBypassRules, setLaunch, setTray, launchOnBoot, @@ -42,6 +44,7 @@ const GeneralSettings: FC = () => { setDisableHardwareAcceleration } = useSettings() const [proxyUrl, setProxyUrl] = useState(storeProxyUrl) + const [proxyBypassRules, setProxyBypassRules] = useState(storeProxyBypassRules) const { theme } = useTheme() const { enableDeveloperMode, setEnableDeveloperMode } = useEnableDeveloperMode() @@ -97,6 +100,10 @@ const GeneralSettings: FC = () => { dispatch(_setProxyUrl(proxyUrl)) } + const onSetProxyBypassRules = () => { + dispatch(_setProxyBypassRules(proxyBypassRules)) + } + const proxyModeOptions: { value: 'system' | 'custom' | 'none'; label: string }[] = [ { value: 'system', label: t('settings.proxy.mode.system') }, { value: 'custom', label: t('settings.proxy.mode.custom') }, @@ -109,6 +116,7 @@ const GeneralSettings: FC = () => { dispatch(_setProxyUrl(undefined)) } else if (mode === 'none') { dispatch(_setProxyUrl(undefined)) + dispatch(_setProxyBypassRules(undefined)) } } @@ -210,6 +218,7 @@ const GeneralSettings: FC = () => { {t('settings.proxy.address')} setProxyUrl(e.target.value)} @@ -220,6 +229,22 @@ const GeneralSettings: FC = () => { )} + {(storeProxyMode === 'custom' || storeProxyMode === 'system') && ( + <> + + + {t('settings.proxy.bypass')} + setProxyBypassRules(e.target.value)} + style={{ width: 180 }} + onBlur={() => onSetProxyBypassRules()} + /> + + + )} diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 2a77a36a38..9189faa6b3 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -14,7 +14,7 @@ import db from '@renderer/databases' import i18n from '@renderer/i18n' import { Assistant, LanguageCode, Model, Provider, WebSearchProvider } from '@renderer/types' import { getDefaultGroupName, getLeadingEmoji, runAsyncFunction, uuid } from '@renderer/utils' -import { UpgradeChannel } from '@shared/config/constant' +import { defaultByPassRules, UpgradeChannel } from '@shared/config/constant' import { isEmpty } from 'lodash' import { createMigrate } from 'redux-persist' @@ -1969,6 +1969,10 @@ const migrateConfig = { try { addProvider(state, 'poe') + if (!state.settings.proxyBypassRules) { + state.settings.proxyBypassRules = defaultByPassRules + } + // 迁移api选项设置 state.llm.providers.forEach((provider) => { // 新字段默认支持 diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 62437cbbcc..b32d6e6f80 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -49,6 +49,7 @@ export interface SettingsState { targetLanguage: TranslateLanguageVarious proxyMode: 'system' | 'custom' | 'none' proxyUrl?: string + proxyBypassRules?: string userName: string userId: string showPrompt: boolean @@ -220,6 +221,7 @@ export const initialState: SettingsState = { targetLanguage: 'en-us', proxyMode: 'system', proxyUrl: undefined, + proxyBypassRules: undefined, userName: '', userId: uuid(), showPrompt: true, @@ -423,6 +425,9 @@ const settingsSlice = createSlice({ setProxyUrl: (state, action: PayloadAction) => { state.proxyUrl = action.payload }, + setProxyBypassRules: (state, action: PayloadAction) => { + state.proxyBypassRules = action.payload + }, setUserName: (state, action: PayloadAction) => { state.userName = action.payload }, @@ -826,6 +831,7 @@ export const { setTargetLanguage, setProxyMode, setProxyUrl, + setProxyBypassRules, setUserName, setShowPrompt, setShowTokens,