diff --git a/test/sageclient/README.md b/test/sageclient/README.md new file mode 100644 index 0000000..b12b2eb --- /dev/null +++ b/test/sageclient/README.md @@ -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`. diff --git a/test/sageclient/build.gradle.kts b/test/sageclient/build.gradle.kts new file mode 100644 index 0000000..f204b61 --- /dev/null +++ b/test/sageclient/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.sageclient.MainKt" + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "sageclient" + packageVersion = "1.0.0" + } + } +} diff --git a/test/sageclient/build.sh b/test/sageclient/build.sh new file mode 100755 index 0000000..ecd14f7 --- /dev/null +++ b/test/sageclient/build.sh @@ -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 "$@" diff --git a/test/sageclient/gradle/libs.versions.toml b/test/sageclient/gradle/libs.versions.toml new file mode 100644 index 0000000..2a0a3cd --- /dev/null +++ b/test/sageclient/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/sageclient/settings.gradle.kts b/test/sageclient/settings.gradle.kts new file mode 100644 index 0000000..183cb70 --- /dev/null +++ b/test/sageclient/settings.gradle.kts @@ -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")) + } +} diff --git a/test/sageclient/src/jvmMain/kotlin/com/bricks/test/sageclient/Main.kt b/test/sageclient/src/jvmMain/kotlin/com/bricks/test/sageclient/Main.kt new file mode 100644 index 0000000..d67a1e7 --- /dev/null +++ b/test/sageclient/src/jvmMain/kotlin/com/bricks/test/sageclient/Main.kt @@ -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(null) } + var message by remember { mutableStateOf?>(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) + } +}