feat: add DevMode with logging, HTTP interception, and debug panel

- DevLogStore: centralized log store with Flow-based observation (INFO/WARN/ERROR/EXCEPTION levels)
- DevHttpInterceptor: capture request/response pairs with timing and body details
- DevPanel: bottom panel with Logs/Network/Errors tabs, expandable entries, JSON formatting
- Integrated into BricksHttp (all HTTP methods), BricksParser, ActionDispatcher, Main
- Move Main.kt from shared/ to test/generic-client/ (library module should not have main)
- Add test/generic-client/ as generic bricks-mp desktop host with DevMode toggle
- Add kotlinx-datetime dependency for timestamp handling
- Add materialIconsExtended for DevPanel icons
This commit is contained in:
yumoqing 2026-05-19 23:15:37 +08:00
parent 51b207626a
commit 43f416a6f0
15 changed files with 1424 additions and 54 deletions

View File

@ -6,9 +6,11 @@ ktor = "3.0.3"
serialization = "1.7.3" serialization = "1.7.3"
coroutines = "1.9.0" coroutines = "1.9.0"
coil = "3.0.4" coil = "3.0.4"
datetime = "0.6.1"
[libraries] [libraries]
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } 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-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", 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" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }

View File

@ -1,4 +1,3 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
plugins { plugins {
@ -28,8 +27,10 @@ kotlin {
implementation(compose.runtime) implementation(compose.runtime)
implementation(compose.foundation) implementation(compose.foundation)
implementation(compose.material3) implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation(compose.ui) implementation(compose.ui)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio) implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
@ -51,14 +52,3 @@ kotlin {
// compileSdk = 35 // compileSdk = 35
// defaultConfig { minSdk = 24 } // 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"
}
}
}

View File

@ -1,6 +1,9 @@
package com.bricks.mp.actions package com.bricks.mp.actions
import com.bricks.mp.core.* 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.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
@ -48,13 +51,35 @@ class ActionDispatcher(
* 分发事件 * 分发事件
*/ */
fun dispatch(bind: BricksBind) { fun dispatch(bind: BricksBind) {
when (bind.actiontype) { DevLogStore.log(
"urlwidget" -> handleUrlWidget(bind) level = DevLogLevel.INFO,
"method" -> handleMethod(bind) message = "Dispatch: actiontype=${bind.actiontype} event=${bind.event} target=${bind.target}",
"script" -> handleScript(bind) source = DevLogSource.ACTION
"registerfunction" -> handleRegisterFunction(bind) )
"event" -> handleEvent(bind) try {
else -> println("[Bricks] Unknown actiontype: ${bind.actiontype}") 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 if (it is JsonPrimitive) it.contentOrNull else null
} ?: bind.url ?: return } ?: bind.url ?: return
DevLogStore.log(
level = DevLogLevel.INFO,
message = "Loading URL widget: $url",
source = DevLogSource.ACTION
)
scope.launch { 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()
)
}
} }
} }

View File

