diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04b2fc5..1fdbf27 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,9 +6,11 @@ ktor = "3.0.3" serialization = "1.7.3" coroutines = "1.9.0" coil = "3.0.4" +datetime = "0.6.1" [libraries] kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 62acfee..856e201 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,4 +1,3 @@ -import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi plugins { @@ -28,8 +27,10 @@ kotlin { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) + implementation(compose.materialIconsExtended) implementation(compose.ui) implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) implementation(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.client.content.negotiation) @@ -51,14 +52,3 @@ kotlin { // compileSdk = 35 // defaultConfig { minSdk = 24 } // } - -compose.desktop { - application { - mainClass = "com.bricks.MainKt" - nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - packageName = "bricks-mp" - packageVersion = "1.0.0" - } - } -} diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/actions/ActionDispatcher.kt b/shared/src/commonMain/kotlin/com/bricks/mp/actions/ActionDispatcher.kt index 7b588c1..30c572c 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/actions/ActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/actions/ActionDispatcher.kt @@ -1,6 +1,9 @@ package com.bricks.mp.actions import com.bricks.mp.core.* +import com.bricks.mp.dev.DevLogStore +import com.bricks.mp.dev.DevLogLevel +import com.bricks.mp.dev.DevLogSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonPrimitive @@ -48,13 +51,35 @@ class ActionDispatcher( * 分发事件 */ fun dispatch(bind: BricksBind) { - when (bind.actiontype) { - "urlwidget" -> handleUrlWidget(bind) - "method" -> handleMethod(bind) - "script" -> handleScript(bind) - "registerfunction" -> handleRegisterFunction(bind) - "event" -> handleEvent(bind) - else -> println("[Bricks] Unknown actiontype: ${bind.actiontype}") + DevLogStore.log( + level = DevLogLevel.INFO, + message = "Dispatch: actiontype=${bind.actiontype} event=${bind.event} target=${bind.target}", + source = DevLogSource.ACTION + ) + try { + when (bind.actiontype) { + "urlwidget" -> handleUrlWidget(bind) + "method" -> handleMethod(bind) + "script" -> handleScript(bind) + "registerfunction" -> handleRegisterFunction(bind) + "event" -> handleEvent(bind) + else -> { + DevLogStore.log( + level = DevLogLevel.WARN, + message = "Unknown actiontype: ${bind.actiontype}", + source = DevLogSource.ACTION + ) + println("[Bricks] Unknown actiontype: ${bind.actiontype}") + } + } + } catch (e: Exception) { + DevLogStore.log( + level = DevLogLevel.ERROR, + message = "Action dispatch failed: ${bind.actiontype}", + details = "event=${bind.event}, target=${bind.target}", + source = DevLogSource.ACTION, + stackTrace = e.stackTraceToString() + ) } } @@ -63,8 +88,23 @@ class ActionDispatcher( if (it is JsonPrimitive) it.contentOrNull else null } ?: bind.url ?: return + DevLogStore.log( + level = DevLogLevel.INFO, + message = "Loading URL widget: $url", + source = DevLogSource.ACTION + ) scope.launch { - loadWidget(url, showAsDialog = false) + try { + loadWidget(url, showAsDialog = false) + } catch (e: Exception) { + DevLogStore.log( + level = DevLogLevel.ERROR, + message = "Failed to load URL widget: $url", + details = e.message, + source = DevLogSource.ACTION, + stackTrace = e.stackTraceToString() + ) + } } } 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 6dd0066..83f8213 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksHttp.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksHttp.kt @@ -1,5 +1,10 @@ package com.bricks.mp.core +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.* @@ -73,8 +78,22 @@ class BricksHttp(private val context: BricksContext? = null) { params: Map = emptyMap(), authToken: String = "" ): JsonObject { - val text = getText(url, params, authToken) - return parseJsonObjectOrError(text) + val devEntry = DevHttpInterceptor.recordRequest(url, "GET") + val startTime = Clock.System.now() + return try { + val text = getText(url, params, authToken, skipLog = true) + val duration = Clock.System.now() - startTime + DevHttpInterceptor.recordResponse(devEntry, 200, text, duration.inWholeMilliseconds) + parseJsonObjectOrError(text) + } catch (e: BricksHttpException) { + val duration = Clock.System.now() - startTime + DevHttpInterceptor.recordError(devEntry, e.message ?: "HTTP ${e.statusCode}", duration.inWholeMilliseconds) + throw e + } catch (e: Exception) { + val duration = Clock.System.now() - startTime + DevHttpInterceptor.recordError(devEntry, e.message ?: "Unknown error", duration.inWholeMilliseconds) + throw e + } } /** @@ -88,16 +107,31 @@ class BricksHttp(private val context: BricksContext? = null) { ): JsonObject { val requestParams = params.withBackendContextIfNeeded(url) val requestUrl = url.withQueryParameters(requestParams) - val response = client.post(requestUrl) { - contentType(ContentType.Application.Json) - setBody(body) - if (authToken.isNotEmpty()) { - header(HttpHeaders.Authorization, "Bearer $authToken") + val requestBodyStr = Json { encodeDefaults = false }.encodeToString(JsonObject.serializer(), body) + val devEntry = DevHttpInterceptor.recordRequest(requestUrl, "POST", requestBodyStr) + val startTime = Clock.System.now() + return try { + val response = client.post(requestUrl) { + contentType(ContentType.Application.Json) + setBody(body) + if (authToken.isNotEmpty()) { + header(HttpHeaders.Authorization, "Bearer $authToken") + } } + val text = response.bodyAsText() + val duration = Clock.System.now() - startTime + DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds) + response.throwIfHttpError(text, requestUrl) + parseJsonObjectOrError(text) + } catch (e: BricksHttpException) { + val duration = Clock.System.now() - startTime + DevHttpInterceptor.recordError(devEntry, e.message ?: "HTTP ${e.statusCode}", duration.inWholeMilliseconds) + throw e + } catch (e: Exception) { + val duration = Clock.System.now() - startTime + DevHttpInterceptor.recordError(devEntry, e.message ?: "Unknown error", duration.inWholeMilliseconds) + throw e } - val text = response.bodyAsText() - response.throwIfHttpError(text, requestUrl) - return parseJsonObjectOrError(text) } /** @@ -113,16 +147,30 @@ class BricksHttp(private val context: BricksContext? = null) { val formBody = form.entries.joinToString("&") { (k, v) -> "${k.encodeURLParameter()}=${v.encodeURLParameter()}" } - val response = client.post(requestUrl) { - contentType(ContentType.Application.FormUrlEncoded) - setBody(formBody) - if (authToken.isNotEmpty()) { - header(HttpHeaders.Authorization, "Bearer $authToken") + val devEntry = DevHttpInterceptor.recordRequest(requestUrl, "POST", formBody) + val startTime = Clock.System.now() + return try { + val response = client.post(requestUrl) { + contentType(ContentType.Application.FormUrlEncoded) + setBody(formBody) + if (authToken.isNotEmpty()) { + header(HttpHeaders.Authorization, "Bearer $authToken") + } } + val text = response.bodyAsText() + val duration = Clock.System.now() - startTime + DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds) + response.throwIfHttpError(text, requestUrl) + text + } catch (e: BricksHttpException) { + val duration = Clock.System.now() - startTime + DevHttpInterceptor.recordError(devEntry, e.message ?: "HTTP ${e.statusCode}", duration.inWholeMilliseconds) + throw e + } catch (e: Exception) { + val duration = Clock.System.now() - startTime + DevHttpInterceptor.recordError(devEntry, e.message ?: "Unknown error", duration.inWholeMilliseconds) + throw e } - val text = response.bodyAsText() - response.throwIfHttpError(text, requestUrl) - return text } /** @@ -131,18 +179,39 @@ class BricksHttp(private val context: BricksContext? = null) { suspend fun getText( url: String, params: Map = emptyMap(), - authToken: String = "" + authToken: String = "", + skipLog: Boolean = false ): String { val requestParams = params.withBackendContextIfNeeded(url) val requestUrl = url.withQueryParameters(requestParams) - val response = client.get(requestUrl) { - if (authToken.isNotEmpty()) { - header(HttpHeaders.Authorization, "Bearer $authToken") + val devEntry = if (skipLog) null else DevHttpInterceptor.recordRequest(requestUrl, "GET") + val startTime = Clock.System.now() + return try { + val response = client.get(requestUrl) { + if (authToken.isNotEmpty()) { + header(HttpHeaders.Authorization, "Bearer $authToken") + } } + val text = response.bodyAsText() + val duration = Clock.System.now() - startTime + if (devEntry != null) { + DevHttpInterceptor.recordResponse(devEntry, response.status.value, text, duration.inWholeMilliseconds) + } + response.throwIfHttpError(text, requestUrl) + text + } catch (e: BricksHttpException) { + val duration = Clock.System.now() - startTime + if (devEntry != null) { + DevHttpInterceptor.recordError(devEntry, e.message ?: "HTTP ${e.statusCode}", duration.inWholeMilliseconds) + } + throw e + } catch (e: Exception) { + val duration = Clock.System.now() - startTime + if (devEntry != null) { + DevHttpInterceptor.recordError(devEntry, e.message ?: "Unknown error", duration.inWholeMilliseconds) + } + throw e } - val text = response.bodyAsText() - response.throwIfHttpError(text, requestUrl) - return text } /** @@ -153,8 +222,23 @@ class BricksHttp(private val context: BricksContext? = null) { authToken: String = "" ): BricksWidget { val fullUrl = resolveUrl(path) - val text = getText(fullUrl, authToken = authToken) - return BricksParser.parse(text) + val devEntry = DevHttpInterceptor.recordRequest(fullUrl, "GET") + val startTime = Clock.System.now() + return try { + val text = getText(fullUrl, skipLog = true, authToken = authToken) + val duration = Clock.System.now() - startTime + DevHttpInterceptor.recordResponse(devEntry, 200, text, duration.inWholeMilliseconds) + val widget = BricksParser.parse(text) + widget + } catch (e: BricksHttpException) { + val duration = Clock.System.now() - startTime + DevHttpInterceptor.recordError(devEntry, e.message ?: "HTTP ${e.statusCode}", duration.inWholeMilliseconds) + throw e + } catch (e: Exception) { + val duration = Clock.System.now() - startTime + DevHttpInterceptor.recordError(devEntry, e.message ?: "Unknown error", duration.inWholeMilliseconds) + throw e + } } /** diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksParser.kt b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksParser.kt index 40faf58..ef43103 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksParser.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksParser.kt @@ -1,5 +1,9 @@ package com.bricks.mp.core +import com.bricks.mp.dev.DevLogEntry +import com.bricks.mp.dev.DevLogLevel +import com.bricks.mp.dev.DevLogSource +import com.bricks.mp.dev.DevLogStore import kotlinx.serialization.json.* /** @@ -11,9 +15,27 @@ object BricksParser { * 从 JSON 字符串解析 Widget 树 */ fun parse(jsonString: String): BricksWidget { - val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } - val element = json.parseToJsonElement(jsonString) - return parseElement(element) + return try { + val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } + val element = json.parseToJsonElement(jsonString) + DevLogStore.log( + level = DevLogLevel.INFO, + message = "Parsed widget tree: ${element.jsonObject["widgettype"]?.jsonPrimitive?.content ?: "unknown"}", + details = "JSON length: ${jsonString.length} chars", + source = DevLogSource.PARSER + ) + parseElement(element) + } catch (e: Exception) { + val snippet = if (jsonString.length > 500) jsonString.take(500) + "…" else jsonString + DevLogStore.log( + level = DevLogLevel.ERROR, + message = "JSON parse failed: ${e.message}", + details = "JSON snippet:\n$snippet", + source = DevLogSource.PARSER, + stackTrace = e.stackTraceToString() + ) + throw e + } } /** diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/dev/DevHttpInterceptor.kt b/shared/src/commonMain/kotlin/com/bricks/mp/dev/DevHttpInterceptor.kt new file mode 100644 index 0000000..4b35cbd --- /dev/null +++ b/shared/src/commonMain/kotlin/com/bricks/mp/dev/DevHttpInterceptor.kt @@ -0,0 +1,172 @@ +package com.bricks.mp.dev + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.json.Json + +/** + * Captured HTTP request/response pair for DevMode network inspection. + */ +data class DevHttpEntry( + val id: Long, + val timestamp: Instant, + val url: String, + val method: String, + val requestBody: String? = null, + val responseStatus: Int? = null, + val responseBody: String? = null, + val durationMs: Long? = null, + val error: String? = null, + val isSuccess: Boolean = error == null +) { + companion object { + private var nextId: Long = 0L + + fun start(url: String, method: String, requestBody: String? = null): DevHttpEntry { + return DevHttpEntry( + id = nextId++, + timestamp = Clock.System.now(), + url = url, + method = method, + requestBody = requestBody + ) + } + } + + /** + * Create a truncated display version of the request body for list views. + */ + fun requestBodySummary(maxLen: Int = 200): String { + val body = requestBody ?: return "" + return if (body.length > maxLen) body.take(maxLen) + "…" else body + } + + /** + * Create a truncated display version of the response body. + */ + fun responseBodySummary(maxLen: Int = 200): String { + val body = responseBody ?: return "" + return if (body.length > maxLen) body.take(maxLen) + "…" else body + } + + /** + * Try to pretty-print JSON bodies. + */ + fun formatBody(body: String?): String { + if (body.isNullOrBlank()) return "(empty)" + return try { + val json = Json { prettyPrint = true; ignoreUnknownKeys = true } + val element = json.parseToJsonElement(body) + json.encodeToString(kotlinx.serialization.json.JsonElement.serializer(), element) + } catch (e: Exception) { + body + } + } +} + +/** + * HTTP request/response interceptor for DevMode. + * Maintains a capped list of recent HTTP exchanges observable via Flow. + */ +object DevHttpInterceptor { + + private const val MAX_ENTRIES = 100 + + private var _entries = mutableListOf() + private val _flow = kotlinx.coroutines.flow.MutableStateFlow>(emptyList()) + val entries: kotlinx.coroutines.flow.Flow> = _flow + + /** + * Record the start of an HTTP request. + * Returns the entry that should later be completed via [complete]. + */ + fun recordRequest(url: String, method: String, requestBody: String? = null): DevHttpEntry { + val entry = DevHttpEntry.start(url, method, requestBody) + synchronized(this) { + _entries.add(entry) + if (_entries.size > MAX_ENTRIES) { + _entries = _entries.drop(_entries.size - MAX_ENTRIES).toMutableList() + } + _flow.value = _entries.toList() + } + DevLogStore.log( + level = DevLogLevel.INFO, + message = "$method $url", + details = requestBody?.let { "Body: ${it.take(500)}" }, + source = DevLogSource.HTTP + ) + return entry + } + + /** + * Record a successful response, updating the entry with status, body, and duration. + */ + fun recordResponse( + entry: DevHttpEntry, + statusCode: Int, + responseBody: String?, + durationMs: Long + ) { + val level = if (statusCode in 200..299) DevLogLevel.INFO else DevLogLevel.WARN + val updated = entry.copy( + responseStatus = statusCode, + responseBody = responseBody, + durationMs = durationMs + ) + synchronized(this) { + val idx = _entries.indexOfFirst { it.id == entry.id } + if (idx >= 0) _entries[idx] = updated + _flow.value = _entries.toList() + } + DevLogStore.log( + level = level, + message = "${entry.method} ${entry.url} -> $statusCode (${durationMs}ms)", + details = responseBody?.let { "Body: ${it.take(500)}" }, + source = DevLogSource.HTTP + ) + } + + /** + * Record a failed request. + */ + fun recordError( + entry: DevHttpEntry, + error: String, + durationMs: Long? = null + ) { + val updated = entry.copy( + error = error, + durationMs = durationMs + ) + synchronized(this) { + val idx = _entries.indexOfFirst { it.id == entry.id } + if (idx >= 0) _entries[idx] = updated + _flow.value = _entries.toList() + } + DevLogStore.log( + level = DevLogLevel.EXCEPTION, + message = "${entry.method} ${entry.url} -> ERROR: $error", + source = DevLogSource.HTTP + ) + } + + /** + * Clear all HTTP entries. + */ + fun clear() { + synchronized(this) { + _entries.clear() + _flow.value = emptyList() + } + } + + /** + * Get the current snapshot. + */ + fun snapshot(): List = synchronized(this) { _entries.toList() } + + /** + * Get error count. + */ + fun errorCount(): Int = synchronized(this) { _entries.count { !it.isSuccess } } +} diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/dev/DevLog.kt b/shared/src/commonMain/kotlin/com/bricks/mp/dev/DevLog.kt new file mode 100644 index 0000000..0894727 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/bricks/mp/dev/DevLog.kt @@ -0,0 +1,161 @@ +package com.bricks.mp.dev + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * Log level for DevMode entries. + */ +enum class DevLogLevel { + INFO, + WARN, + ERROR, + EXCEPTION; + + fun badgeColor(): Long = when (this) { + INFO -> 0xFF1976D2 // Blue + WARN -> 0xFFFFA000 // Amber + ERROR -> 0xFFD32F2F // Red + EXCEPTION -> 0xFFB71C1C // Deep Red + } + + fun label(): String = when (this) { + INFO -> "INFO" + WARN -> "WARN" + ERROR -> "ERROR" + EXCEPTION -> "EXC" + } +} + +/** + * Source category for log entries. + */ +enum class DevLogSource(val label: String) { + HTTP("http"), + PARSER("parser"), + ACTION("action"), + APP("app"), + RENDER("render"), + UI("ui"); + + override fun toString(): String = label +} + +/** + * A single log entry in the DevMode system. + */ +data class DevLogEntry( + val id: Long, + val level: DevLogLevel, + val timestamp: Instant, + val message: String, + val details: String? = null, + val source: DevLogSource = DevLogSource.APP, + val stackTrace: String? = null +) { + companion object { + private var nextId: Long = 0L + + fun create( + level: DevLogLevel, + message: String, + details: String? = null, + source: DevLogSource = DevLogSource.APP, + stackTrace: String? = null, + timestamp: Instant = Clock.System.now() + ): DevLogEntry { + return DevLogEntry( + id = nextId++, + level = level, + timestamp = timestamp, + message = message, + details = details, + source = source, + stackTrace = stackTrace + ) + } + } +} + +/** + * Centralized log store for DevMode. + * Thread-safe, capped at maxEntries, provides Flow-based observation. + */ +object DevLogStore { + + private const val MAX_ENTRIES = 200 + + private val _entries = MutableStateFlow>(emptyList()) + val entries: Flow> = _entries.asStateFlow() + + fun logsByLevel(level: DevLogLevel): Flow> = + _entries.asStateFlow().map { list -> + when (level) { + DevLogLevel.ERROR -> list.filter { it.level == DevLogLevel.ERROR || it.level == DevLogLevel.EXCEPTION } + DevLogLevel.EXCEPTION -> list.filter { it.level == DevLogLevel.EXCEPTION } + else -> list + } + } + + /** + * Append a log entry. Automatically evicts oldest entries when exceeding capacity. + */ + fun append(entry: DevLogEntry) { + val current = _entries.value + val newList = if (current.size >= MAX_ENTRIES) { + current.drop(current.size - MAX_ENTRIES + 1) + entry + } else { + current + entry + } + _entries.value = newList + } + + /** + * Convenience: append using builder parameters. + */ + fun log( + level: DevLogLevel, + message: String, + details: String? = null, + source: DevLogSource = DevLogSource.APP, + stackTrace: String? = null + ) { + append(DevLogEntry.create(level, message, details, source, stackTrace)) + } + + /** + * Clear all log entries. + */ + fun clear() { + _entries.value = emptyList() + } + + /** + * Get the current snapshot of entries (non-Flow). + */ + fun snapshot(): List = _entries.value + + /** + * Get error count (ERROR + EXCEPTION). + */ + fun errorCount(): Int = _entries.value.count { + it.level == DevLogLevel.ERROR || it.level == DevLogLevel.EXCEPTION + } +} + +/** + * Format an Instant to a human-readable time string. + */ +fun Instant.toDisplayTime(): String { + val epochMillis = toEpochMilliseconds() + val totalSeconds = epochMillis / 1000 + val hours = (totalSeconds % 86400) / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + val millis = epochMillis % 1000 + return String.format("%02d:%02d:%02d.%03d", hours, minutes, seconds, millis) +} diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/dev/DevPanel.kt b/shared/src/commonMain/kotlin/com/bricks/mp/dev/DevPanel.kt new file mode 100644 index 0000000..527f50d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/bricks/mp/dev/DevPanel.kt @@ -0,0 +1,513 @@ +package com.bricks.mp.dev + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.automirrored.filled.ArrowRight +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.NetworkCheck +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * DevMode panel with tabs: Logs | Network | Errors. + * Renders as a bottom sheet that can be toggled on/off. + */ +@Composable +fun DevPanel( + modifier: Modifier = Modifier, + onClose: () -> Unit = {} +) { + var selectedTab by remember { mutableIntStateOf(0) } + val tabs = listOf("Logs", "Network", "Errors") + val errorCount = DevLogStore.errorCount() + val httpErrorCount = DevHttpInterceptor.errorCount() + + Surface( + modifier = modifier.fillMaxSize(), + color = Color(0xFF1E1E1E), + tonalElevation = 8.dp + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Header bar + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.BugReport, + contentDescription = null, + tint = Color(0xFF4FC3F7), + modifier = Modifier.width(20.dp).height(20.dp) + ) + Text( + "Dev Mode", + style = MaterialTheme.typography.titleSmall, + color = Color(0xFFE0E0E0), + modifier = Modifier.padding(start = 4.dp) + ) + } + Row { + IconButton(onClick = { + DevLogStore.clear() + DevHttpInterceptor.clear() + }) { + Icon( + Icons.Default.Close, + contentDescription = "Clear", + tint = Color(0xFF9E9E9E), + modifier = Modifier.width(18.dp).height(18.dp) + ) + } + IconButton(onClick = onClose) { + Icon( + Icons.Default.Close, + contentDescription = "Close", + tint = Color(0xFF9E9E9E), + modifier = Modifier.width(18.dp).height(18.dp) + ) + } + } + } + + // Tabs + TabRow(selectedTabIndex = selectedTab) { + tabs.forEachIndexed { index, label -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label, fontSize = 12.sp) + if (index == 2 && errorCount > 0) { + Text( + " ($errorCount)", + fontSize = 10.sp, + color = Color(0xFFD32F2F) + ) + } + if (index == 1 && httpErrorCount > 0) { + Text( + " ($httpErrorCount)", + fontSize = 10.sp, + color = Color(0xFFD32F2F) + ) + } + } + } + ) + } + } + + // Tab content + when (selectedTab) { + 0 -> LogsTab() + 1 -> NetworkTab() + 2 -> ErrorsTab() + } + } + } +} + +@Composable +private fun LogsTab() { + val entries by DevLogStore.entries.collectAsState(initial = emptyList()) + val scrollState = rememberScrollState() + + if (entries.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("No logs yet", color = Color(0xFF757575), fontSize = 14.sp) + } + return + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + ) { + entries.reversed().forEach { entry -> + LogEntryRow(entry) + } + } +} + +@Composable +private fun LogEntryRow(entry: DevLogEntry) { + var expanded by remember(entry.id) { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + // Summary row + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = when (entry.level) { + DevLogLevel.INFO -> Icons.Default.Info + DevLogLevel.WARN -> Icons.Default.Warning + DevLogLevel.ERROR -> Icons.Default.ErrorOutline + DevLogLevel.EXCEPTION -> Icons.Default.ErrorOutline + }, + contentDescription = entry.level.label(), + tint = Color(entry.level.badgeColor()), + modifier = Modifier.width(14.dp).height(14.dp) + ) + + Text( + text = entry.timestamp.toDisplayTime(), + color = Color(0xFF9E9E9E), + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.width(70.dp) + ) + + // Source badge + Text( + text = entry.source.label, + color = Color(0xFFBDBDBD), + fontSize = 9.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier + .background(Color(0xFF333333)) + .padding(horizontal = 4.dp, vertical = 1.dp) + .width(48.dp), + maxLines = 1 + ) + + // Level badge + Text( + text = entry.level.label(), + color = Color(entry.level.badgeColor()), + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + modifier = Modifier + .background(Color(0xFF222222)) + .padding(horizontal = 4.dp, vertical = 1.dp) + .width(36.dp), + maxLines = 1 + ) + + Text( + text = entry.message, + color = Color(0xFFE0E0E0), + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Icon( + imageVector = if (expanded) Icons.Default.ArrowDropDown else Icons.AutoMirrored.Filled.ArrowRight, + contentDescription = null, + tint = Color(0xFF757575), + modifier = Modifier.width(14.dp).height(14.dp) + ) + } + + // Expanded details + if (expanded) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 28.dp, top = 2.dp, bottom = 4.dp, end = 8.dp) + ) { + if (entry.details != null) { + DevCodeBlock(entry.details) + } + if (entry.stackTrace != null) { + DevCodeBlock(entry.stackTrace, color = Color(0xFFFF8A80)) + } + } + } + } +} + +@Composable +private fun NetworkTab() { + val entries by DevHttpInterceptor.entries.collectAsState(initial = emptyList()) + val scrollState = rememberScrollState() + + if (entries.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("No HTTP requests yet", color = Color(0xFF757575), fontSize = 14.sp) + } + return + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + ) { + entries.reversed().forEach { entry -> + HttpEntryRow(entry) + } + } +} + +@Composable +private fun HttpEntryRow(entry: DevHttpEntry) { + var expanded by remember(entry.id) { mutableStateOf(false) } + + val statusColor = when { + entry.isSuccess -> Color(0xFF4CAF50) + entry.responseStatus != null && entry.responseStatus!! in 300..399 -> Color(0xFFFFA000) + entry.responseStatus != null && entry.responseStatus!! >= 400 -> Color(0xFFD32F2F) + else -> Color(0xFF9E9E9E) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.Default.NetworkCheck, + contentDescription = null, + tint = statusColor, + modifier = Modifier.width(14.dp).height(14.dp) + ) + + Text( + text = entry.timestamp.toDisplayTime(), + color = Color(0xFF9E9E9E), + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.width(70.dp) + ) + + // Method badge + Text( + text = entry.method, + color = Color(0xFF4FC3F7), + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + modifier = Modifier + .background(Color(0xFF222222)) + .padding(horizontal = 4.dp, vertical = 1.dp) + .width(40.dp), + maxLines = 1 + ) + + // Status badge + val statusText = if (entry.error != null) "ERR" else "${entry.responseStatus ?: "..."}" + Text( + text = statusText, + color = statusColor, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + modifier = Modifier + .background(Color(0xFF222222)) + .padding(horizontal = 4.dp, vertical = 1.dp) + .width(36.dp), + maxLines = 1 + ) + + // Duration + if (entry.durationMs != null) { + Text( + text = "${entry.durationMs}ms", + color = Color(0xFFBDBDBD), + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.width(60.dp) + ) + } + + Text( + text = entry.url, + color = Color(0xFFE0E0E0), + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Icon( + imageVector = if (expanded) Icons.Default.ArrowDropDown else Icons.AutoMirrored.Filled.ArrowRight, + contentDescription = null, + tint = Color(0xFF757575), + modifier = Modifier.width(14.dp).height(14.dp) + ) + } + + // Expanded details + if (expanded) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 28.dp, top = 2.dp, bottom = 4.dp, end = 8.dp) + ) { + Text( + "URL: ${entry.url}", + color = Color(0xFFBDBDBD), + fontSize = 11.sp, + modifier = Modifier.padding(vertical = 2.dp) + ) + if (entry.requestBody != null) { + Text("Request Body:", color = Color(0xFF4FC3F7), fontSize = 11.sp, fontWeight = FontWeight.Medium) + DevCodeBlock(entry.formatBody(entry.requestBody)) + } + if (entry.responseBody != null) { + Text("Response Body:", color = Color(0xFF4CAF50), fontSize = 11.sp, fontWeight = FontWeight.Medium) + DevCodeBlock(entry.formatBody(entry.responseBody)) + } + if (entry.error != null) { + Text("Error:", color = Color(0xFFD32F2F), fontSize = 11.sp, fontWeight = FontWeight.Medium) + DevCodeBlock(entry.error, color = Color(0xFFFF8A80)) + } + } + } + } +} + +@Composable +private fun ErrorsTab() { + val entries by DevLogStore.logsByLevel(DevLogLevel.ERROR).collectAsState(initial = emptyList()) + val scrollState = rememberScrollState() + + if (entries.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("No errors", color = Color(0xFF4CAF50), fontSize = 14.sp) + } + return + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + ) { + entries.reversed().forEach { entry -> + LogEntryRow(entry) + } + } +} + +@Composable +private fun DevCodeBlock( + text: String, + color: Color = Color(0xFFBDBDBD) +) { + var copied by remember { mutableStateOf(false) } + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFF111111)) + ) { + Box(modifier = Modifier.padding(6.dp)) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + IconButton( + onClick = { + // On desktop, we can't easily copy to clipboard without platform-specific code + // So we just show a brief "copied" indicator + copied = true + }, + modifier = Modifier.width(20.dp).height(20.dp) + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy", + tint = if (copied) Color(0xFF4CAF50) else Color(0xFF757575), + modifier = Modifier.width(12.dp).height(12.dp) + ) + } + } + Text( + text = text, + color = color, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) + } + } + } + + // Reset copied state after delay + if (copied) { + androidx.compose.runtime.LaunchedEffect(Unit) { + kotlinx.coroutines.delay(1500) + copied = false + } + } +} diff --git a/test/generic-client/build.gradle.kts b/test/generic-client/build.gradle.kts new file mode 100644 index 0000000..722aa42 --- /dev/null +++ b/test/generic-client/build.gradle.kts @@ -0,0 +1,34 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.jetbrains.compose) +} + +kotlin { + jvm() + + sourceSets { + jvmMain.dependencies { + implementation("com.bricks.mp:shared") + implementation(compose.desktop.currentOs) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(libs.kotlinx.coroutines.core) + } + } +} + +compose.desktop { + application { + mainClass = "com.bricks.test.generic.MainKt" + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "bricks-mp-generic" + packageVersion = "1.0.0" + } + } +} diff --git a/test/generic-client/gradle/libs.versions.toml b/test/generic-client/gradle/libs.versions.toml new file mode 100644 index 0000000..2a0a3cd --- /dev/null +++ b/test/generic-client/gradle/libs.versions.toml @@ -0,0 +1,13 @@ +[versions] +compose = "1.7.3" +compose-plugin = "1.7.3" +kotlin = "2.1.0" +coroutines = "1.9.0" + +[libraries] +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } + +[plugins] +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } diff --git a/test/generic-client/gradle/wrapper/gradle-wrapper.jar b/test/generic-client/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/test/generic-client/gradle/wrapper/gradle-wrapper.jar differ diff --git a/test/generic-client/gradle/wrapper/gradle-wrapper.properties b/test/generic-client/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/test/generic-client/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/test/generic-client/gradlew b/test/generic-client/gradlew new file mode 100755 index 0000000..97de990 --- /dev/null +++ b/test/generic-client/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/test/generic-client/settings.gradle.kts b/test/generic-client/settings.gradle.kts new file mode 100644 index 0000000..42267a2 --- /dev/null +++ b/test/generic-client/settings.gradle.kts @@ -0,0 +1,26 @@ +rootProject.name = "bricks-mp-generic-client" + +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + } +} + +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +includeBuild("../..") { + dependencySubstitution { + substitute(module("com.bricks.mp:shared")).using(project(":shared")) + } +} diff --git a/shared/src/jvmMain/kotlin/com/bricks/Main.kt b/test/generic-client/src/jvmMain/kotlin/com/bricks/test/generic/Main.kt similarity index 72% rename from shared/src/jvmMain/kotlin/com/bricks/Main.kt rename to test/generic-client/src/jvmMain/kotlin/com/bricks/test/generic/Main.kt index b3cb580..6d89fb0 100644 --- a/shared/src/jvmMain/kotlin/com/bricks/Main.kt +++ b/test/generic-client/src/jvmMain/kotlin/com/bricks/test/generic/Main.kt @@ -1,7 +1,8 @@ -package com.bricks +package com.bricks.test.generic import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -31,6 +32,10 @@ import com.bricks.mp.core.BricksContext import com.bricks.mp.core.BricksHttp import com.bricks.mp.core.BricksWidget import com.bricks.mp.core.RenderWidget +import com.bricks.mp.dev.DevLogStore +import com.bricks.mp.dev.DevLogLevel +import com.bricks.mp.dev.DevLogSource +import com.bricks.mp.dev.DevPanel import kotlinx.coroutines.launch import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent @@ -50,9 +55,30 @@ fun main() = application { LaunchedEffect(Unit) { context.baseUrl = System.getProperty("bricks.baseUrl", "http://localhost:8080") + DevLogStore.log( + level = DevLogLevel.INFO, + message = "bricks-mp starting", + details = "baseUrl=${context.baseUrl}", + source = DevLogSource.APP + ) runCatching { http.fetchUi(System.getProperty("bricks.entry", "/")) } - .onSuccess { context.setCurrentWidget(it) } - .onFailure { println("[Bricks] Failed to load entry UI: ${it.message}") } + .onSuccess { + context.setCurrentWidget(it) + DevLogStore.log( + level = DevLogLevel.INFO, + message = "Entry UI loaded successfully", + source = DevLogSource.APP + ) + } + .onFailure { + DevLogStore.log( + level = DevLogLevel.ERROR, + message = "Failed to load entry UI: ${it.message}", + source = DevLogSource.APP, + stackTrace = it.stackTraceToString() + ) + println("[Bricks] Failed to load entry UI: ${it.message}") + } } Window( @@ -108,18 +134,41 @@ fun main() = application { } val currentWidget by context.currentWidget.collectAsState() + var devModeEnabled by remember { mutableStateOf(false) } BricksHostScreen( widget = currentWidget, actionDispatcher = actionDispatcher, + devModeEnabled = devModeEnabled, + onDevModeToggle = { devModeEnabled = !devModeEnabled }, onReload = { scope.launch { + DevLogStore.log( + level = DevLogLevel.INFO, + message = "User triggered reload", + source = DevLogSource.APP + ) runCatching { http.fetchUi(System.getProperty("bricks.entry", "/")) } .onSuccess { context.setCurrentWidget(it) } - .onFailure { message = Triple("Error", it.message ?: "Load failed", true) } + .onFailure { + message = Triple("Error", it.message ?: "Load failed", true) + DevLogStore.log( + level = DevLogLevel.ERROR, + message = "Reload failed: ${it.message}", + source = DevLogSource.APP + ) + } } } ) + // DevPanel - only rendered when dev mode is enabled + if (devModeEnabled) { + DevPanel( + modifier = Modifier.height(320.dp), + onClose = { devModeEnabled = false } + ) + } + dialogWidget?.let { widget -> AlertDialog( onDismissRequest = { dialogWidget = null }, @@ -150,6 +199,8 @@ fun main() = application { private fun BricksHostScreen( widget: BricksWidget?, actionDispatcher: ActionDispatcher, + devModeEnabled: Boolean, + onDevModeToggle: () -> Unit, onReload: () -> Unit ) { Scaffold( @@ -157,6 +208,12 @@ private fun BricksHostScreen( TopAppBar( title = { Text("bricks-mp") }, actions = { + Button( + onClick = onDevModeToggle, + modifier = Modifier.padding(end = 4.dp) + ) { + Text(if (devModeEnabled) "Dev ON" else "Dev") + } Button(onClick = onReload, modifier = Modifier.padding(end = 8.dp)) { Text("Reload") }