diff --git a/package.json b/package.json index 7c937cc3b..0b8c5971a 100644 --- a/package.json +++ b/package.json @@ -280,6 +280,7 @@ "husky": "^9.1.7", "i18next": "^23.11.5", "iconv-lite": "^0.6.3", + "ipaddr.js": "^2.2.0", "isbinaryfile": "5.0.4", "jaison": "^2.0.2", "jest-styled-components": "^7.2.0", diff --git a/src/main/services/ProxyManager.ts b/src/main/services/ProxyManager.ts index 0e188c100..6fcf20da1 100644 --- a/src/main/services/ProxyManager.ts +++ b/src/main/services/ProxyManager.ts @@ -4,6 +4,7 @@ import { app, ProxyConfig, session } from 'electron' import { socksDispatcher } from 'fetch-socks' import http from 'http' import https from 'https' +import * as ipaddr from 'ipaddr.js' import { getSystemProxy } from 'os-proxy-config' import { ProxyAgent } from 'proxy-agent' import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici' @@ -11,41 +12,293 @@ import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher const logger = loggerService.withContext('ProxyManager') let byPassRules: string[] = [] -const isByPass = (url: string) => { - if (byPassRules.length === 0) { +type HostnameMatchType = 'exact' | 'wildcardSubdomain' | 'generalWildcard' + +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 === '') { + return { + type: ProxyBypassRuleType.Local, + matchType: 'exact', + rule: '' + } + } + + 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 } try { - const subjectUrlTokens = new URL(url) - for (const rule of byPassRules) { - const ruleMatch = rule.replace(/^(?\.)/, '*').match(/^(?.+?)(?::(?\d+))?$/) + const parsedUrl = new URL(url) + const hostname = parsedUrl.hostname + 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) { - logger.warn('Failed to parse bypass rule:', { rule }) + for (const rule of parsedByPassRules) { + if (rule.scheme && rule.scheme !== protocolName) { continue } - if (!ruleMatch.groups.hostname) { + if (rule.port && rule.port !== port) { 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 ( - hostnameIsMatch && - (!ruleMatch.groups || - !ruleMatch.groups.port || - (subjectUrlTokens.port && subjectUrlTokens.port === ruleMatch.groups.port)) - ) { - return true + if (rule.ip && cleanedHostname === rule.ip) { + return true + } + + if (rule.regex && rule.regex.test(cleanedHostname)) { + 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) { logger.error('Failed to check bypass:', error as Error) return false } + return false } class SelectiveDispatcher extends Dispatcher { private proxyDispatcher: Dispatcher @@ -154,19 +407,31 @@ export class ProxyManager { this.isSettingProxy = true try { - this.config = config this.clearSystemProxyMonitor() if (config.mode === 'system') { const currentProxy = await getSystemProxy() if (currentProxy) { - logger.info(`current system proxy: ${currentProxy.proxyUrl}`) - this.config.proxyRules = currentProxy.proxyUrl.toLowerCase() + logger.info(`current system proxy: ${currentProxy.proxyUrl}, bypass rules: ${currentProxy.noProxy.join(',')}`) + config.proxyRules = currentProxy.proxyUrl.toLowerCase() + config.proxyBypassRules = currentProxy.noProxy.join(',') } this.monitorSystemProxy() } - byPassRules = config.proxyBypassRules?.split(',') || [] - this.setGlobalProxy(this.config) + // Support both semicolon and comma as separators + 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) { logger.error('Failed to config proxy:', error as Error) throw error diff --git a/src/main/services/__tests__/ProxyManager.test.ts b/src/main/services/__tests__/ProxyManager.test.ts new file mode 100644 index 000000000..b1d70f870 --- /dev/null +++ b/src/main/services/__tests__/ProxyManager.test.ts @@ -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 keyword is provided', () => { + updateByPassRules(['']) + + 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) + }) +}) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 6b58618ed..84ef5a991 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -4228,7 +4228,8 @@ "none": "No Proxy", "system": "System Proxy", "title": "Proxy Mode" - } + }, + "tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)" }, "quickAssistant": { "click_tray_to_show": "Click the tray icon to start", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 915631157..b4c0864b5 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -4228,7 +4228,8 @@ "none": "不使用代理", "system": "系统代理", "title": "代理模式" - } + }, + "tip": "支持模糊匹配(*.test.com,192.168.0.0/16)" }, "quickAssistant": { "click_tray_to_show": "点击托盘图标启动", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index ef4ac80d9..749ee263c 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -4228,7 +4228,8 @@ "none": "不使用代理伺服器", "system": "系統代理伺服器", "title": "代理伺服器模式" - } + }, + "tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)" }, "quickAssistant": { "click_tray_to_show": "點選工具列圖示啟動", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 6bba2e3f3..1d5e2ea08 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -1960,6 +1960,14 @@ "rename": "μετονομασία", "rename_changed": "Λόγω πολιτικής ασφάλειας, το όνομα του αρχείου έχει αλλάξει από {{original}} σε {{final}}", "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": { "data": { "apply": "εφαρμογή", @@ -2108,6 +2116,8 @@ "install_code_103": "Η λήψη του OVMS runtime απέτυχε", "install_code_104": "Η αποσυμπίεση του 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 απέτυχε:", "stop": "Η διακοπή του OVMS απέτυχε:" }, @@ -4218,7 +4228,8 @@ "none": "χωρίς πρόξενο", "system": "συστηματική προξενική", "title": "κλίμακα προξενικής" - } + }, + "tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)" }, "quickAssistant": { "click_tray_to_show": "Επιλέξτε την εικόνα στο πίνακα για να ενεργοποιήσετε", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 8b0d6cb1e..dec0ef59e 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -1960,6 +1960,14 @@ "rename": "renombrar", "rename_changed": "Debido a políticas de seguridad, el nombre del archivo ha cambiado de {{original}} a {{final}}", "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": { "data": { "apply": "aplicación", @@ -2108,6 +2116,8 @@ "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_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:", "stop": "Error al detener OVMS:" }, @@ -4218,7 +4228,8 @@ "none": "No usar proxy", "system": "Proxy del sistema", "title": "Modo de proxy" - } + }, + "tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)" }, "quickAssistant": { "click_tray_to_show": "Haz clic en el icono de la bandeja para iniciar", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 36a3c0159..1f26564f5 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -1960,6 +1960,14 @@ "rename": "renommer", "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", + "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": { "data": { "apply": "application", @@ -2108,6 +2116,8 @@ "install_code_103": "Échec du téléchargement 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_106": "[to be translated]:创建 run.bat 失败", + "install_code_110": "[to be translated]:清理旧 OVMS runtime 失败", "run": "Échec de l'exécution d'OVMS :", "stop": "Échec de l'arrêt d'OVMS :" }, @@ -4218,7 +4228,8 @@ "none": "Ne pas utiliser de proxy", "system": "Proxy système", "title": "Mode de proxy" - } + }, + "tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)" }, "quickAssistant": { "click_tray_to_show": "Cliquez sur l'icône dans la barre d'état système pour démarrer", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 9d0884b2a..aae0fdb3c 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -1960,6 +1960,14 @@ "rename": "名前の変更", "rename_changed": "セキュリティポリシーにより、ファイル名は{{original}}から{{final}}に変更されました", "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": { "data": { "apply": "応用", @@ -2108,6 +2116,8 @@ "install_code_103": "OVMSランタイムのダウンロードに失敗しました", "install_code_104": "OVMSランタイムの解凍に失敗しました", "install_code_105": "OVMSランタイムのクリーンアップに失敗しました", + "install_code_106": "[to be translated]:创建 run.bat 失败", + "install_code_110": "[to be translated]:清理旧 OVMS runtime 失败", "run": "OVMSの実行に失敗しました:", "stop": "OVMSの停止に失敗しました:" }, @@ -4218,7 +4228,8 @@ "none": "プロキシを使用しない", "system": "システムプロキシ", "title": "プロキシモード" - } + }, + "tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)" }, "quickAssistant": { "click_tray_to_show": "トレイアイコンをクリックして起動", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 5145f74b1..f534e7bea 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -1960,6 +1960,14 @@ "rename": "renomear", "rename_changed": "Devido às políticas de segurança, o nome do arquivo foi alterado de {{original}} para {{final}}", "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": { "data": { "apply": "aplicativo", @@ -2108,6 +2116,8 @@ "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_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:", "stop": "Falha ao parar o OVMS:" }, @@ -4218,7 +4228,8 @@ "none": "Não Usar Proxy", "system": "Proxy do Sistema", "title": "Modo de Proxy" - } + }, + "tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)" }, "quickAssistant": { "click_tray_to_show": "Clique no ícone da bandeja para iniciar", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 0d2448a92..04d8fde18 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -1960,6 +1960,14 @@ "rename": "переименовать", "rename_changed": "В связи с политикой безопасности имя файла было изменено с {{Original}} на {{final}}", "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": { "data": { "apply": "приложение", @@ -2108,6 +2116,8 @@ "install_code_103": "Ошибка загрузки среды выполнения OVMS", "install_code_104": "Ошибка распаковки среды выполнения OVMS", "install_code_105": "Ошибка очистки среды выполнения OVMS", + "install_code_106": "[to be translated]:创建 run.bat 失败", + "install_code_110": "[to be translated]:清理旧 OVMS runtime 失败", "run": "Ошибка запуска OVMS:", "stop": "Ошибка остановки OVMS:" }, @@ -4218,7 +4228,8 @@ "none": "Не использовать прокси", "system": "Системный прокси", "title": "Режим прокси" - } + }, + "tip": "[to be translated]:支持模糊匹配(*.test.com,192.168.0.0/16)" }, "quickAssistant": { "click_tray_to_show": "Нажмите на иконку трея для запуска", diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index 3bfdd5968..6746444c4 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -233,7 +233,12 @@ const GeneralSettings: FC = () => { <> - {t('settings.proxy.bypass')} + + {t('settings.proxy.bypass')} + + + +