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..7e52b476a 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,66 @@ 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 + + /** + * 代理主机。 + * + * 只填主机名或 IP 地址,**不要带 `http://` 之类的协议前缀**:该值会直接传给 + * [InetSocketAddress],带前缀时整个字符串会被当作主机名去解析,得到未解析地址, + * 最终在连接阶段报出与代理配置看不出关联的错误。代理协议由 [type] 决定。 + */ + var host: String = "" + + /** + * 代理端口 + */ + var port: Int = 0 + + /** + * 返回用于创建连接的代理,未启用或配置不完整时返回 `null`。 + * + * [host] 与 [port] 直接用于构造 [InetSocketAddress],因此 [host] 必须是裸主机名或 IP。 + */ + 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..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 @@ -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,33 @@ 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 } + } + + /** + * 代理主机。使用 [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() { + 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 +263,30 @@ 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)) + + 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()) + } + val textSelectionPanel = titledPanel(message("settings.panel.title.text.selection"), true) { add(keepFormatCheckBox, wrap().span(4)) add(takeNearestWordCheckBox, wrap().span(4)) @@ -355,6 +407,7 @@ internal abstract class SettingsUi { wholePanel.addVertically( generalPanel, + proxyPanel, fontsPanel, textSelectionPanel, translationPopupPanel, @@ -365,6 +418,8 @@ internal abstract class SettingsUi { otherPanel, supportLinkLabel ) + + fixProxyComponentsEnabled() } fun createMainPanel(): JPanel { @@ -372,6 +427,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..a946f4b58 100644 --- a/src/main/resources/messages/TranslationBundle.properties +++ b/src/main/resources/messages/TranslationBundle.properties @@ -24,6 +24,14 @@ 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.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 settings.ali.title=Alibaba Translate Settings settings.translator.requires.configuration={0} requires configuration @@ -109,6 +117,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..506739502 100644 --- a/src/main/resources/messages/TranslationBundle_ja.properties +++ b/src/main/resources/messages/TranslationBundle_ja.properties @@ -24,6 +24,14 @@ 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.comment.proxy.host=ホスト名または IP アドレスのみを入力してください。http:// などのスキームは付けないでください。プロトコルは上で選択します。 +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 +117,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..d56a0b7a6 100644 --- a/src/main/resources/messages/TranslationBundle_ko.properties +++ b/src/main/resources/messages/TranslationBundle_ko.properties @@ -24,6 +24,14 @@ 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.comment.proxy.host=호스트 이름 또는 IP 주소만 입력하세요. http:// 같은 스킴은 붙이지 마세요. 프로토콜은 위에서 선택합니다. +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 +117,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..7b163a3e5 100644 --- a/src/main/resources/messages/TranslationBundle_zh_CN.properties +++ b/src/main/resources/messages/TranslationBundle_zh_CN.properties @@ -24,6 +24,14 @@ 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.comment.proxy.host=只填主机名或 IP 地址,不要加 http:// 这类协议前缀。代理协议由上方选择。 +settings.proxy.type.http=HTTP +settings.proxy.type.socks=SOCKS5 settings.baidu.title=百度翻译设置 settings.ali.title=阿里翻译设置 settings.translator.requires.configuration={0} 需要配置 @@ -109,6 +117,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