From 72c2020a8f97dea4d371e688228d6d1007132d11 Mon Sep 17 00:00:00 2001 From: houlixuan Date: Wed, 30 Sep 2026 15:34:07 +0800 Subject: [PATCH 1/2] Add a proxy setting for translation requests Google Translate, OpenAI and other engines cannot be reached directly in some networks. The plugin had no proxy configuration of its own and relied entirely on the IDE-wide HTTP proxy. Adds an opt-in global proxy (HTTP or SOCKS5) to the Translation settings. When disabled - the default - requests keep going through HttpRequests and therefore the IDE proxy, so existing behavior is unchanged. When enabled, the plugin opens connections itself through the configured proxy. com.intellij.util.io.RequestBuilder turns out to be subclassable with a single abstract `connect`, and HttpRequests.Request is a public interface, so the proxied path extends the platform builder rather than replacing the type. Call sites and the platform's setHeadersViaTuner() are left untouched. Defaults mirror HttpRequests$RequestBuilderImpl (10s connect, 60s read, gzip on, redirect limit 10) so that the proxied and platform paths behave the same. The Edge TTS WebSocket, which does not go through Http, honors the proxy too. Connection-level failures now hint at the proxy settings when one is configured. Co-Authored-By: Claude Code --- CHANGELOG.md | 3 + .../cn/yiiguxing/plugin/translate/Settings.kt | 61 ++++ .../trans/microsoft/BingTranslator.kt | 3 +- .../trans/microsoft/MicrosoftHttp.kt | 3 +- .../translate/trans/openai/OpenAiHttp.kt | 3 +- .../plugin/translate/tts/GoogleTTSPlayer.kt | 3 +- .../translate/tts/microsoft/EdgeTTSSource.kt | 2 + .../translate/ui/settings/SettingsPanel.kt | 37 +++ .../translate/ui/settings/SettingsUi.kt | 46 +++ .../plugin/translate/util/Exceptions.kt | 33 +- .../yiiguxing/plugin/translate/util/Http.kt | 310 +++++++++++++++++- .../translate/wordbook/WordBookService.kt | 3 +- .../messages/TranslationBundle.properties | 8 + .../messages/TranslationBundle_ja.properties | 8 + .../messages/TranslationBundle_ko.properties | 8 + .../TranslationBundle_zh_CN.properties | 8 + .../plugin/translate/ProxySettingsTest.kt | 120 +++++++ .../plugin/translate/util/ExceptionsTest.kt | 51 +++ .../plugin/translate/util/HttpProxyTest.kt | 150 +++++++++ 19 files changed, 834 insertions(+), 26 deletions(-) create mode 100644 src/test/kotlin/cn/yiiguxing/plugin/translate/ProxySettingsTest.kt create mode 100644 src/test/kotlin/cn/yiiguxing/plugin/translate/util/ExceptionsTest.kt create mode 100644 src/test/kotlin/cn/yiiguxing/plugin/translate/util/HttpProxyTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 171ba4cca..a9c2bed4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +- Added a proxy setting for translation requests. +- 新增翻译请求的代理设置 + ## [3.9.1] (2026/08/26) - Fixed the issue of cache conflicts in document translation for Google Translate. diff --git a/src/main/kotlin/cn/yiiguxing/plugin/translate/Settings.kt b/src/main/kotlin/cn/yiiguxing/plugin/translate/Settings.kt index cfb3ebf9c..c50e7a600 100644 --- a/src/main/kotlin/cn/yiiguxing/plugin/translate/Settings.kt +++ b/src/main/kotlin/cn/yiiguxing/plugin/translate/Settings.kt @@ -24,6 +24,8 @@ import com.intellij.util.xmlb.XmlSerializerUtil import com.intellij.util.xmlb.annotations.Tag import com.intellij.util.xmlb.annotations.Transient import org.jetbrains.concurrency.runAsync +import java.net.InetSocketAddress +import java.net.Proxy import kotlin.properties.Delegates /** @@ -186,6 +188,11 @@ class Settings : PersistentStateComponent { */ var translationWindowLocation: WindowLocation = WindowLocation.MOUSE_SCREEN + /** + * 网络代理 + */ + var proxySettings: ProxySettings = ProxySettings() + override fun getState(): Settings = this override fun loadState(state: Settings) { @@ -308,6 +315,60 @@ class BaiduTranslateSettings : AppKeySettings(BAIDU_APP_KEY) */ class AliTranslateSettings : AppKeySettings(ALI_APP_KEY) +/** + * 代理类型 + */ +enum class ProxyType(val displayName: String) { + HTTP(message("settings.proxy.type.http")), + SOCKS(message("settings.proxy.type.socks")) +} + +/** + * 网络代理选项 + */ +@Tag("proxy") +class ProxySettings { + + /** + * 是否启用代理 + */ + var enabled: Boolean = false + + /** + * 代理类型 + */ + var type: ProxyType = ProxyType.HTTP + + /** + * 代理主机 + */ + var host: String = "" + + /** + * 代理端口 + */ + var port: Int = 0 + + /** + * 返回用于创建连接的代理,未启用或配置不完整时返回 `null`。 + */ + fun toProxyOrNull(): Proxy? { + if (!enabled || host.isBlank() || port !in MIN_PORT..MAX_PORT) { + return null + } + val javaType = when (type) { + ProxyType.HTTP -> Proxy.Type.HTTP + ProxyType.SOCKS -> Proxy.Type.SOCKS + } + return Proxy(javaType, InetSocketAddress(host, port)) + } + + companion object { + private const val MIN_PORT = 1 + private const val MAX_PORT = 65535 + } +} + enum class TTSSource(val displayName: String) { ORIGINAL(message("settings.item.original")), TRANSLATION(message("settings.item.translation")) diff --git a/src/main/kotlin/cn/yiiguxing/plugin/translate/trans/microsoft/BingTranslator.kt b/src/main/kotlin/cn/yiiguxing/plugin/translate/trans/microsoft/BingTranslator.kt index 27604a0a3..b39b69d61 100644 --- a/src/main/kotlin/cn/yiiguxing/plugin/translate/trans/microsoft/BingTranslator.kt +++ b/src/main/kotlin/cn/yiiguxing/plugin/translate/trans/microsoft/BingTranslator.kt @@ -16,7 +16,6 @@ import cn.yiiguxing.plugin.translate.util.md5 import cn.yiiguxing.plugin.translate.util.splitSentence import cn.yiiguxing.plugin.translate.util.type import com.intellij.util.concurrency.annotations.RequiresBackgroundThread -import com.intellij.util.io.HttpRequests import kotlinx.coroutines.* import kotlin.text.any import kotlin.time.Duration.Companion.milliseconds @@ -68,7 +67,7 @@ internal class BingTranslator( object : AsyncExpiringData(scope) { override suspend fun load(): BingAuthentication { val html = withContext(Dispatchers.IO) { - HttpRequests.request(BING_TRANSLATOR_URL).setUserAgent().readString() + Http.newRequest(BING_TRANSLATOR_URL).setUserAgent().readString() } return parseBingAuthentication(html) } diff --git a/src/main/kotlin/cn/yiiguxing/plugin/translate/trans/microsoft/MicrosoftHttp.kt b/src/main/kotlin/cn/yiiguxing/plugin/translate/trans/microsoft/MicrosoftHttp.kt index 0e5e6e9ec..a759ec83a 100644 --- a/src/main/kotlin/cn/yiiguxing/plugin/translate/trans/microsoft/MicrosoftHttp.kt +++ b/src/main/kotlin/cn/yiiguxing/plugin/translate/trans/microsoft/MicrosoftHttp.kt @@ -14,7 +14,6 @@ import com.intellij.ide.plugins.marketplace.setHeadersViaTuner import com.intellij.openapi.diagnostic.Attachment import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.logger -import com.intellij.util.io.HttpRequests import com.intellij.util.io.RequestBuilder import java.lang.reflect.Type @@ -151,7 +150,7 @@ internal object MicrosoftHttp { builder: RequestBuilder.() -> Unit ): String { return try { - HttpRequests.post(url, contentType) + Http.newRequest(url, contentType) .accept(Http.MIME_TYPE_JSON) .apply(builder) .send(data) { it.readString() } diff --git a/src/main/kotlin/cn/yiiguxing/plugin/translate/trans/openai/OpenAiHttp.kt b/src/main/kotlin/cn/yiiguxing/plugin/translate/trans/openai/OpenAiHttp.kt index 70ed23994..c9fc33e15 100644 --- a/src/main/kotlin/cn/yiiguxing/plugin/translate/trans/openai/OpenAiHttp.kt +++ b/src/main/kotlin/cn/yiiguxing/plugin/translate/trans/openai/OpenAiHttp.kt @@ -9,7 +9,6 @@ import com.google.gson.JsonParseException import com.google.gson.annotations.SerializedName import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.logger -import com.intellij.util.io.HttpRequests import com.intellij.util.io.RequestBuilder internal object OpenAiHttp { @@ -27,7 +26,7 @@ internal object OpenAiHttp { fun post(url: String, block: RequestBuilder.() -> T): T { return try { block( - HttpRequests.post(url, Http.MIME_TYPE_JSON) + Http.newRequest(url, Http.MIME_TYPE_JSON) .accept(Http.MIME_TYPE_JSON) ) } catch (e: Http.StatusException) { diff --git a/src/main/kotlin/cn/yiiguxing/plugin/translate/tts/GoogleTTSPlayer.kt b/src/main/kotlin/cn/yiiguxing/plugin/translate/tts/GoogleTTSPlayer.kt index 71ea42235..b09996242 100644 --- a/src/main/kotlin/cn/yiiguxing/plugin/translate/tts/GoogleTTSPlayer.kt +++ b/src/main/kotlin/cn/yiiguxing/plugin/translate/tts/GoogleTTSPlayer.kt @@ -16,7 +16,6 @@ import cn.yiiguxing.plugin.translate.util.Observable import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.progress.EmptyProgressIndicator import com.intellij.openapi.project.Project -import com.intellij.util.io.HttpRequests import java.io.IOException import java.util.* @@ -66,7 +65,7 @@ class GoogleTTSPlayer private constructor( override fun onLoad(): ByteArray { val url = getTtsUrl(sentences[index++], lang) - val response: TextToSpeechResponse = HttpRequests.request(url) + val response: TextToSpeechResponse = Http.newRequest(url) .setUserAgent() .connect { Http.defaultGson.fromJson(it.getReader(indicator), TextToSpeechResponse::class.java) diff --git a/src/main/kotlin/cn/yiiguxing/plugin/translate/tts/microsoft/EdgeTTSSource.kt b/src/main/kotlin/cn/yiiguxing/plugin/translate/tts/microsoft/EdgeTTSSource.kt index 415862eb5..f344b9dfe 100644 --- a/src/main/kotlin/cn/yiiguxing/plugin/translate/tts/microsoft/EdgeTTSSource.kt +++ b/src/main/kotlin/cn/yiiguxing/plugin/translate/tts/microsoft/EdgeTTSSource.kt @@ -1,5 +1,6 @@ package cn.yiiguxing.plugin.translate.tts.microsoft +import cn.yiiguxing.plugin.translate.Settings import cn.yiiguxing.plugin.translate.trans.Lang import cn.yiiguxing.plugin.translate.tts.sound.source.PushablePlaybackSource import cn.yiiguxing.plugin.translate.util.Http @@ -50,6 +51,7 @@ internal class EdgeTTSSource( private val settings: EdgeTTSSettings by lazy { EdgeTTSSettings.instance() } init { + Settings.getInstance().proxySettings.toProxyOrNull()?.let { setProxy(it) } addHeader("Pragma", "no-cache") addHeader("Cache-Control", "no-cache") addHeader("Accept-Encoding", "gzip, deflate, br") diff --git a/src/main/kotlin/cn/yiiguxing/plugin/translate/ui/settings/SettingsPanel.kt b/src/main/kotlin/cn/yiiguxing/plugin/translate/ui/settings/SettingsPanel.kt index a6a94f52d..b3b2252b0 100644 --- a/src/main/kotlin/cn/yiiguxing/plugin/translate/ui/settings/SettingsPanel.kt +++ b/src/main/kotlin/cn/yiiguxing/plugin/translate/ui/settings/SettingsPanel.kt @@ -1,5 +1,6 @@ package cn.yiiguxing.plugin.translate.ui.settings +import cn.yiiguxing.plugin.translate.ProxyType import cn.yiiguxing.plugin.translate.Settings import cn.yiiguxing.plugin.translate.TTSSource import cn.yiiguxing.plugin.translate.TranslationStates @@ -241,8 +242,14 @@ internal class SettingsPanel( || settings.showActionsInContextMenuOnlyWithSelection != showActionsInContextMenuOnlyWithSelectionCheckbox.isSelected || states.maxHistorySize != maxHistoriesSizeComboBox.item || settings.translationWindowLocation != translationWindowLocationComboBox.selected + || settings.proxySettings.enabled != proxyCheckBox.isSelected + || settings.proxySettings.type != proxyTypeComboBox.selected + || settings.proxySettings.host != proxyHostField.text.trim() + || settings.proxySettings.port != proxyPort } + private val proxyPort: Int get() = proxyPortField.text.trim().toIntOrNull() ?: 0 + private fun getConfigurationPath(vararg configurations: String): String = configurations.joinToString("|") { it.trim(' ', '\n', ':', ':') } @@ -301,6 +308,29 @@ internal class SettingsPanel( ignoreRegex = this@SettingsPanel.ignoreRegExp.text translationWindowLocation = translationWindowLocationComboBox.selected ?: WindowLocation.MOUSE_SCREEN } + + applyProxySettings() + } + + private fun applyProxySettings() { + val enabled = proxyCheckBox.isSelected + val host = proxyHostField.text.trim() + val port = proxyPort + if (enabled && (host.isEmpty() || port !in 1..65535)) { + throw ConfigurationException( + message( + "settings.invalid.configuration", + getConfigurationPath(message("settings.panel.title.proxy")) + ) + ) + } + + with(settings.proxySettings) { + this.enabled = enabled + type = proxyTypeComboBox.selected ?: ProxyType.HTTP + this.host = host + this.port = port + } } private fun throwConfigurationException(name: String): Nothing { @@ -338,6 +368,13 @@ internal class SettingsPanel( takeWordCheckBox.isSelected = settings.takeWordWhenDialogOpens wordbookStoragePathField.text = settings.wordbookStoragePath ?: "" translationWindowLocationComboBox.selected = settings.translationWindowLocation + + with(settings.proxySettings) { + proxyCheckBox.isSelected = enabled + proxyTypeComboBox.selected = type + proxyHostField.text = host + proxyPortField.text = port.takeIf { it > 0 }?.toString().orEmpty() + } } companion object { diff --git a/src/main/kotlin/cn/yiiguxing/plugin/translate/ui/settings/SettingsUi.kt b/src/main/kotlin/cn/yiiguxing/plugin/translate/ui/settings/SettingsUi.kt index 65bf76010..e461567d4 100644 --- a/src/main/kotlin/cn/yiiguxing/plugin/translate/ui/settings/SettingsUi.kt +++ b/src/main/kotlin/cn/yiiguxing/plugin/translate/ui/settings/SettingsUi.kt @@ -1,5 +1,6 @@ package cn.yiiguxing.plugin.translate.ui.settings +import cn.yiiguxing.plugin.translate.ProxyType import cn.yiiguxing.plugin.translate.TTSSource import cn.yiiguxing.plugin.translate.TranslationStorages import cn.yiiguxing.plugin.translate.message @@ -201,6 +202,27 @@ internal abstract class SettingsUi { renderer = textListCellRenderer { it.displayName } } + protected val proxyCheckBox: JBCheckBox = JBCheckBox(message("settings.proxy.enabled")).apply { + addItemListener { fixProxyComponentsEnabled() } + } + + protected val proxyTypeComboBox: ComboBox = comboBox().apply { + renderer = textListCellRenderer { it.displayName } + } + + protected val proxyHostField: JTextField = JTextField() + + protected val proxyPortField: JTextField = JTextField().apply { + document = object : PlainDocument() { + override fun insertString(offset: Int, str: String?, attr: AttributeSet?) { + val digits = str?.filter(Char::isDigit)?.take(5) ?: return + if (digits.isNotEmpty()) { + super.insertString(offset, digits, attr) + } + } + } + } + protected val supportLinkLabel: LinkLabel<*> = LinkLabel(message("support.or.donate"), TranslationIcons.Support).apply { border = JBUI.Borders.emptyTop(20) @@ -235,6 +257,20 @@ internal abstract class SettingsUi { add(JLabel(message("settings.label.targetLanguage"))) add(targetLanguageComboBox, wrap().sizeGroupX(comboboxGroup)) } + val proxyPanel = titledPanel(message("settings.panel.title.proxy")) { + add(proxyCheckBox, wrap().span(2)) + + add(JLabel(message("settings.label.proxy.type"))) + add(proxyTypeComboBox, wrap()) + + add(JLabel(message("settings.label.proxy.host"))) + add(proxyHostField, fillX().wrap()) + setMinWidth(proxyHostField, JBUIScale.scale(250)) + + add(JLabel(message("settings.label.proxy.port"))) + add(proxyPortField, wrap()) + } + val textSelectionPanel = titledPanel(message("settings.panel.title.text.selection"), true) { add(keepFormatCheckBox, wrap().span(4)) add(takeNearestWordCheckBox, wrap().span(4)) @@ -355,6 +391,7 @@ internal abstract class SettingsUi { wholePanel.addVertically( generalPanel, + proxyPanel, fontsPanel, textSelectionPanel, translationPopupPanel, @@ -365,6 +402,8 @@ internal abstract class SettingsUi { otherPanel, supportLinkLabel ) + + fixProxyComponentsEnabled() } fun createMainPanel(): JPanel { @@ -372,6 +411,13 @@ internal abstract class SettingsUi { return wholePanel } + private fun fixProxyComponentsEnabled() { + val enabled = proxyCheckBox.isSelected + proxyTypeComboBox.isEnabled = enabled + proxyHostField.isEnabled = enabled + proxyPortField.isEnabled = enabled + } + private fun fixEngineConfigurationComponent() { configureTranslationEngineButton.isVisible = translationEngineComboBox.selected?.hasConfiguration ?: false } diff --git a/src/main/kotlin/cn/yiiguxing/plugin/translate/util/Exceptions.kt b/src/main/kotlin/cn/yiiguxing/plugin/translate/util/Exceptions.kt index d7a2d7008..4280c1d6b 100644 --- a/src/main/kotlin/cn/yiiguxing/plugin/translate/util/Exceptions.kt +++ b/src/main/kotlin/cn/yiiguxing/plugin/translate/util/Exceptions.kt @@ -1,5 +1,6 @@ package cn.yiiguxing.plugin.translate.util +import cn.yiiguxing.plugin.translate.Settings import cn.yiiguxing.plugin.translate.message import com.intellij.util.io.HttpRequests import io.netty.handler.codec.http.HttpResponseStatus @@ -10,8 +11,17 @@ import java.net.SocketTimeoutException import java.net.UnknownHostException import javax.net.ssl.SSLException -fun IOException.getCommonMessage(): String { - return when (this) { +fun IOException.getCommonMessage(): String = + getCommonMessage(Settings.getInstance().proxySettings.toProxyOrNull() != null) + +/** + * 与 [getCommonMessage] 相同,但显式接收"是否配置了代理"。 + * + * 配置了代理时在连接类错误后追加提示——否则用户看到"网络连接失败"根本不会想到是代理的问题。 + * 状态码错误([HttpRequests.HttpStatusException])是服务端返回的,与代理无关,不追加提示。 + */ +internal fun IOException.getCommonMessage(proxyConfigured: Boolean): String { + val commonMessage = when (this) { is ConnectException, is UnknownHostException -> message("error.network.connection") is SocketException, is SSLException -> message("error.network") is SocketTimeoutException -> message("error.network.timeout") @@ -25,4 +35,21 @@ fun IOException.getCommonMessage(): String { else -> message("error.io.exception", message ?: message("error.unknown")) } -} \ No newline at end of file + + return if (proxyConfigured && isConnectionFailure()) { + "$commonMessage ${message("error.network.proxy.hint")}" + } else { + commonMessage + } +} + +/** + * 只有连接层面的失败才可能与代理有关。状态码错误是服务端返回的,其他 I/O 错误也与代理无关—— + * 对这些情况追加代理提示只会误导用户。 + */ +private fun IOException.isConnectionFailure(): Boolean = + this is ConnectException || + this is UnknownHostException || + this is SocketException || + this is SSLException || + this is SocketTimeoutException \ No newline at end of file diff --git a/src/main/kotlin/cn/yiiguxing/plugin/translate/util/Http.kt b/src/main/kotlin/cn/yiiguxing/plugin/translate/util/Http.kt index 5203bf493..5e3edbe78 100644 --- a/src/main/kotlin/cn/yiiguxing/plugin/translate/util/Http.kt +++ b/src/main/kotlin/cn/yiiguxing/plugin/translate/util/Http.kt @@ -3,18 +3,32 @@ package cn.yiiguxing.plugin.translate.util import cn.yiiguxing.plugin.translate.RegistryKeys +import cn.yiiguxing.plugin.translate.Settings import cn.yiiguxing.plugin.translate.TranslationPlugin import com.google.gson.Gson +import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.util.SystemInfoRt import com.intellij.openapi.util.registry.RegistryManager import com.intellij.util.io.HttpRequests import com.intellij.util.io.RequestBuilder +import java.io.BufferedReader +import java.io.ByteArrayOutputStream +import java.io.File import java.io.IOException +import java.io.InputStream import java.io.InputStreamReader import java.lang.reflect.Type import java.net.HttpURLConnection +import java.net.Proxy import java.net.URL +import java.net.URLConnection +import java.nio.charset.Charset +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption import java.util.zip.GZIPInputStream +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.HttpsURLConnection object Http { @@ -41,8 +55,33 @@ object Http { val defaultGson = Gson() - inline fun get(url: String, init: RequestBuilder.() -> Unit = {}): String { - return HttpRequests.request(url) + /** + * 返回当前配置的代理。未启用代理时返回 `null`,此时请求交由平台的 [HttpRequests] + * 处理,即沿用 IDE 的全局代理设置。 + */ + private fun currentProxy(): Proxy? = Settings.getInstance().proxySettings.toProxyOrNull() + + /** + * 创建请求。代理为空时使用平台的 [HttpRequests],否则使用自建的、走指定代理的连接。 + * [contentType] 不为 `null` 时创建 POST 请求。 + */ + @PublishedApi + internal fun newRequest(url: String, contentType: String? = null): RequestBuilder = + newRequest(url, currentProxy(), contentType) + + internal fun newRequest(url: String, proxy: Proxy?, contentType: String? = null): RequestBuilder { + if (proxy == null) { + return if (contentType == null) HttpRequests.request(url) else HttpRequests.post(url, contentType) + } + return ProxiedRequestBuilder(url, contentType, proxy) + } + + fun get(url: String, init: RequestBuilder.() -> Unit = {}): String { + return get(url, currentProxy(), init) + } + + internal fun get(url: String, proxy: Proxy?, init: RequestBuilder.() -> Unit = {}): String { + return newRequest(url, proxy) .accept(MIME_TYPE_JSON) .apply(init) .readString() @@ -54,7 +93,7 @@ object Http { typeOfT: Type = T::class.java, init: RequestBuilder.() -> Unit = {} ): T { - return HttpRequests.request(url) + return newRequest(url) .accept(MIME_TYPE_JSON) .apply(init) .connect { gson.fromJson(it.reader, typeOfT) } @@ -108,9 +147,19 @@ object Http { url: String, contentType: String, data: String, - init: RequestBuilder.() -> Unit + init: RequestBuilder.() -> Unit = {} + ): String { + return post(url, currentProxy(), contentType, data, init) + } + + internal fun post( + url: String, + proxy: Proxy?, + contentType: String, + data: String, + init: RequestBuilder.() -> Unit = {} ): String { - return HttpRequests.post(url, contentType) + return newRequest(url, proxy, contentType) .accept(MIME_TYPE_JSON) .apply(init) .send(data) { it.readString() } @@ -120,7 +169,7 @@ object Http { val connection = connection as HttpURLConnection val responseCode = connection.responseCode if (responseCode >= 400) { - throw StatusException( + throw Http.StatusException( "Request failed with status code $responseCode", responseCode, url, @@ -210,7 +259,7 @@ object Http { } private fun SendResult.Redirect.rebuildRequest(): RequestBuilder { - return HttpRequests.post(url, contentType) + return newRequest(url, contentType) .accept(MIME_TYPE_JSON) .tuner { connection -> headers.forEach { (key, values) -> @@ -240,12 +289,6 @@ object Http { return send(defaultGson.toJson(data), dataReader) } - private fun HttpURLConnection.getErrorText(): String? { - val errorStream = errorStream ?: return null - val stream = if (contentEncoding == "gzip") GZIPInputStream(errorStream) else errorStream - return InputStreamReader(stream, Charsets.UTF_8).use { it.readText() } - } - private fun isVersionGreaterThanDefault(version: String): Boolean { val versionParts = version.split('.') for (index in versionParts.indices) { @@ -284,4 +327,245 @@ object Http { fun RequestBuilder.setUserAgent(): RequestBuilder = apply { userAgent(getUserAgent()) } fun RequestBuilder.pluginUserAgent(): RequestBuilder = apply { userAgent(PLUGIN_USER_AGENT) } +} + +/** + * 走插件指定代理的 [RequestBuilder]。 + * + * 平台的 [HttpRequests] 只能表达"用 / 不用 IDE 代理"(`HttpRequests$RequestImpl` 仅依据 + * `myUseProxy` 分叉),无法指定代理地址,因此这里自行创建连接。`RequestBuilder` 的 + * `readString` / `readBytes` / `saveToFile` 都由基类实现并回调 [connect],所以只需重写 + * [connect];其余配置方法退化为本地记录。 + */ +private class ProxiedRequestBuilder( + private val url: String, + private val contentType: String?, + private val proxy: Proxy +) : RequestBuilder() { + + // 默认值与平台的 HttpRequests$RequestBuilderImpl 保持一致,否则走代理与直连两条路径 + // 在超时、压缩、重定向上的行为会不同。 + private var accepted: String? = null + private var userAgent: String? = null + private var connectTimeout: Int = DEFAULT_CONNECT_TIMEOUT + private var readTimeout: Int = DEFAULT_READ_TIMEOUT + private var gzip: Boolean = true + private var redirectLimit: Int = DEFAULT_REDIRECT_LIMIT + private var forceHttps: Boolean = false + private var hostNameVerifier: HostnameVerifier? = null + private var tuner: HttpRequests.ConnectionTuner? = null + private var throwStatusCodeException: Boolean = true + + override fun accept(contentType: String?): RequestBuilder = apply { accepted = contentType } + + override fun userAgent(userAgent: String?): RequestBuilder = apply { this.userAgent = userAgent } + + override fun productNameAsUserAgent(): RequestBuilder = apply { userAgent = Http.PLUGIN_USER_AGENT } + + override fun connectTimeout(timeout: Int): RequestBuilder = apply { connectTimeout = timeout } + + override fun readTimeout(timeout: Int): RequestBuilder = apply { readTimeout = timeout } + + override fun gzip(gzip: Boolean): RequestBuilder = apply { this.gzip = gzip } + + override fun redirectLimit(limit: Int): RequestBuilder = apply { redirectLimit = limit } + + override fun forceHttps(forceHttps: Boolean): RequestBuilder = apply { this.forceHttps = forceHttps } + + override fun hostNameVerifier(verifier: HostnameVerifier?): RequestBuilder = + apply { hostNameVerifier = verifier } + + /** 请求始终使用插件配置的代理,忽略平台的代理开关。 */ + override fun useProxy(useProxy: Boolean): RequestBuilder = this + + override fun throwStatusCodeException(throwException: Boolean): RequestBuilder = + apply { throwStatusCodeException = throwException } + + override fun isReadResponseOnError(value: Boolean): RequestBuilder = this + + override fun tuner(tuner: HttpRequests.ConnectionTuner?): RequestBuilder = apply { this.tuner = tuner } + + override fun connect(processor: HttpRequests.RequestProcessor): T { + val connection = openConnection() + return try { + processor.process(ProxiedRequest(connection, throwStatusCodeException)) + } finally { + connection.disconnect() + } + } + + private fun openConnection(): HttpURLConnection { + val connection = URL(resolveUrl()).openConnection(proxy) as HttpURLConnection + connection.instanceFollowRedirects = redirectLimit > 0 + connection.useCaches = false + + if (connectTimeout > 0) { + connection.connectTimeout = connectTimeout + } + if (readTimeout > 0) { + connection.readTimeout = readTimeout + } + if (contentType != null) { + connection.requestMethod = "POST" + connection.doOutput = true + connection.setRequestProperty("Content-Type", contentType) + } + accepted?.let { connection.setRequestProperty("Accept", it) } + userAgent?.let { connection.setRequestProperty("User-Agent", it) } + if (gzip) { + connection.setRequestProperty("Accept-Encoding", "gzip") + } + if (connection is HttpsURLConnection) { + hostNameVerifier?.let { connection.hostnameVerifier = it } + } + tuner?.tune(connection) + + return connection + } + + private fun resolveUrl(): String = if (forceHttps && url.startsWith("http:")) { + "https:${url.substring("http:".length)}" + } else { + url + } + + private companion object { + /** 取自 `HttpRequests$RequestBuilderImpl.myConnectTimeout`。 */ + private const val DEFAULT_CONNECT_TIMEOUT = 10_000 + + /** 取自 `HttpRequests$RequestBuilderImpl.myTimeout`。 */ + private const val DEFAULT_READ_TIMEOUT = 60_000 + + /** 取自 `HttpRequests$RequestBuilderImpl.myRedirectLimit`。 */ + private const val DEFAULT_REDIRECT_LIMIT = 10 + } +} + +private fun HttpURLConnection.getErrorText(): String? { + val errorStream = errorStream ?: return null + val stream = if (contentEncoding?.contains("gzip", ignoreCase = true) == true) { + GZIPInputStream(errorStream) + } else { + errorStream + } + return InputStreamReader(stream, Charsets.UTF_8).use { it.readText() } +} + +/** + * [HttpRequests.Request] 的代理版本实现。多数方法直接委托给底层连接; + * `write` 由接口的默认实现提供。 + */ +private class ProxiedRequest( + private val connection: HttpURLConnection, + private val throwStatusCodeException: Boolean +) : HttpRequests.Request { + + override fun getConnection(): URLConnection = connection + + override fun getURL(): String = connection.url.toExternalForm() + + override fun getInputStream(): InputStream { + val stream = connection.inputStream + return if (connection.contentEncoding?.contains("gzip", ignoreCase = true) == true) { + GZIPInputStream(stream) + } else { + stream + } + } + + override fun getReader(): BufferedReader = getReader(null) + + override fun getReader(indicator: ProgressIndicator?): BufferedReader { + return BufferedReader(InputStreamReader(getInputStream(), getCharset())) + } + + override fun readString(indicator: ProgressIndicator?): String { + throwStatusCodeErrorIfNeeded() + return getReader(indicator).use { it.readText() } + } + + override fun readChars(indicator: ProgressIndicator?): CharSequence = readString(indicator) + + override fun readBytes(indicator: ProgressIndicator?): ByteArray { + throwStatusCodeErrorIfNeeded() + return getInputStream().use { input -> + val output = ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + indicator?.checkCanceled() + val count = input.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + } + output.toByteArray() + } + } + + override fun saveToFile(file: Path, indicator: ProgressIndicator?): Path = + saveToFile(file, indicator, false) + + override fun saveToFile(file: Path, indicator: ProgressIndicator?, append: Boolean): Path { + throwStatusCodeErrorIfNeeded() + file.parent?.let { Files.createDirectories(it) } + val options = if (append) { + arrayOf(StandardOpenOption.CREATE, StandardOpenOption.APPEND) + } else { + arrayOf(StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) + } + getInputStream().use { input -> + Files.newOutputStream(file, *options).use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + indicator?.checkCanceled() + val count = input.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + } + } + } + return file + } + + override fun readError(): String? { + val stream = connection.errorStream ?: return null + val charset = getCharset() + return stream.use { + val input = if (connection.contentEncoding?.contains("gzip", ignoreCase = true) == true) { + GZIPInputStream(it) + } else { + it + } + input.reader(charset).readText() + } + } + + /** + * 平台在响应码 >= 400 且未禁用状态码异常时抛出 [HttpRequests.HttpStatusException]。 + */ + private fun throwStatusCodeErrorIfNeeded() { + if (!throwStatusCodeException) { + return + } + val statusCode = connection.responseCode + if (statusCode >= 400) { + throw Http.StatusException( + "Request failed with status code $statusCode", + statusCode, + connection.url.toExternalForm(), + connection.responseMessage, + connection.getErrorText() + ) + } + } + + private fun getCharset(): Charset { + val contentType = connection.contentType ?: return Charsets.UTF_8 + return CHARSET_PATTERN.find(contentType)?.groupValues?.get(1)?.let { name -> + runCatching { Charset.forName(name.trim().trim('"')) }.getOrNull() + } ?: Charsets.UTF_8 + } + + private companion object { + private val CHARSET_PATTERN = Regex("charset=([^;]+)", RegexOption.IGNORE_CASE) + } } \ No newline at end of file diff --git a/src/main/kotlin/cn/yiiguxing/plugin/translate/wordbook/WordBookService.kt b/src/main/kotlin/cn/yiiguxing/plugin/translate/wordbook/WordBookService.kt index ec739d10e..c77c70186 100644 --- a/src/main/kotlin/cn/yiiguxing/plugin/translate/wordbook/WordBookService.kt +++ b/src/main/kotlin/cn/yiiguxing/plugin/translate/wordbook/WordBookService.kt @@ -19,7 +19,6 @@ import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task import com.intellij.openapi.util.SystemInfo -import com.intellij.util.io.HttpRequests import org.apache.commons.dbutils.QueryRunner import org.apache.commons.dbutils.ResultSetHandler import org.jetbrains.concurrency.runAsync @@ -286,7 +285,7 @@ class WordBookService : Disposable { val tempFile = Files.createTempFile(TranslationStorages.DATA_DIRECTORY, "download.", ".tmp") downloadedFile = tempFile - HttpRequests.request(DRIVER_FILE_URL).saveToFile(tempFile.toFile(), indicator) + Http.newRequest(DRIVER_FILE_URL).saveToFile(tempFile.toFile(), indicator) indicator.checkCanceled() indicator.fraction = 1.0 indicator.isIndeterminate = true diff --git a/src/main/resources/messages/TranslationBundle.properties b/src/main/resources/messages/TranslationBundle.properties index d1f3aec99..3ee40304a 100644 --- a/src/main/resources/messages/TranslationBundle.properties +++ b/src/main/resources/messages/TranslationBundle.properties @@ -24,6 +24,13 @@ settings.panel.title.word.of.the.day=Word of the Day settings.panel.title.word.book=Word Book settings.panel.title.cacheAndHistory=Cache and History settings.panel.title.other=Other +settings.panel.title.proxy=Proxy +settings.proxy.enabled=Use proxy +settings.label.proxy.type=Type: +settings.label.proxy.host=Host: +settings.label.proxy.port=Port: +settings.proxy.type.http=HTTP +settings.proxy.type.socks=SOCKS5 settings.baidu.title=Baidu Translate Settings settings.ali.title=Alibaba Translate Settings settings.translator.requires.configuration={0} requires configuration @@ -109,6 +116,7 @@ error.network=Network error error.network.connection=Connection failed error.network.timeout=Connection timeout error.io.exception=I/O exception: {0} +error.network.proxy.hint=Check your proxy settings. error.service.unavailable=The service is currently unavailable. Please try again later error.language.unsupported=Unsupported language error.request.timeout=Request timeout diff --git a/src/main/resources/messages/TranslationBundle_ja.properties b/src/main/resources/messages/TranslationBundle_ja.properties index 4f7e09891..22625ffeb 100644 --- a/src/main/resources/messages/TranslationBundle_ja.properties +++ b/src/main/resources/messages/TranslationBundle_ja.properties @@ -24,6 +24,13 @@ settings.panel.title.word.of.the.day=今日の単語 settings.panel.title.word.book=単語帳 settings.panel.title.cacheAndHistory=キャッシュと履歴 settings.panel.title.other=その他 +settings.panel.title.proxy=プロキシ +settings.proxy.enabled=プロキシを使用 +settings.label.proxy.type=種類: +settings.label.proxy.host=ホスト: +settings.label.proxy.port=ポート: +settings.proxy.type.http=HTTP +settings.proxy.type.socks=SOCKS5 settings.baidu.title=Baidu 翻訳設定 settings.ali.title=Alibaba 翻訳設定 settings.translator.requires.configuration={0} は構成が必要です @@ -109,6 +116,7 @@ error.network=ネットワークエラー error.network.connection=接続に失敗しました error.network.timeout=接続タイムアウト error.io.exception=I/O例外: {0} +error.network.proxy.hint=プロキシ設定を確認してください。 error.service.unavailable=サービスは現在利用できません。後でもう一度お試しください。 error.language.unsupported=サポート対象外の言語 error.request.timeout=リクエストがタイムアウトしました diff --git a/src/main/resources/messages/TranslationBundle_ko.properties b/src/main/resources/messages/TranslationBundle_ko.properties index 3c0774a28..0186a4201 100644 --- a/src/main/resources/messages/TranslationBundle_ko.properties +++ b/src/main/resources/messages/TranslationBundle_ko.properties @@ -24,6 +24,13 @@ settings.panel.title.word.of.the.day=오늘의 단어 settings.panel.title.word.book=단어장 settings.panel.title.cacheAndHistory=캐시 및 기록 settings.panel.title.other=기타 +settings.panel.title.proxy=프록시 +settings.proxy.enabled=프록시 사용 +settings.label.proxy.type=유형: +settings.label.proxy.host=호스트: +settings.label.proxy.port=포트: +settings.proxy.type.http=HTTP +settings.proxy.type.socks=SOCKS5 settings.baidu.title=Baidu 번역 설정 settings.ali.title=Alibaba 번역 설정 settings.translator.requires.configuration={0}에는 구성이 필요합니다 @@ -109,6 +116,7 @@ error.network=네트워크 오류 error.network.connection=연결 실패 error.network.timeout=연결 시간 제한 error.io.exception=입출력 예외: {0} +error.network.proxy.hint=프록시 설정을 확인하세요. error.service.unavailable=현재 서비스를 사용할 수 없습니다. 나중에 다시 시도하세요. error.language.unsupported=지원되지 않는 언어 error.request.timeout=요청 시간 초과 diff --git a/src/main/resources/messages/TranslationBundle_zh_CN.properties b/src/main/resources/messages/TranslationBundle_zh_CN.properties index 491ec9ac5..b2e4bfa01 100644 --- a/src/main/resources/messages/TranslationBundle_zh_CN.properties +++ b/src/main/resources/messages/TranslationBundle_zh_CN.properties @@ -24,6 +24,13 @@ settings.panel.title.word.of.the.day=每日单词 settings.panel.title.word.book=单词本 settings.panel.title.cacheAndHistory=缓存和历史记录 settings.panel.title.other=其他 +settings.panel.title.proxy=代理 +settings.proxy.enabled=使用代理 +settings.label.proxy.type=协议: +settings.label.proxy.host=主机: +settings.label.proxy.port=端口: +settings.proxy.type.http=HTTP +settings.proxy.type.socks=SOCKS5 settings.baidu.title=百度翻译设置 settings.ali.title=阿里翻译设置 settings.translator.requires.configuration={0} 需要配置 @@ -109,6 +116,7 @@ error.network=网络错误 error.network.connection=网络连接失败 error.network.timeout=网络连接超时 error.io.exception=I/O异常: {0} +error.network.proxy.hint=请检查代理设置。 error.service.unavailable=当前服务不可用,请稍候再试 error.language.unsupported=不支持的语言 error.request.timeout=网络请求超时 diff --git a/src/test/kotlin/cn/yiiguxing/plugin/translate/ProxySettingsTest.kt b/src/test/kotlin/cn/yiiguxing/plugin/translate/ProxySettingsTest.kt new file mode 100644 index 000000000..17d4c0d33 --- /dev/null +++ b/src/test/kotlin/cn/yiiguxing/plugin/translate/ProxySettingsTest.kt @@ -0,0 +1,120 @@ +package cn.yiiguxing.plugin.translate + +import com.intellij.util.xmlb.XmlSerializer +import org.junit.Assert +import org.junit.Test +import java.net.Proxy + +class ProxySettingsTest { + + @Test + fun `test defaults produce no proxy`() { + val settings = ProxySettings() + Assert.assertFalse("Proxy must be disabled by default", settings.enabled) + Assert.assertEquals("Default type", ProxyType.HTTP, settings.type) + Assert.assertEquals("Default host", "", settings.host) + Assert.assertEquals("Default port", 0, settings.port) + Assert.assertNull("Default settings must not yield a proxy", settings.toProxyOrNull()) + } + + @Test + fun `test disabled yields no proxy even when address is valid`() { + val settings = ProxySettings().apply { + enabled = false + host = "127.0.0.1" + port = 7890 + } + Assert.assertNull("Disabled settings must not yield a proxy", settings.toProxyOrNull()) + } + + @Test + fun `test blank host yields no proxy`() { + val settings = ProxySettings().apply { + enabled = true + host = " " + port = 7890 + } + Assert.assertNull("Blank host must not yield a proxy", settings.toProxyOrNull()) + } + + @Test + fun `test port out of range yields no proxy`() { + for (port in listOf(-1, 0, 65536, Int.MAX_VALUE)) { + val settings = ProxySettings().apply { + enabled = true + host = "127.0.0.1" + this.port = port + } + Assert.assertNull("Port $port must not yield a proxy", settings.toProxyOrNull()) + } + } + + @Test + fun `test port boundaries yield a proxy`() { + for (port in listOf(1, 65535)) { + val settings = ProxySettings().apply { + enabled = true + host = "127.0.0.1" + this.port = port + } + Assert.assertNotNull("Port $port must yield a proxy", settings.toProxyOrNull()) + } + } + + @Test + fun `test http type maps to proxy type http`() { + val proxy = ProxySettings().apply { + enabled = true + type = ProxyType.HTTP + host = "127.0.0.1" + port = 7890 + }.toProxyOrNull() + + Assert.assertNotNull("Proxy", proxy) + Assert.assertEquals("Proxy type", Proxy.Type.HTTP, proxy!!.type()) + } + + @Test + fun `test socks type maps to proxy type socks`() { + val proxy = ProxySettings().apply { + enabled = true + type = ProxyType.SOCKS + host = "127.0.0.1" + port = 1080 + }.toProxyOrNull() + + Assert.assertNotNull("Proxy", proxy) + Assert.assertEquals("Proxy type", Proxy.Type.SOCKS, proxy!!.type()) + } + + @Test + fun `test address is passed through`() { + val proxy = ProxySettings().apply { + enabled = true + host = "127.0.0.1" + port = 7890 + }.toProxyOrNull() + + Assert.assertNotNull("Proxy", proxy) + val address = proxy!!.address() as java.net.InetSocketAddress + Assert.assertEquals("Proxy host", "127.0.0.1", address.hostString) + Assert.assertEquals("Proxy port", 7890, address.port) + } + + @Test + fun `test proxy settings survive xml serialization`() { + val original = ProxySettings().apply { + enabled = true + type = ProxyType.SOCKS + host = "127.0.0.1" + port = 7897 + } + + val restored = XmlSerializer.deserialize(XmlSerializer.serialize(original), ProxySettings::class.java) + + Assert.assertTrue("enabled", restored.enabled) + Assert.assertEquals("type", ProxyType.SOCKS, restored.type) + Assert.assertEquals("host", "127.0.0.1", restored.host) + Assert.assertEquals("port", 7897, restored.port) + } +} diff --git a/src/test/kotlin/cn/yiiguxing/plugin/translate/util/ExceptionsTest.kt b/src/test/kotlin/cn/yiiguxing/plugin/translate/util/ExceptionsTest.kt new file mode 100644 index 000000000..602e74654 --- /dev/null +++ b/src/test/kotlin/cn/yiiguxing/plugin/translate/util/ExceptionsTest.kt @@ -0,0 +1,51 @@ +package cn.yiiguxing.plugin.translate.util + +import org.junit.Assert +import org.junit.Test +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +/** + * The assertions are deliberately locale-independent: the bundle resolves against the + * JVM's default locale, so they check the shape of the message rather than its wording. + */ +class ExceptionsTest { + + @Test + fun `test connection failure appends a hint when a proxy is configured`() { + for (error in listOf( + ConnectException("Connection refused"), + UnknownHostException("example.invalid"), + SocketTimeoutException("Read timed out") + )) { + val withProxy = error.getCommonMessage(proxyConfigured = true) + val withoutProxy = error.getCommonMessage(proxyConfigured = false) + Assert.assertTrue( + "${error::class.java.simpleName} should append a hint, but was: $withProxy", + withProxy != withoutProxy && withProxy.startsWith(withoutProxy) + ) + } + } + + @Test + fun `test status errors never append a hint`() { + assertNoHint(Http.StatusException("Too Many Requests", 429, "http://example.invalid/", null, null)) + } + + @Test + fun `test unrelated io errors never append a hint`() { + assertNoHint(IOException("Something else went wrong")) + } + + private fun assertNoHint(error: IOException) { + val withProxy = error.getCommonMessage(proxyConfigured = true) + val withoutProxy = error.getCommonMessage(proxyConfigured = false) + Assert.assertEquals( + "${error::class.java.simpleName} should not mention the proxy", + withoutProxy, + withProxy + ) + } +} \ No newline at end of file diff --git a/src/test/kotlin/cn/yiiguxing/plugin/translate/util/HttpProxyTest.kt b/src/test/kotlin/cn/yiiguxing/plugin/translate/util/HttpProxyTest.kt new file mode 100644 index 000000000..1c64cfb88 --- /dev/null +++ b/src/test/kotlin/cn/yiiguxing/plugin/translate/util/HttpProxyTest.kt @@ -0,0 +1,150 @@ +package cn.yiiguxing.plugin.translate.util + +import org.junit.Assert +import org.junit.Test +import java.io.BufferedReader +import java.io.Closeable +import java.io.InputStreamReader +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.ServerSocket +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread + +/** + * Verifies that [Http] routes requests through a plugin-configured proxy instead of + * connecting to the target host directly. + * + * The target host is `example.invalid`, which never resolves, so a request that reached + * the origin directly could not succeed. For plain HTTP an HTTP proxy is addressed with + * an absolute-form request line, so the recorded request line itself proves the proxy + * was used. + */ +class HttpProxyTest { + + @Test + fun `test get is routed through the configured proxy`() { + FakeProxy().use { proxy -> + val result = Http.get(TARGET_URL, proxy.proxy) + + Assert.assertEquals("Response body", "hello", result) + val line = proxy.requestLine.get() + Assert.assertNotNull("The proxy must receive the request", line) + Assert.assertTrue( + "The proxy must receive an absolute-form request line, but was: $line", + line!!.startsWith("GET ") && line.contains(TARGET_URL) + ) + } + } + + @Test + fun `test post body is sent through the configured proxy`() { + FakeProxy().use { proxy -> + val result = Http.post(TARGET_URL, proxy.proxy, Http.MIME_TYPE_FORM, "key=value") + + Assert.assertEquals("Response body", "hello", result) + val line = proxy.requestLine.get() + Assert.assertNotNull("The proxy must receive the request", line) + Assert.assertTrue( + "The proxy must receive an absolute-form POST request line, but was: $line", + line!!.startsWith("POST ") && line.contains(TARGET_URL) + ) + Assert.assertEquals("Request body", "key=value", proxy.requestBody.get()) + } + } + + @Test + fun `test requests ask for gzip by default like the platform builder does`() { + // HttpRequests$RequestBuilderImpl defaults myGzip to true. The proxied path must match, + // otherwise the same request behaves differently depending on whether a proxy is set. + FakeProxy().use { proxy -> + Http.get(TARGET_URL, proxy.proxy) + + val headers = proxy.requestHeaders.get() + Assert.assertTrue( + "Should send Accept-Encoding: gzip by default, but headers were: $headers", + headers.any { it.startsWith("Accept-Encoding:", ignoreCase = true) && it.contains("gzip") } + ) + } + } + + /** + * A minimal HTTP proxy: records the request line, headers and body, then answers with a + * canned response. It never forwards anything, which is fine because the assertions only + * care that the request arrived here rather than at the (nonexistent) origin. + */ + private class FakeProxy : Closeable { + + private val server = ServerSocket(0) + + val requestLine = AtomicReference(null) + val requestBody = AtomicReference(null) + val requestHeaders = AtomicReference>(emptyList()) + + private val serverThread = thread(isDaemon = true, name = "fake-proxy") { serve() } + + val proxy: Proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress("127.0.0.1", server.localPort)) + + override fun close() { + server.close() + serverThread.join(5_000) + } + + private fun serve() { + try { + server.accept().use { socket -> + val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.ISO_8859_1)) + requestLine.set(reader.readLine()) + requestBody.set(readBody(reader)) + + val body = BODY + val response = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Length: ${body.toByteArray(Charsets.UTF_8).size}\r\n" + + "Connection: close\r\n" + + "\r\n" + + body + socket.getOutputStream().apply { + write(response.toByteArray(Charsets.ISO_8859_1)) + flush() + } + } + } catch (_: Throwable) { + // The test is over and the server socket was closed. + } + } + + private fun readBody(reader: BufferedReader): String? { + val headers = mutableListOf() + var contentLength = 0 + while (true) { + val line = reader.readLine() + if (line.isNullOrEmpty()) break + headers += line + if (line.startsWith("Content-Length:", ignoreCase = true)) { + contentLength = line.substringAfter(':').trim().toIntOrNull() ?: 0 + } + } + requestHeaders.set(headers) + if (contentLength <= 0) { + return null + } + val buffer = CharArray(contentLength) + var read = 0 + while (read < contentLength) { + val count = reader.read(buffer, read, contentLength - read) + if (count < 0) break + read += count + } + return String(buffer, 0, read) + } + + private companion object { + const val BODY = "hello" + } + } + + private companion object { + const val TARGET_URL = "http://example.invalid/test" + } +} \ No newline at end of file From 72433726a72922ec632a72ba3a0c9b1ea4e8ea4b Mon Sep 17 00:00:00 2001 From: houlixuan Date: Wed, 30 Sep 2026 15:55:54 +0800 Subject: [PATCH 2/2] Document the expected proxy host format The host field feeds straight into InetSocketAddress, so a value like "http://127.0.0.1" is resolved as a hostname, yields an unresolved address and fails later with an error that gives no hint about the proxy configuration. The proxy protocol is picked by the type combo box, not by a URL scheme. Documents this in three places: a KDoc note on ProxySettings.host and toProxyOrNull(), a placeholder on the host field showing "127.0.0.1", and a comment under the field in the settings panel. Co-Authored-By: Claude Code --- .../cn/yiiguxing/plugin/translate/Settings.kt | 8 +++++++- .../plugin/translate/ui/settings/SettingsUi.kt | 18 +++++++++++++++++- .../messages/TranslationBundle.properties | 1 + .../messages/TranslationBundle_ja.properties | 1 + .../messages/TranslationBundle_ko.properties | 1 + .../TranslationBundle_zh_CN.properties | 1 + 6 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/cn/yiiguxing/plugin/translate/Settings.kt b/src/main/kotlin/cn/yiiguxing/plugin/translate/Settings.kt index c50e7a600..7e52b476a 100644 --- a/src/main/kotlin/cn/yiiguxing/plugin/translate/Settings.kt +++ b/src/main/kotlin/cn/yiiguxing/plugin/translate/Settings.kt @@ -340,7 +340,11 @@ class ProxySettings { var type: ProxyType = ProxyType.HTTP /** - * 代理主机 + * 代理主机。 + * + * 只填主机名或 IP 地址,**不要带 `http://` 之类的协议前缀**:该值会直接传给 + * [InetSocketAddress],带前缀时整个字符串会被当作主机名去解析,得到未解析地址, + * 最终在连接阶段报出与代理配置看不出关联的错误。代理协议由 [type] 决定。 */ var host: String = "" @@ -351,6 +355,8 @@ class ProxySettings { /** * 返回用于创建连接的代理,未启用或配置不完整时返回 `null`。 + * + * [host] 与 [port] 直接用于构造 [InetSocketAddress],因此 [host] 必须是裸主机名或 IP。 */ fun toProxyOrNull(): Proxy? { if (!enabled || host.isBlank() || port !in MIN_PORT..MAX_PORT) { diff --git a/src/main/kotlin/cn/yiiguxing/plugin/translate/ui/settings/SettingsUi.kt b/src/main/kotlin/cn/yiiguxing/plugin/translate/ui/settings/SettingsUi.kt index e461567d4..e365e1a2a 100644 --- a/src/main/kotlin/cn/yiiguxing/plugin/translate/ui/settings/SettingsUi.kt +++ b/src/main/kotlin/cn/yiiguxing/plugin/translate/ui/settings/SettingsUi.kt @@ -210,7 +210,13 @@ internal abstract class SettingsUi { renderer = textListCellRenderer { it.displayName } } - protected val proxyHostField: JTextField = JTextField() + /** + * 代理主机。使用 [JBTextField] 的占位提示来展示期望的格式:这里只接受裸主机名或 IP, + * 带 `http://` 前缀会让 [java.net.InetSocketAddress] 解析失败。 + */ + protected val proxyHostField: JBTextField = JBTextField().apply { + emptyText.text = "127.0.0.1" + } protected val proxyPortField: JTextField = JTextField().apply { document = object : PlainDocument() { @@ -267,6 +273,16 @@ internal abstract class SettingsUi { add(proxyHostField, fillX().wrap()) setMinWidth(proxyHostField, JBUIScale.scale(250)) + val hostComment = ComponentPanelBuilder.createCommentComponent( + message("settings.comment.proxy.host"), true + ) + val hostCommentCC = fillX() + .gapBefore(JBUIScale.scale(2).toString()) + .gapTop(JBUIScale.scale(2).toString()) + .span(2) + .wrap() + add(hostComment, hostCommentCC) + add(JLabel(message("settings.label.proxy.port"))) add(proxyPortField, wrap()) } diff --git a/src/main/resources/messages/TranslationBundle.properties b/src/main/resources/messages/TranslationBundle.properties index 3ee40304a..a946f4b58 100644 --- a/src/main/resources/messages/TranslationBundle.properties +++ b/src/main/resources/messages/TranslationBundle.properties @@ -29,6 +29,7 @@ settings.proxy.enabled=Use proxy settings.label.proxy.type=Type: settings.label.proxy.host=Host: settings.label.proxy.port=Port: +settings.comment.proxy.host=Host name or IP address only. Do not prefix it with a scheme like http:// - the proxy protocol is selected above. settings.proxy.type.http=HTTP settings.proxy.type.socks=SOCKS5 settings.baidu.title=Baidu Translate Settings diff --git a/src/main/resources/messages/TranslationBundle_ja.properties b/src/main/resources/messages/TranslationBundle_ja.properties index 22625ffeb..506739502 100644 --- a/src/main/resources/messages/TranslationBundle_ja.properties +++ b/src/main/resources/messages/TranslationBundle_ja.properties @@ -29,6 +29,7 @@ settings.proxy.enabled=プロキシを使用 settings.label.proxy.type=種類: settings.label.proxy.host=ホスト: settings.label.proxy.port=ポート: +settings.comment.proxy.host=ホスト名または IP アドレスのみを入力してください。http:// などのスキームは付けないでください。プロトコルは上で選択します。 settings.proxy.type.http=HTTP settings.proxy.type.socks=SOCKS5 settings.baidu.title=Baidu 翻訳設定 diff --git a/src/main/resources/messages/TranslationBundle_ko.properties b/src/main/resources/messages/TranslationBundle_ko.properties index 0186a4201..d56a0b7a6 100644 --- a/src/main/resources/messages/TranslationBundle_ko.properties +++ b/src/main/resources/messages/TranslationBundle_ko.properties @@ -29,6 +29,7 @@ settings.proxy.enabled=프록시 사용 settings.label.proxy.type=유형: settings.label.proxy.host=호스트: settings.label.proxy.port=포트: +settings.comment.proxy.host=호스트 이름 또는 IP 주소만 입력하세요. http:// 같은 스킴은 붙이지 마세요. 프로토콜은 위에서 선택합니다. settings.proxy.type.http=HTTP settings.proxy.type.socks=SOCKS5 settings.baidu.title=Baidu 번역 설정 diff --git a/src/main/resources/messages/TranslationBundle_zh_CN.properties b/src/main/resources/messages/TranslationBundle_zh_CN.properties index b2e4bfa01..7b163a3e5 100644 --- a/src/main/resources/messages/TranslationBundle_zh_CN.properties +++ b/src/main/resources/messages/TranslationBundle_zh_CN.properties @@ -29,6 +29,7 @@ settings.proxy.enabled=使用代理 settings.label.proxy.type=协议: settings.label.proxy.host=主机: settings.label.proxy.port=端口: +settings.comment.proxy.host=只填主机名或 IP 地址,不要加 http:// 这类协议前缀。代理协议由上方选择。 settings.proxy.type.http=HTTP settings.proxy.type.socks=SOCKS5 settings.baidu.title=百度翻译设置