feat: support bypass proxy (#8791)

* feat(ProxyManager): implement SelectiveDispatcher for localhost handling

- Added SelectiveDispatcher to manage proxy and direct connections based on the hostname.
- Introduced isLocalhost function to check for localhost addresses.
- Updated ProxyManager to bypass proxy for localhost in dispatch methods and set proxy bypass rules.
- Enhanced global dispatcher setup to utilize SelectiveDispatcher for both EnvHttpProxyAgent and SOCKS dispatcher.

* refactor(ProxyManager): update axios configuration to use fetch adapter

- Changed axios to use the 'fetch' adapter for proxy requests.
- Removed previous proxy settings for axios, streamlining the configuration.
- Updated HTTP methods to bind with the new proxy agent.

* feat(Proxy): add support for proxy bypass rules

- Updated IPC handler to accept optional bypass rules for proxy configuration.
- Enhanced ProxyManager to store and utilize bypass rules for localhost and other specified addresses.
- Modified settings and UI components to allow users to input and manage bypass rules.
- Added translations for bypass rules in multiple languages.

* feat(ProxyManager): add HTTP_PROXY environment variable support

- Added support for the HTTP_PROXY environment variable in ProxyManager to enhance proxy configuration capabilities.

* lint

* refactor(ProxyManager): optimize bypass rules handling

- Updated bypass rules initialization to split the rules string into an array for improved performance.
- Simplified the isByPass function to directly check against the array of bypass rules.
- Enhanced configuration handling to ensure bypass rules are correctly parsed from the provided settings.

* refactor(ProxyManager): streamline bypass rules initialization

- Consolidated the initialization of bypass rules by directly splitting the default rules string into an array.
- Updated configuration handling to ensure bypass rules are correctly assigned without redundant splitting.

* style(GeneralSettings): adjust proxy bypass rules input width to improve UI layout

* refactor(ProxyManager): enhance proxy configuration logging and handling

- Added proxy bypass rules to the configuration method for improved flexibility.
- Updated logging to include bypass rules for better debugging.
- Refactored the setGlobalProxy method to accept configuration parameters directly, streamlining proxy setup.
- Adjusted the useAppInit hook to handle proxy settings more cleanly.

* refactor(ProxyManager): implement close and destroy methods for proxy dispatcher

- Added close and destroy methods to the SelectiveDispatcher class for better resource management.
- Updated ProxyManager to handle the lifecycle of the proxyDispatcher, ensuring proper closure and destruction.
- Enhanced error handling during dispatcher closure and destruction to prevent resource leaks.

* refactor(ProxyManager): manage proxy agent lifecycle

- Introduced proxyAgent property to ProxyManager for better management of the proxy agent.
- Implemented error handling during the destruction of the proxy agent to prevent potential issues.
- Updated the proxy setup process to ensure the proxy agent is correctly initialized and cleaned up.

* refactor(ProxyManager): centralize default bypass rules management

- Moved default bypass rules to a shared constant for consistency across components.
- Updated ProxyManager and GeneralSettings to utilize the centralized bypass rules.
- Adjusted migration logic to set default bypass rules from the shared constant, ensuring uniformity in configuration.
This commit is contained in:
beyondkmp 2025-08-04 19:24:28 +08:00 committed by GitHub
parent 0e1df2460e
commit efda20c143
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 163 additions and 45 deletions

View File

@ -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'

View File

@ -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)
})

View File

@ -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<void> {
try {
await this.proxyDispatcher.close()
} catch (error) {
logger.error('Failed to close dispatcher:', error as Error)
this.proxyDispatcher.destroy()
}
}
async destroy(): Promise<void> {
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<void> {
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<void> {

View File

@ -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),

View File

@ -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)

View File

@ -3238,6 +3238,7 @@
},
"proxy": {
"address": "Proxy Address",
"bypass": "Bypass Rules",
"mode": {
"custom": "Custom Proxy",
"none": "No Proxy",

View File

@ -3238,6 +3238,7 @@
},
"proxy": {
"address": "プロキシアドレス",
"bypass": "バイパスルール",
"mode": {
"custom": "カスタムプロキシ",
"none": "プロキシを使用しない",

View File

@ -3238,6 +3238,7 @@
},
"proxy": {
"address": "Адрес прокси",
"bypass": "Правила обхода",
"mode": {
"custom": "Пользовательский прокси",
"none": "Не использовать прокси",

View File

@ -3238,6 +3238,7 @@
},
"proxy": {
"address": "代理地址",
"bypass": "代理绕过规则",
"mode": {
"custom": "自定义代理",
"none": "不使用代理",

View File

@ -3238,6 +3238,7 @@
},
"proxy": {
"address": "代理伺服器位址",
"bypass": "代理略過規則",
"mode": {
"custom": "自訂代理伺服器",
"none": "不使用代理伺服器",

View File

@ -3236,6 +3236,7 @@
},
"proxy": {
"address": "Διεύθυνση διαμεσολάβησης",
"bypass": "Κανόνες Παράκαμψης",
"mode": {
"custom": "προσαρμοσμένη προξενική",
"none": "χωρίς πρόξενο",

View File

@ -3236,6 +3236,7 @@
},
"proxy": {
"address": "Dirección del proxy",
"bypass": "Reglas de omisión",
"mode": {
"custom": "Proxy personalizado",
"none": "No usar proxy",

View File

@ -3236,6 +3236,7 @@
},
"proxy": {
"address": "Adresse du proxy",
"bypass": "Règles de contournement",
"mode": {
"custom": "Proxy personnalisé",
"none": "Ne pas utiliser de proxy",

View File

@ -3236,6 +3236,7 @@
},
"proxy": {
"address": "Endereço do proxy",
"bypass": "Regras de Contorno",
"mode": {
"custom": "Proxy Personalizado",
"none": "Não Usar Proxy",

View File

@ -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<string | undefined>(storeProxyUrl)
const [proxyBypassRules, setProxyBypassRules] = useState<string | undefined>(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 = () => {
<SettingRow>
<SettingRowTitle>{t('settings.proxy.address')}</SettingRowTitle>
<Input
spellCheck={false}
placeholder="socks5://127.0.0.1:6153"
value={proxyUrl}
onChange={(e) => setProxyUrl(e.target.value)}
@ -220,6 +229,22 @@ const GeneralSettings: FC = () => {
</SettingRow>
</>
)}
{(storeProxyMode === 'custom' || storeProxyMode === 'system') && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.proxy.bypass')}</SettingRowTitle>
<Input
spellCheck={false}
placeholder={defaultByPassRules}
value={proxyBypassRules}
onChange={(e) => setProxyBypassRules(e.target.value)}
style={{ width: 180 }}
onBlur={() => onSetProxyBypassRules()}
/>
</SettingRow>
</>
)}
<SettingDivider />
<SettingRow>
<HStack justifyContent="space-between" alignItems="center" style={{ flex: 1, marginRight: 16 }}>

View File

@ -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) => {
// 新字段默认支持

View File

@ -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<string | undefined>) => {
state.proxyUrl = action.payload
},
setProxyBypassRules: (state, action: PayloadAction<string | undefined>) => {
state.proxyBypassRules = action.payload
},
setUserName: (state, action: PayloadAction<string>) => {
state.userName = action.payload
},
@ -826,6 +831,7 @@ export const {
setTargetLanguage,
setProxyMode,
setProxyUrl,
setProxyBypassRules,
setUserName,
setShowPrompt,
setShowTokens,