@ -1,5 +1,10 @@
package com.bricks.mp.core 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.*
import io.ktor.client.engine.cio.* import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.cookies.* import io.ktor.client.plugins.cookies.*
@ -73,8 +78,22 @@ class BricksHttp(private val context: BricksContext? = null) {
params: Map<String, String> = emptyMap(), params: Map<String, String> = emptyMap(),
authToken: String = "" authToken: String = ""
): JsonObject { ): JsonObject {
val text = getText(url, params, authToken) val devEntry = DevHttpInterceptor.recordRequest(url, "GET")
return parseJsonObjectOrError(text) 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 { ): JsonObject {
val requestParams = params.withBackendContextIfNeeded(url) val requestParams = params.withBackendContextIfNeeded(url)
val requestUrl = url.withQueryParameters(requestParams) val requestUrl = url.withQueryParameters(requestParams)
val response = client.post(requestUrl) { val requestBodyStr = Json { encodeDefaults = false }.encodeToString(JsonObject.serializer(), body)
contentType(ContentType.Application.Json) val devEntry = DevHttpInterceptor.recordRequest(requestUrl, "POST", requestBodyStr)
setBody(body) val startTime = Clock.System.now()
if (authToken.isNotEmpty()) { return try {
header(HttpHeaders.Authorization, "Bearer $authToken") 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) -> val formBody = form.entries.joinToString("&") { (k, v) ->
"${k.encodeURLParameter()}=${v.encodeURLParameter()}" "${k.encodeURLParameter()}=${v.encodeURLParameter()}"
} }
val response = client.post(requestUrl) { val devEntry = DevHttpInterceptor.recordRequest(requestUrl, "POST", formBody)
contentType(ContentType.Application.FormUrlEncoded) val startTime = Clock.System.now()
setBody(formBody) return try {
if (authToken.isNotEmpty()) { val response = client.post(requestUrl) {
header(HttpHeaders.Authorization, "Bearer $authToken") 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( suspend fun getText(
url: String, url: String,
params: Map<String, String> = emptyMap(), params: Map<String, String> = emptyMap(),
authToken: String = "" authToken: String = "",
skipLog: Boolean = false
): String { ): String {
val requestParams = params.withBackendContextIfNeeded(url) val requestParams = params.withBackendContextIfNeeded(url)
val requestUrl = url.withQueryParameters(requestParams) val requestUrl = url.withQueryParameters(requestParams)
val response = client.get(requestUrl) { val devEntry = if (skipLog) null else DevHttpInterceptor.recordRequest(requestUrl, "GET")
if (authToken.isNotEmpty()) { val startTime = Clock.System.now()
header(HttpHeaders.Authorization, "Bearer $authToken") 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 = "" authToken: String = ""
): BricksWidget { ): BricksWidget {
val fullUrl = resolveUrl(path) val fullUrl = resolveUrl(path)
val text = getText(fullUrl, authToken = authToken) val devEntry = DevHttpInterceptor.recordRequest(fullUrl, "GET")
return BricksParser.parse(text) 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
}
} }
/** /**

View File

@ -1,5 +1,9 @@
package com.bricks.mp.core 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.* import kotlinx.serialization.json.*
/** /**
@ -11,9 +15,27 @@ object BricksParser {
* JSON 字符串解析 Widget * JSON 字符串解析 Widget
*/ */
fun parse(jsonString: String): BricksWidget { fun parse(jsonString: String): BricksWidget {
val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } return try {
val element = json.parseToJsonElement(jsonString) val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
return parseElement(element) 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
}
} }
/** /**

View File

@ -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<DevHttpEntry>()
private val _flow = kotlinx.coroutines.flow.MutableStateFlow<List<DevHttpEntry>>(emptyList())
val entries: kotlinx.coroutines.flow.Flow<List<DevHttpEntry>> = _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<DevHttpEntry> = synchronized(this) { _entries.toList() }
/**
* Get error count.
*/
fun errorCount(): Int = synchronized(this) { _entries.count { !it.isSuccess } }
}

View File

@ -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<List<DevLogEntry>>(emptyList())
val entries: Flow<List<DevLogEntry>> = _entries.asStateFlow()
fun logsByLevel(level: DevLogLevel): Flow<List<DevLogEntry>> =
_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<DevLogEntry> = _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)
}

View File

@ -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
}
}
}

View File

@ -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"
}
}
}

View File

@ -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" }

Binary file not shown.

View File

@ -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

249
test/generic-client/gradlew vendored Executable file
View File

@ -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" "$@"

View File

@ -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"))
}
}

View File

@ -1,7 +1,8 @@
package com.bricks package com.bricks.test.generic
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button 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.BricksHttp
import com.bricks.mp.core.BricksWidget import com.bricks.mp.core.BricksWidget
import com.bricks.mp.core.RenderWidget 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 kotlinx.coroutines.launch
import java.awt.event.ComponentAdapter import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent import java.awt.event.ComponentEvent
@ -50,9 +55,30 @@ fun main() = application {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
context.baseUrl = System.getProperty("bricks.baseUrl", "http://localhost:8080") 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", "/")) } runCatching { http.fetchUi(System.getProperty("bricks.entry", "/")) }
.onSuccess { context.setCurrentWidget(it) } .onSuccess {
.onFailure { println("[Bricks] Failed to load entry UI: ${it.message}") } 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( Window(
@ -108,18 +134,41 @@ fun main() = application {
} }
val currentWidget by context.currentWidget.collectAsState() val currentWidget by context.currentWidget.collectAsState()
var devModeEnabled by remember { mutableStateOf(false) }
BricksHostScreen( BricksHostScreen(
widget = currentWidget, widget = currentWidget,
actionDispatcher = actionDispatcher, actionDispatcher = actionDispatcher,
devModeEnabled = devModeEnabled,
onDevModeToggle = { devModeEnabled = !devModeEnabled },
onReload = { onReload = {
scope.launch { scope.launch {
DevLogStore.log(
level = DevLogLevel.INFO,
message = "User triggered reload",
source = DevLogSource.APP
)
runCatching { http.fetchUi(System.getProperty("bricks.entry", "/")) } runCatching { http.fetchUi(System.getProperty("bricks.entry", "/")) }
.onSuccess { context.setCurrentWidget(it) } .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 -> dialogWidget?.let { widget ->
AlertDialog( AlertDialog(
onDismissRequest = { dialogWidget = null }, onDismissRequest = { dialogWidget = null },
@ -150,6 +199,8 @@ fun main() = application {
private fun BricksHostScreen( private fun BricksHostScreen(
widget: BricksWidget?, widget: BricksWidget?,
actionDispatcher: ActionDispatcher, actionDispatcher: ActionDispatcher,
devModeEnabled: Boolean,
onDevModeToggle: () -> Unit,
onReload: () -> Unit onReload: () -> Unit
) { ) {
Scaffold( Scaffold(
@ -157,6 +208,12 @@ private fun BricksHostScreen(
TopAppBar( TopAppBar(
title = { Text("bricks-mp") }, title = { Text("bricks-mp") },
actions = { 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)) { Button(onClick = onReload, modifier = Modifier.padding(end = 8.dp)) {
Text("Reload") Text("Reload")
} }