test: add Sage client sample app

This commit is contained in:
yumoqing 2026-05-18 23:28:36 +08:00
parent 4993e550db
commit d9b545577c
6 changed files with 470 additions and 0 deletions

65
test/sageclient/README.md Normal file
View File

@ -0,0 +1,65 @@
# Sage desktop client sample
This directory is an independent Compose Desktop sample app for Sage on top of the
root `bricks-mp` shared module. It is intentionally **not** included from the
root `settings.gradle.kts`, so normal library builds are unchanged.
## Build
From any directory:
```bash
/path/to/bricks-mp/test/sageclient/build.sh
```
The script:
1. resolves its own directory,
2. checks that `java` is available and is JDK 17+,
3. uses the repository Gradle wrapper when present, otherwise system `gradle`,
4. runs `gradle build` for this standalone sample.
Extra Gradle arguments can be appended, for example:
```bash
./test/sageclient/build.sh --info
```
## Run
```bash
cd /path/to/bricks-mp/test/sageclient
../../gradlew run \
-Dsage.baseUrl=http://localhost:8080 \
-Dsage.centerUi=/center.ui
```
Optional properties:
- `sage.baseUrl` defaults to `http://localhost:8080`
- `sage.centerUi` defaults to `/center.ui`
- `sage.loginAction` defaults to `/rbac/user/login`
- `sage.loginUi` defaults to `/rbac/user/login.ui`
- `sage.username` / `sage.password` prefill the login form
- `sage.lang` overrides the default JVM locale tag
## What the sample demonstrates
- Uses the generic `BricksHttp` client from the shared module for Sage login and
`center.ui` loading. Sage-specific bootstrapping lives here, not in the shared
library package.
- Loads `center.ui` via `ActionDispatcher.dispatch(BricksBind(actiontype =
"urlwidget", ...))`, so HTTP handling stays in the shared ActionDispatcher /
BricksHttp flow.
- `BricksHttp` automatically appends the correct WebBricks query parameters for
`.ui` / `.dspy` backend requests:
`_webbricks_`, `_width`, `_height`, `_is_mobile`, `_lang`.
- `BricksHttp` surfaces HTTP 403, 401 and 3xx (including 301) as
`BricksHttpException`. `ActionDispatcher` handles them generically:
- 403 loads the configured login UI (`sage.loginUi`) in a dialog,
- 401 shows the server response as an unauthorized message,
- 3xx follows the `Location` header as a UI navigation target.
The sample is wired as a Gradle composite build through `includeBuild("../..")`
and depends on the root project module using `implementation("com.bricks.mp:shared")`
with dependency substitution to `:shared`.

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.sageclient.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "sageclient"
packageVersion = "1.0.0"
}
}
}

38
test/sageclient/build.sh Executable file
View File

@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$SCRIPT_DIR"
ROOT_DIR="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
if ! command -v java >/dev/null 2>&1; then
cat >&2 <<'EOF'
ERROR: java was not found in PATH.
Please install JDK 17 or newer, then rerun this script.
EOF
exit 1
fi
JAVA_VERSION_OUTPUT="$(java -version 2>&1 | head -n 1)"
JAVA_VERSION="$(printf '%s\n' "$JAVA_VERSION_OUTPUT" | sed -E 's/.*version "([0-9]+)(\.[0-9]+)?.*/\1/')"
if ! [[ "$JAVA_VERSION" =~ ^[0-9]+$ ]]; then
echo "WARNING: Unable to parse Java version from: $JAVA_VERSION_OUTPUT" >&2
elif [ "$JAVA_VERSION" -lt 17 ]; then
echo "ERROR: JDK 17 or newer is required. Found: $JAVA_VERSION_OUTPUT" >&2
exit 1
fi
if [ -x "$ROOT_DIR/gradlew" ]; then
GRADLE_CMD=("$ROOT_DIR/gradlew")
elif command -v gradle >/dev/null 2>&1; then
GRADLE_CMD=(gradle)
else
cat >&2 <<'EOF'
ERROR: Neither root gradlew nor system gradle was found.
Run from a checkout that contains the Gradle wrapper, or install Gradle.
EOF
exit 1
fi
cd "$PROJECT_DIR"
"${GRADLE_CMD[@]}" --no-configuration-cache build "$@"

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

