From d4f7e39834dd9b807056c95c041a9f557927fe0b Mon Sep 17 00:00:00 2001 From: yumoqing Date: Wed, 20 May 2026 08:18:58 +0800 Subject: [PATCH] fix: ensure _webbricks_=1 is sent for all backend resource paths including /public - isWebBricksBackendResource() now matches /public and / as WebBricks backend resources - BricksHttp.kt switched from manual URL query string to Ktor parameter() API - Added test/generic-client/build.sh for one-click build/run/package --- .../kotlin/com/bricks/mp/core/BricksHttp.kt | 56 +++++++------------ .../bricks/mp/core/WebBricksRequestContext.kt | 15 ++++- test/generic-client/build.sh | 37 ++++++++++++ 3 files changed, 70 insertions(+), 38 deletions(-) create mode 100755 test/generic-client/build.sh diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksHttp.kt b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksHttp.kt index 83f8213..3bb5c60 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksHttp.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksHttp.kt @@ -4,15 +4,16 @@ import com.bricks.mp.dev.DevHttpInterceptor import com.bricks.mp.dev.DevLogSource import com.bricks.mp.dev.DevLogLevel import com.bricks.mp.dev.DevLogStore -import kotlinx.datetime.Clock import io.ktor.client.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.cookies.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* -import io.ktor.http.encodeURLParameter +import kotlinx.datetime.Clock import kotlinx.serialization.json.* +import kotlin.time.DurationUnit.MILLISECONDS +import kotlin.time.toDuration /** * HTTP error surfaced by BricksHttp before the response body is parsed. @@ -37,6 +38,9 @@ class BricksHttpException( * Bricks HTTP 客户端 - 对应 JS 版 HttpJson/HttpText * 支持 session cookie 管理和 Bearer token 认证,并将 .ui/.dspy 请求自动追加 * WebBricks 参数:_webbricks_、_width、_height、_is_mobile、_lang。 + * + * 所有请求统一使用 Ktor 的 HttpRequestBuilder.parameter() 添加查询参数, + * 而非手动拼接 URL 字符串,确保编码和传递可靠。 */ class BricksHttp(private val context: BricksContext? = null) { @@ -106,12 +110,12 @@ class BricksHttp(private val context: BricksContext? = null) { authToken: String = "" ): JsonObject { val requestParams = params.withBackendContextIfNeeded(url) - val requestUrl = url.withQueryParameters(requestParams) val requestBodyStr = Json { encodeDefaults = false }.encodeToString(JsonObject.serializer(), body) - val devEntry = DevHttpInterceptor.recordRequest(requestUrl, "POST", requestBodyStr) + val devEntry = DevHttpInterceptor.recordRequest(url, "POST", requestBodyStr) val startTime = Clock.System.now() return try { - val response = client.post(requestUrl) { + val response = client.post(url) { + requestParams.forEach { (key, value) -> parameter(key, value) } contentType(ContentType.Application.Json) setBody(body) if (authToken.isNotEmpty()) { @@ -121,7 +125,7 @@ class BricksHttp(private val context: BricksContext? = null) { val text = response.bodyAsText() val duration = Clock.System.now() - startTime DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds) - response.throwIfHttpError(text, requestUrl) + response.throwIfHttpError(text, url) parseJsonObjectOrError(text) } catch (e: BricksHttpException) { val duration = Clock.System.now() - startTime @@ -143,16 +147,13 @@ class BricksHttp(private val context: BricksContext? = null) { authToken: String = "" ): String { val requestParams = emptyMap().withBackendContextIfNeeded(url) - val requestUrl = url.withQueryParameters(requestParams) - val formBody = form.entries.joinToString("&") { (k, v) -> - "${k.encodeURLParameter()}=${v.encodeURLParameter()}" - } - val devEntry = DevHttpInterceptor.recordRequest(requestUrl, "POST", formBody) + val devEntry = DevHttpInterceptor.recordRequest(url, "POST", form.entries.joinToString("&") { "${it.key}=${it.value}" }) val startTime = Clock.System.now() return try { - val response = client.post(requestUrl) { + val response = client.post(url) { + requestParams.forEach { (key, value) -> parameter(key, value) } contentType(ContentType.Application.FormUrlEncoded) - setBody(formBody) + setBody(Parameters.build { form.forEach { (k, v) -> append(k, v) } }) if (authToken.isNotEmpty()) { header(HttpHeaders.Authorization, "Bearer $authToken") } @@ -160,7 +161,7 @@ class BricksHttp(private val context: BricksContext? = null) { val text = response.bodyAsText() val duration = Clock.System.now() - startTime DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds) - response.throwIfHttpError(text, requestUrl) + response.throwIfHttpError(text, url) text } catch (e: BricksHttpException) { val duration = Clock.System.now() - startTime @@ -183,11 +184,11 @@ class BricksHttp(private val context: BricksContext? = null) { skipLog: Boolean = false ): String { val requestParams = params.withBackendContextIfNeeded(url) - val requestUrl = url.withQueryParameters(requestParams) - val devEntry = if (skipLog) null else DevHttpInterceptor.recordRequest(requestUrl, "GET") + val devEntry = if (skipLog) null else DevHttpInterceptor.recordRequest(url, "GET") val startTime = Clock.System.now() return try { - val response = client.get(requestUrl) { + val response = client.get(url) { + requestParams.forEach { (key, value) -> parameter(key, value) } if (authToken.isNotEmpty()) { header(HttpHeaders.Authorization, "Bearer $authToken") } @@ -197,7 +198,7 @@ class BricksHttp(private val context: BricksContext? = null) { if (devEntry != null) { DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds) } - response.throwIfHttpError(text, requestUrl) + response.throwIfHttpError(text, url) text } catch (e: BricksHttpException) { val duration = Clock.System.now() - startTime @@ -259,25 +260,6 @@ class BricksHttp(private val context: BricksContext? = null) { private fun Map.withBackendContextIfNeeded(url: String): Map = if (url.isWebBricksBackendResource()) withWebBricksRequestContext(this, requestContext) else this - private fun String.withQueryParameters(params: Map): String { - if (params.isEmpty()) return this - val fragmentIndex = indexOf('#') - val baseAndQuery = if (fragmentIndex >= 0) substring(0, fragmentIndex) else this - val fragment = if (fragmentIndex >= 0) substring(fragmentIndex) else "" - val path = baseAndQuery.substringBefore('?') - val existingQuery = baseAndQuery.substringAfter('?', missingDelimiterValue = "") - val encodedOverrideKeys = params.keys.map { it.encodeURLParameter() }.toSet() - val preservedQuery = existingQuery - .split('&') - .filter { it.isNotBlank() } - .filter { entry -> entry.substringBefore('=').substringBefore('&') !in encodedOverrideKeys } - val appendedQuery = params.entries.map { (key, value) -> - "${key.encodeURLParameter()}=${value.encodeURLParameter()}" - } - val query = (preservedQuery + appendedQuery).joinToString("&") - return if (query.isBlank()) "$path$fragment" else "$path?$query$fragment" - } - private suspend fun HttpResponse.throwIfHttpError(body: String, requestUrl: String) { val code = status.value if (code in 300..399 || code == 401 || code == 403 || code >= 400) { diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/core/WebBricksRequestContext.kt b/shared/src/commonMain/kotlin/com/bricks/mp/core/WebBricksRequestContext.kt index 8b9f396..5aa6df1 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/core/WebBricksRequestContext.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/core/WebBricksRequestContext.kt @@ -50,7 +50,20 @@ fun withWebBricksRequestContext( return merged } +/** + * Detect URLs that need WebBricks backend context (_webbricks_=1). + * Covers both direct .ui/.dspy paths and index fallback paths that the + * server resolves to .ui files (e.g. "/" → index.ui, "/public" → index.ui). + */ fun String.isWebBricksBackendResource(): Boolean { val path = substringBefore('?').substringBefore('#') - return path.endsWith(".ui") || path.endsWith(".dspy") + val normalized = path.trimEnd('/') + + // Direct .ui/.dspy resource + if (path.endsWith(".ui") || path.endsWith(".dspy")) return true + + // Index fallback paths resolved by the server to .ui files + if (normalized.isEmpty() || normalized == "/" || normalized == "/public") return true + + return false } diff --git a/test/generic-client/build.sh b/test/generic-client/build.sh new file mode 100755 index 0000000..3ef63f1 --- /dev/null +++ b/test/generic-client/build.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +MODE="${1:-run}" + +case "$MODE" in + run) + echo "=== Building and running generic-client ===" + ./gradlew run --no-daemon + ;; + build) + echo "=== Building generic-client ===" + ./gradlew createDistributable --no-daemon + echo "Output: build/compose/binaries/main/" + ;; + package) + echo "=== Packaging generic-client ===" + ./gradlew packageDmg --no-daemon 2>/dev/null || \ + ./gradlew packageDeb --no-daemon 2>/dev/null || \ + ./gradlew packageMsi --no-daemon 2>/dev/null || \ + echo "Note: Packaging requires the target OS (macOS/Windows/Linux)" + ;; + clean) + echo "=== Cleaning ===" + ./gradlew clean --no-daemon + ;; + *) + echo "Usage: $0 {run|build|package|clean}" + echo " run - Build and run the app (default)" + echo " build - Create distributable app bundle" + echo " package - Create OS-specific installer (dmg/deb/msi)" + echo " clean - Clean build artifacts" + exit 1 + ;; +esac