feat: enhance proxy bypass rules with comprehensive matching (#10817)

* feat: enhance proxy bypass rules with comprehensive matching

- Add support for wildcard domains (*.example.com, .example.com)
- Add CIDR notation support for IPv4 and IPv6 (192.168.0.0/16, 2001:db8::/32)
- Add wildcard IP matching (192.168.1.*)
- Add <local> keyword for local network hostnames
- Support both semicolon and comma separators in bypass rules
- Add comprehensive unit tests with 22 test cases
- Export matchWildcardDomain and matchIpRule for testability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* move to devDeps

* delete logs

* feat: enhance ProxyManager with advanced proxy bypass rule handling

- Introduced comprehensive parsing and matching for proxy bypass rules, including support for wildcard domains, CIDR notation, and local network addresses.
- Refactored existing functions and added new utility methods for improved clarity and maintainability.
- Updated unit tests to cover new functionality and ensure robust validation of bypass rules.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* update proxy rules

* fix lint

* add tips

* delete hostname rule

* add logs

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
beyondkmp 2025-10-21 10:39:16 +08:00 committed by GitHub
parent bf35228b49
commit a5049d8872
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 466 additions and 32 deletions

View File

@ -280,6 +280,7 @@
"husky": "^9.1.7", "husky": "^9.1.7",
"i18next": "^23.11.5", "i18next": "^23.11.5",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
"ipaddr.js": "^2.2.0",
"isbinaryfile": "5.0.4", "isbinaryfile": "5.0.4",
"jaison": "^2.0.2", "jaison": "^2.0.2",
"jest-styled-components": "^7.2.0", "jest-styled-components": "^7.2.0",

View File

@ -4,6 +4,7 @@ import { app, ProxyConfig, session } from 'electron'
import { socksDispatcher } from 'fetch-socks' import { socksDispatcher } from 'fetch-socks'
import http from 'http' import http from 'http'
import https from 'https' import https from 'https'
import * as ipaddr from 'ipaddr.js'
import { getSystemProxy } from 'os-proxy-config' import { getSystemProxy } from 'os-proxy-config'
import { ProxyAgent } from 'proxy-agent' import { ProxyAgent } from 'proxy-agent'
import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici' import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici'
@ -11,41 +12,293 @@ import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher
const logger = loggerService.withContext('ProxyManager') const logger = loggerService.withContext('ProxyManager')
let byPassRules: string[] = [] let byPassRules: string[] = []
const isByPass = (url: string) => { type HostnameMatchType = 'exact' | 'wildcardSubdomain' | 'generalWildcard'
if (byPassRules.length === 0) {
const enum ProxyBypassRuleType {
Local = 'local',
Cidr = 'cidr',
Ip = 'ip',
Domain = 'domain'
}
interface ParsedProxyBypassRule {
type: ProxyBypassRuleType
matchType: HostnameMatchType
rule: string
scheme?: string
port?: string
domain?: string
regex?: RegExp
cidr?: [ipaddr.IPv4 | ipaddr.IPv6, number]
ip?: string
}
let parsedByPassRules: ParsedProxyBypassRule[] = []
const getDefaultPortForProtocol = (protocol: string): string | null => {
switch (protocol.toLowerCase()) {
case 'http:':
return '80'
case 'https:':
return '443'
default:
return null
}
}
const buildWildcardRegex = (pattern: string): RegExp => {
const escapedSegments = pattern.split('*').map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
return new RegExp(`^${escapedSegments.join('.*')}$`, 'i')
}
const isWildcardIp = (value: string): boolean => {
if (!value.includes('*')) {
return false
}
const replaced = value.replace(/\*/g, '0')
return ipaddr.isValid(replaced)
}
const matchHostnameRule = (hostname: string, rule: ParsedProxyBypassRule): boolean => {
const normalizedHostname = hostname.toLowerCase()
switch (rule.matchType) {
case 'exact':
return normalizedHostname === rule.domain
case 'wildcardSubdomain': {
const domain = rule.domain
if (!domain) {
return false
}
return normalizedHostname === domain || normalizedHostname.endsWith(`.${domain}`)
}
case 'generalWildcard':
return rule.regex ? rule.regex.test(normalizedHostname) : false
default:
return false
}
}
const parseProxyBypassRule = (rule: string): ParsedProxyBypassRule | null => {
const trimmedRule = rule.trim()
if (!trimmedRule) {
return null
}
if (trimmedRule === '<local>') {
return {
type: ProxyBypassRuleType.Local,
matchType: 'exact',
rule: '<local>'
}
}
let workingRule = trimmedRule
let scheme: string | undefined
const schemeMatch = workingRule.match(/^([a-zA-Z][a-zA-Z\d+\-.]*):\/\//)
if (schemeMatch) {
scheme = schemeMatch[1].toLowerCase()
workingRule = workingRule.slice(schemeMatch[0].length)
}
// CIDR notation must be processed before port extraction
if (workingRule.includes('/')) {
const cleanedCidr = workingRule.replace(/^\[|\]$/g, '')
if (ipaddr.isValidCIDR(cleanedCidr)) {
return {
type: ProxyBypassRuleType.Cidr,
matchType: 'exact',
rule: workingRule,
scheme,
cidr: ipaddr.parseCIDR(cleanedCidr)
}
}
}
// Extract port: supports "host:port" and "[ipv6]:port" formats
let port: string | undefined
const portMatch = workingRule.match(/^(.+?):(\d+)$/)
if (portMatch) {
// For IPv6, ensure we're not splitting inside the brackets
const potentialHost = portMatch[1]
if (!potentialHost.startsWith('[') || potentialHost.includes(']')) {
workingRule = potentialHost
port = portMatch[2]
}
}
const cleanedHost = workingRule.replace(/^\[|\]$/g, '')
const normalizedHost = cleanedHost.toLowerCase()
if (!cleanedHost) {
return null
}
if (ipaddr.isValid(cleanedHost)) {
return {
type: ProxyBypassRuleType.Ip,
matchType: 'exact',
rule: cleanedHost,
scheme,
port,
ip: cleanedHost
}
}
if (isWildcardIp(cleanedHost)) {
const regexPattern = cleanedHost.replace(/\./g, '\\.').replace(/\*/g, '\\d+')
return {
type: ProxyBypassRuleType.Ip,
matchType: 'generalWildcard',
rule: cleanedHost,
scheme,
port,
regex: new RegExp(`^${regexPattern}$`)
}
}
if (workingRule.startsWith('*.')) {
const domain = normalizedHost.slice(2)
return {
type: ProxyBypassRuleType.Domain,
matchType: 'wildcardSubdomain',
rule: workingRule,
scheme,
port,
domain
}
}
if (workingRule.startsWith('.')) {
const domain = normalizedHost.slice(1)
return {
type: ProxyBypassRuleType.Domain,
matchType: 'wildcardSubdomain',
rule: workingRule,
scheme,
port,
domain
}
}
if (workingRule.includes('*')) {
return {
type: ProxyBypassRuleType.Domain,
matchType: 'generalWildcard',
rule: workingRule,
scheme,
port,
regex: buildWildcardRegex(normalizedHost)
}
}
return {
type: ProxyBypassRuleType.Domain,
matchType: 'exact',
rule: workingRule,
scheme,
port,
domain: normalizedHost
}
}
const isLocalHostname = (hostname: string): boolean => {
const normalized = hostname.toLowerCase()
if (normalized === 'localhost') {
return true
}
const cleaned = hostname.replace(/^\[|\]$/g, '')
if (ipaddr.isValid(cleaned)) {
const parsed = ipaddr.parse(cleaned)
return parsed.range() === 'loopback'
}
return false
}
export const updateByPassRules = (rules: string[]): void => {
byPassRules = rules
parsedByPassRules = []
for (const rule of rules) {
const parsedRule = parseProxyBypassRule(rule)
if (parsedRule) {
parsedByPassRules.push(parsedRule)
} else {
logger.warn(`Skipping invalid proxy bypass rule: ${rule}`)
}
}
}
export const isByPass = (url: string) => {
if (parsedByPassRules.length === 0) {
return false return false
} }
try { try {
const subjectUrlTokens = new URL(url) const parsedUrl = new URL(url)
for (const rule of byPassRules) { const hostname = parsedUrl.hostname
const ruleMatch = rule.replace(/^(?<leadingDot>\.)/, '*').match(/^(?<hostname>.+?)(?::(?<port>\d+))?$/) const cleanedHostname = hostname.replace(/^\[|\]$/g, '')
const protocol = parsedUrl.protocol
const protocolName = protocol.replace(':', '').toLowerCase()
const defaultPort = getDefaultPortForProtocol(protocol)
const port = parsedUrl.port || defaultPort || ''
const hostnameIsIp = ipaddr.isValid(cleanedHostname)
if (!ruleMatch || !ruleMatch.groups) { for (const rule of parsedByPassRules) {
logger.warn('Failed to parse bypass rule:', { rule }) if (rule.scheme && rule.scheme !== protocolName) {
continue continue
} }
if (!ruleMatch.groups.hostname) { if (rule.port && rule.port !== port) {
continue continue
} }
const hostnameIsMatch = subjectUrlTokens.hostname === ruleMatch.groups.hostname switch (rule.type) {
case ProxyBypassRuleType.Local:
if (isLocalHostname(hostname)) {
return true
}
break
case ProxyBypassRuleType.Ip:
if (!hostnameIsIp) {
break
}
if ( if (rule.ip && cleanedHostname === rule.ip) {
hostnameIsMatch && return true
(!ruleMatch.groups || }
!ruleMatch.groups.port ||
(subjectUrlTokens.port && subjectUrlTokens.port === ruleMatch.groups.port)) if (rule.regex && rule.regex.test(cleanedHostname)) {
) { return true
return true }
break
case ProxyBypassRuleType.Cidr:
if (hostnameIsIp && rule.cidr) {
const parsedHost = ipaddr.parse(cleanedHostname)
const [cidrAddress, prefixLength] = rule.cidr
// Ensure IP version matches before comparing
if (parsedHost.kind() === cidrAddress.kind() && parsedHost.match([cidrAddress, prefixLength])) {
return true
}
}
break
case ProxyBypassRuleType.Domain:
if (!hostnameIsIp && matchHostnameRule(hostname, rule)) {
return true
}
break
default:
logger.error(`Unknown proxy bypass rule type: ${rule.type}`)
break
} }
} }
return false
} catch (error) { } catch (error) {
logger.error('Failed to check bypass:', error as Error) logger.error('Failed to check bypass:', error as Error)
return false return false
} }
return false
} }
class SelectiveDispatcher extends Dispatcher { class SelectiveDispatcher extends Dispatcher {
private proxyDispatcher: Dispatcher private proxyDispatcher: Dispatcher
@ -154,19 +407,31 @@ export class ProxyManager {
this.isSettingProxy = true this.isSettingProxy = true
try { try {
this.config = config
this.clearSystemProxyMonitor() this.clearSystemProxyMonitor()
if (config.mode === 'system') { if (config.mode === 'system') {
const currentProxy = await getSystemProxy() const currentProxy = await getSystemProxy()
if (currentProxy) { if (currentProxy) {
logger.info(`current system proxy: ${currentProxy.proxyUrl}`) logger.info(`current system proxy: ${currentProxy.proxyUrl}, bypass rules: ${currentProxy.noProxy.join(',')}`)
this.config.proxyRules = currentProxy.proxyUrl.toLowerCase() config.proxyRules = currentProxy.proxyUrl.toLowerCase()
config.proxyBypassRules = currentProxy.noProxy.join(',')
} }
this.monitorSystemProxy() this.monitorSystemProxy()
} }
byPassRules = config.proxyBypassRules?.split(',') || [] // Support both semicolon and comma as separators
this.setGlobalProxy(this.config) if (config.proxyBypassRules !== this.config.proxyBypassRules) {
const rawRules = config.proxyBypassRules
? config.proxyBypassRules
.split(/[;,]/)
.map((rule) => rule.trim())
.filter((rule) => rule.length > 0)
: []
updateByPassRules(rawRules)
}
this.setGlobalProxy(config)
this.config = config
} catch (error) { } catch (error) {
logger.error('Failed to config proxy:', error as Error) logger.error('Failed to config proxy:', error as Error)
throw error throw error

View File

@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { isByPass, updateByPassRules } from '../ProxyManager'
describe('ProxyManager - bypass evaluation', () => {
beforeEach(() => {
updateByPassRules([])
})
it('matches simple hostname patterns', () => {
updateByPassRules(['foobar.com'])
expect(isByPass('http://foobar.com')).toBe(true)
expect(isByPass('http://www.foobar.com')).toBe(false)
updateByPassRules(['*.foobar.com'])
expect(isByPass('http://api.foobar.com')).toBe(true)
expect(isByPass('http://foobar.com')).toBe(true)
expect(isByPass('http://foobar.org')).toBe(false)
updateByPassRules(['*foobar.com'])
expect(isByPass('http://devfoobar.com')).toBe(true)
expect(isByPass('http://foobar.com')).toBe(true)
expect(isByPass('http://foobar.company')).toBe(false)
})
it('matches hostname patterns with scheme and port qualifiers', () => {
updateByPassRules(['https://secure.example.com'])
expect(isByPass('https://secure.example.com')).toBe(true)
expect(isByPass('https://secure.example.com:443/home')).toBe(true)
expect(isByPass('http://secure.example.com')).toBe(false)
updateByPassRules(['https://secure.example.com:8443'])
expect(isByPass('https://secure.example.com:8443')).toBe(true)
expect(isByPass('https://secure.example.com')).toBe(false)
expect(isByPass('https://secure.example.com:443')).toBe(false)
updateByPassRules(['https://x.*.y.com:99'])
expect(isByPass('https://x.api.y.com:99')).toBe(true)
expect(isByPass('https://x.api.y.com')).toBe(false)
expect(isByPass('http://x.api.y.com:99')).toBe(false)
})
it('matches domain suffix patterns with leading dot', () => {
updateByPassRules(['.example.com'])
expect(isByPass('https://example.com')).toBe(true)
expect(isByPass('https://api.example.com')).toBe(true)
expect(isByPass('https://deep.api.example.com')).toBe(true)
expect(isByPass('https://example.org')).toBe(false)
updateByPassRules(['.com'])
expect(isByPass('https://anything.com')).toBe(true)
expect(isByPass('https://example.org')).toBe(false)
updateByPassRules(['http://.google.com'])
expect(isByPass('http://maps.google.com')).toBe(true)
expect(isByPass('https://maps.google.com')).toBe(false)
})
it('matches IP literals, CIDR ranges, and wildcard IPs', () => {
updateByPassRules(['127.0.0.1', '[::1]', '192.168.1.0/24', 'fefe:13::abc/33', '192.168.*.*'])
expect(isByPass('http://127.0.0.1')).toBe(true)
expect(isByPass('http://[::1]')).toBe(true)
expect(isByPass('http://192.168.1.55')).toBe(true)
expect(isByPass('http://192.168.200.200')).toBe(true)
expect(isByPass('http://192.169.1.1')).toBe(false)
expect(isByPass('http://[fefe:13::abc]')).toBe(true)
})
it('matches CIDR ranges specified with IPv6 prefix lengths', () => {
updateByPassRules(['[2001:db8::1]', '2001:db8::/32'])
expect(isByPass('http://[2001:db8::1]')).toBe(true)
expect(isByPass('http://[2001:db8:0:0:0:0:0:ffff]')).toBe(true)
expect(isByPass('http://[2001:db9::1]')).toBe(false)
})
it('matches local addresses when <local> keyword is provided', () => {
updateByPassRules(['<local>'])
expect(isByPass('http://localhost')).toBe(true)
expect(isByPass('http://127.0.0.1')).toBe(true)
expect(isByPass('http://[::1]')).toBe(true)
expect(isByPass('http://dev.localdomain')).toBe(false)
})
})

View File

@ -4228,7 +4228,8 @@
"none": "No Proxy", "none": "No Proxy",
"system": "System Proxy", "system": "System Proxy",
"title": "Proxy Mode" "title": "Proxy Mode"
} },
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
}, },
"quickAssistant": { "quickAssistant": {
"click_tray_to_show": "Click the tray icon to start", "click_tray_to_show": "Click the tray icon to start",

View File

@ -4228,7 +4228,8 @@
"none": "不使用代理", "none": "不使用代理",
"system": "系统代理", "system": "系统代理",
"title": "代理模式" "title": "代理模式"
} },
"tip": "支持模糊匹配(*.test.com,192.168.0.0/16)"
}, },
"quickAssistant": { "quickAssistant": {
"click_tray_to_show": "点击托盘图标启动", "click_tray_to_show": "点击托盘图标启动",

View File

@ -4228,7 +4228,8 @@
"none": "不使用代理伺服器", "none": "不使用代理伺服器",
"system": "系統代理伺服器", "system": "系統代理伺服器",
"title": "代理伺服器模式" "title": "代理伺服器模式"
} },
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
}, },
"quickAssistant": { "quickAssistant": {
"click_tray_to_show": "點選工具列圖示啟動", "click_tray_to_show": "點選工具列圖示啟動",

View File

@ -1960,6 +1960,14 @@
"rename": "μετονομασία", "rename": "μετονομασία",
"rename_changed": "Λόγω πολιτικής ασφάλειας, το όνομα του αρχείου έχει αλλάξει από {{original}} σε {{final}}", "rename_changed": "Λόγω πολιτικής ασφάλειας, το όνομα του αρχείου έχει αλλάξει από {{original}} σε {{final}}",
"save": "αποθήκευση στις σημειώσεις", "save": "αποθήκευση στις σημειώσεις",
"search": {
"both": "[to be translated]:名称+内容",
"content": "[to be translated]:内容",
"found_results": "[to be translated]:找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
"more_matches": "[to be translated]:个匹配",
"searching": "[to be translated]:搜索中...",
"show_less": "[to be translated]:收起"
},
"settings": { "settings": {
"data": { "data": {
"apply": "εφαρμογή", "apply": "εφαρμογή",
@ -2108,6 +2116,8 @@
"install_code_103": "Η λήψη του OVMS runtime απέτυχε", "install_code_103": "Η λήψη του OVMS runtime απέτυχε",
"install_code_104": "Η αποσυμπίεση του OVMS runtime απέτυχε", "install_code_104": "Η αποσυμπίεση του OVMS runtime απέτυχε",
"install_code_105": "Ο καθαρισμός του OVMS runtime απέτυχε", "install_code_105": "Ο καθαρισμός του OVMS runtime απέτυχε",
"install_code_106": "[to be translated]:创建 run.bat 失败",
"install_code_110": "[to be translated]:清理旧 OVMS runtime 失败",
"run": "Η εκτέλεση του OVMS απέτυχε:", "run": "Η εκτέλεση του OVMS απέτυχε:",
"stop": "Η διακοπή του OVMS απέτυχε:" "stop": "Η διακοπή του OVMS απέτυχε:"
}, },
@ -4218,7 +4228,8 @@
"none": "χωρίς πρόξενο", "none": "χωρίς πρόξενο",
"system": "συστηματική προξενική", "system": "συστηματική προξενική",
"title": "κλίμακα προξενικής" "title": "κλίμακα προξενικής"
} },
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
}, },
"quickAssistant": { "quickAssistant": {
"click_tray_to_show": "Επιλέξτε την εικόνα στο πίνακα για να ενεργοποιήσετε", "click_tray_to_show": "Επιλέξτε την εικόνα στο πίνακα για να ενεργοποιήσετε",

View File

@ -1960,6 +1960,14 @@
"rename": "renombrar", "rename": "renombrar",
"rename_changed": "Debido a políticas de seguridad, el nombre del archivo ha cambiado de {{original}} a {{final}}", "rename_changed": "Debido a políticas de seguridad, el nombre del archivo ha cambiado de {{original}} a {{final}}",
"save": "Guardar en notas", "save": "Guardar en notas",
"search": {
"both": "[to be translated]:名称+内容",
"content": "[to be translated]:内容",
"found_results": "[to be translated]:找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
"more_matches": "[to be translated]:个匹配",
"searching": "[to be translated]:搜索中...",
"show_less": "[to be translated]:收起"
},
"settings": { "settings": {
"data": { "data": {
"apply": "aplicación", "apply": "aplicación",
@ -2108,6 +2116,8 @@
"install_code_103": "Error al descargar el tiempo de ejecución de OVMS", "install_code_103": "Error al descargar el tiempo de ejecución de OVMS",
"install_code_104": "Error al descomprimir el tiempo de ejecución de OVMS", "install_code_104": "Error al descomprimir el tiempo de ejecución de OVMS",
"install_code_105": "Error al limpiar el tiempo de ejecución de OVMS", "install_code_105": "Error al limpiar el tiempo de ejecución de OVMS",
"install_code_106": "[to be translated]:创建 run.bat 失败",
"install_code_110": "[to be translated]:清理旧 OVMS runtime 失败",
"run": "Error al ejecutar OVMS:", "run": "Error al ejecutar OVMS:",
"stop": "Error al detener OVMS:" "stop": "Error al detener OVMS:"
}, },
@ -4218,7 +4228,8 @@
"none": "No usar proxy", "none": "No usar proxy",
"system": "Proxy del sistema", "system": "Proxy del sistema",
"title": "Modo de proxy" "title": "Modo de proxy"
} },
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
}, },
"quickAssistant": { "quickAssistant": {
"click_tray_to_show": "Haz clic en el icono de la bandeja para iniciar", "click_tray_to_show": "Haz clic en el icono de la bandeja para iniciar",

View File

@ -1960,6 +1960,14 @@
"rename": "renommer", "rename": "renommer",
"rename_changed": "En raison de la politique de sécurité, le nom du fichier a été changé de {{original}} à {{final}}", "rename_changed": "En raison de la politique de sécurité, le nom du fichier a été changé de {{original}} à {{final}}",
"save": "sauvegarder dans les notes", "save": "sauvegarder dans les notes",
"search": {
"both": "[to be translated]:名称+内容",
"content": "[to be translated]:内容",
"found_results": "[to be translated]:找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
"more_matches": "[to be translated]:个匹配",
"searching": "[to be translated]:搜索中...",
"show_less": "[to be translated]:收起"
},
"settings": { "settings": {
"data": { "data": {
"apply": "application", "apply": "application",
@ -2108,6 +2116,8 @@
"install_code_103": "Échec du téléchargement du runtime OVMS", "install_code_103": "Échec du téléchargement du runtime OVMS",
"install_code_104": "Échec de la décompression du runtime OVMS", "install_code_104": "Échec de la décompression du runtime OVMS",
"install_code_105": "Échec du nettoyage du runtime OVMS", "install_code_105": "Échec du nettoyage du runtime OVMS",
"install_code_106": "[to be translated]:创建 run.bat 失败",
"install_code_110": "[to be translated]:清理旧 OVMS runtime 失败",
"run": "Échec de l'exécution d'OVMS :", "run": "Échec de l'exécution d'OVMS :",
"stop": "Échec de l'arrêt d'OVMS :" "stop": "Échec de l'arrêt d'OVMS :"
}, },
@ -4218,7 +4228,8 @@
"none": "Ne pas utiliser de proxy", "none": "Ne pas utiliser de proxy",
"system": "Proxy système", "system": "Proxy système",
"title": "Mode de proxy" "title": "Mode de proxy"
} },
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
}, },
"quickAssistant": { "quickAssistant": {
"click_tray_to_show": "Cliquez sur l'icône dans la barre d'état système pour démarrer", "click_tray_to_show": "Cliquez sur l'icône dans la barre d'état système pour démarrer",

View File

@ -1960,6 +1960,14 @@
"rename": "名前の変更", "rename": "名前の変更",
"rename_changed": "セキュリティポリシーにより、ファイル名は{{original}}から{{final}}に変更されました", "rename_changed": "セキュリティポリシーにより、ファイル名は{{original}}から{{final}}に変更されました",
"save": "メモに保存する", "save": "メモに保存する",
"search": {
"both": "[to be translated]:名称+内容",
"content": "[to be translated]:内容",
"found_results": "[to be translated]:找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
"more_matches": "[to be translated]:个匹配",
"searching": "[to be translated]:搜索中...",
"show_less": "[to be translated]:收起"
},
"settings": { "settings": {
"data": { "data": {
"apply": "応用", "apply": "応用",
@ -2108,6 +2116,8 @@
"install_code_103": "OVMSランタイムのダウンロードに失敗しました", "install_code_103": "OVMSランタイムのダウンロードに失敗しました",
"install_code_104": "OVMSランタイムの解凍に失敗しました", "install_code_104": "OVMSランタイムの解凍に失敗しました",
"install_code_105": "OVMSランタイムのクリーンアップに失敗しました", "install_code_105": "OVMSランタイムのクリーンアップに失敗しました",
"install_code_106": "[to be translated]:创建 run.bat 失败",
"install_code_110": "[to be translated]:清理旧 OVMS runtime 失败",
"run": "OVMSの実行に失敗しました:", "run": "OVMSの実行に失敗しました:",
"stop": "OVMSの停止に失敗しました:" "stop": "OVMSの停止に失敗しました:"
}, },
@ -4218,7 +4228,8 @@
"none": "プロキシを使用しない", "none": "プロキシを使用しない",
"system": "システムプロキシ", "system": "システムプロキシ",
"title": "プロキシモード" "title": "プロキシモード"
} },
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
}, },
"quickAssistant": { "quickAssistant": {
"click_tray_to_show": "トレイアイコンをクリックして起動", "click_tray_to_show": "トレイアイコンをクリックして起動",

View File

@ -1960,6 +1960,14 @@
"rename": "renomear", "rename": "renomear",
"rename_changed": "Devido às políticas de segurança, o nome do arquivo foi alterado de {{original}} para {{final}}", "rename_changed": "Devido às políticas de segurança, o nome do arquivo foi alterado de {{original}} para {{final}}",
"save": "salvar em notas", "save": "salvar em notas",
"search": {
"both": "[to be translated]:名称+内容",
"content": "[to be translated]:内容",
"found_results": "[to be translated]:找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
"more_matches": "[to be translated]:个匹配",
"searching": "[to be translated]:搜索中...",
"show_less": "[to be translated]:收起"
},
"settings": { "settings": {
"data": { "data": {
"apply": "aplicativo", "apply": "aplicativo",
@ -2108,6 +2116,8 @@
"install_code_103": "Falha ao baixar o tempo de execução do OVMS", "install_code_103": "Falha ao baixar o tempo de execução do OVMS",
"install_code_104": "Falha ao descompactar o tempo de execução do OVMS", "install_code_104": "Falha ao descompactar o tempo de execução do OVMS",
"install_code_105": "Falha ao limpar o tempo de execução do OVMS", "install_code_105": "Falha ao limpar o tempo de execução do OVMS",
"install_code_106": "[to be translated]:创建 run.bat 失败",
"install_code_110": "[to be translated]:清理旧 OVMS runtime 失败",
"run": "Falha ao executar o OVMS:", "run": "Falha ao executar o OVMS:",
"stop": "Falha ao parar o OVMS:" "stop": "Falha ao parar o OVMS:"
}, },
@ -4218,7 +4228,8 @@
"none": "Não Usar Proxy", "none": "Não Usar Proxy",
"system": "Proxy do Sistema", "system": "Proxy do Sistema",
"title": "Modo de Proxy" "title": "Modo de Proxy"
} },
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
}, },
"quickAssistant": { "quickAssistant": {
"click_tray_to_show": "Clique no ícone da bandeja para iniciar", "click_tray_to_show": "Clique no ícone da bandeja para iniciar",

View File

@ -1960,6 +1960,14 @@
"rename": "переименовать", "rename": "переименовать",
"rename_changed": "В связи с политикой безопасности имя файла было изменено с {{Original}} на {{final}}", "rename_changed": "В связи с политикой безопасности имя файла было изменено с {{Original}} на {{final}}",
"save": "Сохранить в заметки", "save": "Сохранить в заметки",
"search": {
"both": "[to be translated]:名称+内容",
"content": "[to be translated]:内容",
"found_results": "[to be translated]:找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
"more_matches": "[to be translated]:个匹配",
"searching": "[to be translated]:搜索中...",
"show_less": "[to be translated]:收起"
},
"settings": { "settings": {
"data": { "data": {
"apply": "приложение", "apply": "приложение",
@ -2108,6 +2116,8 @@
"install_code_103": "Ошибка загрузки среды выполнения OVMS", "install_code_103": "Ошибка загрузки среды выполнения OVMS",
"install_code_104": "Ошибка распаковки среды выполнения OVMS", "install_code_104": "Ошибка распаковки среды выполнения OVMS",
"install_code_105": "Ошибка очистки среды выполнения OVMS", "install_code_105": "Ошибка очистки среды выполнения OVMS",
"install_code_106": "[to be translated]:创建 run.bat 失败",
"install_code_110": "[to be translated]:清理旧 OVMS runtime 失败",
"run": "Ошибка запуска OVMS:", "run": "Ошибка запуска OVMS:",
"stop": "Ошибка остановки OVMS:" "stop": "Ошибка остановки OVMS:"
}, },
@ -4218,7 +4228,8 @@
"none": "Не использовать прокси", "none": "Не использовать прокси",
"system": "Системный прокси", "system": "Системный прокси",
"title": "Режим прокси" "title": "Режим прокси"
} },
"tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)"
}, },
"quickAssistant": { "quickAssistant": {
"click_tray_to_show": "Нажмите на иконку трея для запуска", "click_tray_to_show": "Нажмите на иконку трея для запуска",

View File

@ -233,7 +233,12 @@ const GeneralSettings: FC = () => {
<> <>
<SettingDivider /> <SettingDivider />
<SettingRow> <SettingRow>
<SettingRowTitle>{t('settings.proxy.bypass')}</SettingRowTitle> <SettingRowTitle style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span>{t('settings.proxy.bypass')}</span>
<Tooltip title={t('settings.proxy.tip')} placement="right">
<InfoCircleOutlined style={{ cursor: 'pointer' }} />
</Tooltip>
</SettingRowTitle>
<Input <Input
spellCheck={false} spellCheck={false}
placeholder={defaultByPassRules} placeholder={defaultByPassRules}

View File

@ -14021,6 +14021,7 @@ __metadata:
husky: "npm:^9.1.7" husky: "npm:^9.1.7"
i18next: "npm:^23.11.5" i18next: "npm:^23.11.5"
iconv-lite: "npm:^0.6.3" iconv-lite: "npm:^0.6.3"
ipaddr.js: "npm:^2.2.0"
isbinaryfile: "npm:5.0.4" isbinaryfile: "npm:5.0.4"
jaison: "npm:^2.0.2" jaison: "npm:^2.0.2"
jest-styled-components: "npm:^7.2.0" jest-styled-components: "npm:^7.2.0"
@ -20322,6 +20323,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ipaddr.js@npm:^2.2.0":
version: 2.2.0
resolution: "ipaddr.js@npm:2.2.0"
checksum: 10c0/e4ee875dc1bd92ac9d27e06cfd87cdb63ca786ff9fd7718f1d4f7a8ef27db6e5d516128f52d2c560408cbb75796ac2f83ead669e73507c86282d45f84c5abbb6
languageName: node
linkType: hard
"is-alphabetical@npm:^1.0.0": "is-alphabetical@npm:^1.0.0":
version: 1.0.4 version: 1.0.4
resolution: "is-alphabetical@npm:1.0.4" resolution: "is-alphabetical@npm:1.0.4"