View File

@ -0,0 +1,26 @@
rootProject.name = "bricks-mp-sageclient-sample"
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

@ -0,0 +1,294 @@
package com.bricks.test.sageclient
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import com.bricks.mp.actions.ActionDispatcher
import com.bricks.mp.core.BricksBind
import com.bricks.mp.core.BricksContext
import com.bricks.mp.core.BricksHttp
import com.bricks.mp.core.BricksHttpException
import com.bricks.mp.core.BricksWidget
import com.bricks.mp.core.RenderWidget
import kotlinx.coroutines.launch
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.util.Locale
private const val DEFAULT_BASE_URL = "http://localhost:8080"
private const val DEFAULT_CENTER_UI = "/center.ui"
private const val DEFAULT_LOGIN_ACTION = "/rbac/user/login"
private const val DEFAULT_LOGIN_UI = "/rbac/user/login.ui"
fun main() = application {
val context = remember { BricksContext() }
val http = remember { BricksHttp(context) }
val scope = rememberCoroutineScope()
val windowState = remember { WindowState(width = 1280.dp, height = 800.dp) }
LaunchedEffect(Unit) {
context.baseUrl = System.getProperty("sage.baseUrl", DEFAULT_BASE_URL)
}
Window(
onCloseRequest = {
http.close()
exitApplication()
},
title = "Sage Bricks Client",
state = windowState
) {
MaterialTheme {
val density = LocalDensity.current
val lang = remember { System.getProperty("sage.lang", Locale.getDefault().toLanguageTag()) }
val fallbackWidthPx = with(density) { windowState.size.width.roundToPx() }
val fallbackHeightPx = with(density) { windowState.size.height.roundToPx() }
var windowSizePx by remember { mutableStateOf(IntSize(fallbackWidthPx, fallbackHeightPx)) }
var dialogWidget by remember { mutableStateOf<BricksWidget?>(null) }
var message by remember { mutableStateOf<Triple<String, String, Boolean>?>(null) }
DisposableEffect(window) {
fun updateWindowSize() {
val size = window.size
if (size.width > 0 && size.height > 0) {
windowSizePx = IntSize(size.width, size.height)
}
}
val listener = object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) = updateWindowSize()
override fun componentShown(e: ComponentEvent) = updateWindowSize()
}
updateWindowSize()
window.addComponentListener(listener)
onDispose { window.removeComponentListener(listener) }
}
LaunchedEffect(windowSizePx, lang) {
http.updateRequestContext(
width = windowSizePx.width.takeIf { it > 0 } ?: fallbackWidthPx,
height = windowSizePx.height.takeIf { it > 0 } ?: fallbackHeightPx,
isMobile = false,
lang = lang
)
}
val actionDispatcher = remember(context, http, scope) {
ActionDispatcher(context = context, http = http, scope = scope).apply {
loginUiPath = System.getProperty("sage.loginUi", DEFAULT_LOGIN_UI)
onWidgetLoaded = { widget -> context.setCurrentWidget(widget) }
onDialog = { widget, show -> dialogWidget = if (show) widget else null }
onMessage = { title, body, isError -> message = Triple(title, body, isError) }
}
}
val currentWidget by context.currentWidget.collectAsState()
SageClientScreen(
baseUrl = context.baseUrl,
widget = currentWidget,
actionDispatcher = actionDispatcher,
onBaseUrlChange = { context.baseUrl = it.trimEnd('/') },
onLoadCenter = {
actionDispatcher.dispatch(
BricksBind(
event = "click",
actiontype = "urlwidget",
url = System.getProperty("sage.centerUi", DEFAULT_CENTER_UI)
)
)
},
onLogin = { username, password ->
scope.launch {
loginAndLoadCenter(
context = context,
http = http,
actionDispatcher = actionDispatcher,
username = username,
password = password,
onMessage = { title, body, isError -> message = Triple(title, body, isError) }
)
}
}
)
dialogWidget?.let { widget ->
AlertDialog(
onDismissRequest = { dialogWidget = null },
title = { Text("Login required") },
text = { RenderWidget(widget, actionDispatcher) },
confirmButton = {
TextButton(onClick = { dialogWidget = null }) { Text("Close") }
}
)
}
message?.let { (title, body, _) ->
AlertDialog(
onDismissRequest = { message = null },
title = { Text(title) },
text = { Text(body) },
confirmButton = {
TextButton(onClick = { message = null }) { Text("OK") }
}
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SageClientScreen(
baseUrl: String,
widget: BricksWidget?,
actionDispatcher: ActionDispatcher,
onBaseUrlChange: (String) -> Unit,
onLoadCenter: () -> Unit,
onLogin: (String, String) -> Unit
) {
var editableBaseUrl by remember(baseUrl) { mutableStateOf(baseUrl) }
var username by remember { mutableStateOf(System.getProperty("sage.username", "")) }
var password by remember { mutableStateOf(System.getProperty("sage.password", "")) }
Scaffold(
topBar = {
TopAppBar(title = { Text("Sage Bricks Client") })
}
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
Row(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
OutlinedTextField(
value = editableBaseUrl,
onValueChange = {
editableBaseUrl = it
onBaseUrlChange(it)
},
label = { Text("Sage base URL") },
singleLine = true,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(8.dp))
Button(onClick = onLoadCenter, modifier = Modifier.padding(top = 8.dp)) {
Text("Load center.ui")
}
}
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) {
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
singleLine = true,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(8.dp))
Button(onClick = { onLogin(username, password) }, modifier = Modifier.padding(top = 8.dp)) {
Text("Login + load")
}
}
Box(modifier = Modifier.fillMaxSize().padding(12.dp)) {
if (widget == null) {
Text(
"Click Load center.ui to fetch Sage UI. " +
"BricksHttp automatically appends WebBricks params " +
"(_webbricks_, _width, _height, _is_mobile, _lang) for .ui/.dspy requests. " +
"HTTP 403/401/301 responses are surfaced to ActionDispatcher/BricksHttp handling."
)
} else {
RenderWidget(
widget = widget,
actionDispatcher = actionDispatcher,
modifier = Modifier.fillMaxSize()
)
}
}
}
}
}
private suspend fun loginAndLoadCenter(
context: BricksContext,
http: BricksHttp,
actionDispatcher: ActionDispatcher,
username: String,
password: String,
onMessage: (String, String, Boolean) -> Unit
) {
val loginAction = System.getProperty("sage.loginAction", DEFAULT_LOGIN_ACTION)
runCatching {
http.postForm(
url = context.entireUrl(loginAction),
form = mapOf(
"username" to username,
"password" to password
),
authToken = context.authToken
)
}.onSuccess {
onMessage("Login", "Login request completed; loading center.ui", false)
actionDispatcher.dispatch(
BricksBind(
event = "login-success",
actiontype = "urlwidget",
url = System.getProperty("sage.centerUi", DEFAULT_CENTER_UI)
)
)
}.onFailure { error ->
handleLoadFailure("Login failed", error, onMessage)
}
}
private fun handleLoadFailure(
title: String,
error: Throwable,
onMessage: (String, String, Boolean) -> Unit,
hint: String = ""
) {
if (error is BricksHttpException) {
val location = error.location?.let { "\nLocation: $it" }.orEmpty()
val extra = if (hint.isBlank()) "" else "\n$hint"
onMessage(title, "HTTP ${error.statusCode}: ${error.responseBody}$location$extra", true)
} else {
onMessage(title, error.message ?: error.toString(), true)
}
}