Initialize Meatbag SDUI platform

This commit is contained in:
2026-06-30 15:12:58 -06:00
commit 8b4cb8eace
28 changed files with 3058 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.gradle/
build/
**/build/
.idea/
*.iml
.DS_Store
local.properties
*.log

29
backend/build.gradle.kts Normal file
View File

@@ -0,0 +1,29 @@
plugins {
kotlin("jvm")
id("io.ktor.plugin")
}
group = "meatbag"
version = "1.0-SNAPSHOT"
application {
mainClass.set("meatbag.backend.ApplicationKt")
}
dependencies {
implementation(project(":common"))
implementation("io.ktor:ktor-server-core-jvm:2.3.11")
implementation("io.ktor:ktor-server-netty-jvm:2.3.11")
implementation("io.ktor:ktor-server-websockets-jvm:2.3.11")
implementation("io.ktor:ktor-server-content-negotiation-jvm:2.3.11")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:2.3.11")
implementation("io.ktor:ktor-server-cors-jvm:2.3.11")
implementation("io.ktor:ktor-server-call-logging-jvm:2.3.11")
implementation("ch.qos.logback:logback-classic:1.4.14")
// Test dependencies including Ktor client plugins for testApplication
testImplementation("io.ktor:ktor-server-tests-jvm:2.3.11")
testImplementation("io.ktor:ktor-client-content-negotiation-jvm:2.3.11")
testImplementation("io.ktor:ktor-client-websockets-jvm:2.3.11")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.9.23")
}

View File

@@ -0,0 +1,145 @@
package meatbag.backend
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.callloging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import meatbag.common.*
import java.time.Duration
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module).start(wait = true)
}
fun Application.module() {
install(CallLogging)
install(CORS) {
anyHost()
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Authorization)
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Get)
}
install(WebSockets) {
pingPeriod = Duration.ofSeconds(15)
timeout = Duration.ofSeconds(15)
maxFrameSize = Long.MAX_VALUE
masking = false
}
install(ContentNegotiation) {
json(SDUIJson.json)
}
val deviceRegistry = DeviceRegistry()
val routingEngine = RoutingEngine(deviceRegistry)
routing {
// Client WebSocket connection endpoint
webSocket("/ws/connect") {
val deviceId = call.parameters["deviceId"] ?: java.util.UUID.randomUUID().toString()
val deviceTypeStr = call.parameters["deviceType"] ?: "DESKTOP"
val deviceType = try {
DeviceType.valueOf(deviceTypeStr.uppercase())
} catch (e: IllegalArgumentException) {
DeviceType.DESKTOP
}
val device = ActiveDevice(deviceId, deviceType, this)
deviceRegistry.register(device)
println("[WebSocket] Device registered: ID=$deviceId, Type=$deviceType")
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
println("[WebSocket] Message from device $deviceId: $text")
}
}
} catch (e: Exception) {
println("[WebSocket] Connection error or close for device $deviceId: ${e.localizedMessage}")
} finally {
deviceRegistry.unregister(deviceId)
println("[WebSocket] Device unregistered: ID=$deviceId")
}
}
// Endpoint for receiving responses from clients
post("/api/respond") {
val response = try {
call.receive<ApprovalResponse>()
} catch (e: Exception) {
call.respond(HttpStatusCode.BadRequest, "Invalid Response body structure: ${e.localizedMessage}")
return@post
}
val matchedPendingRequest = routingEngine.handleResponse(response)
call.respond(
HttpStatusCode.OK,
mapOf(
"status" to "Response processed",
"matchedPendingRequest" to matchedPendingRequest
)
)
}
// Endpoint for receiving requests from agents
post("/api/request") {
val rawJson = call.receiveText()
val uiPrimitive = try {
SDUIJson.decodeFromString(rawJson)
} catch (e: Exception) {
call.respond(HttpStatusCode.BadRequest, "Invalid UI Primitive format: ${e.localizedMessage}")
return@post
}
try {
val approvalResult = routingEngine.routeRequest(
AgentRequestContext(
uiPrimitive = uiPrimitive,
urgent = call.request.queryParameters["urgent"]?.toBoolean() ?: false,
requestedAttention = parseAttentionTier(call.request.queryParameters["attentionTier"]),
preferredDeviceTypes = parseDeviceTypes(call.request.queryParameters["deviceTypes"])
)
)
call.respond(HttpStatusCode.OK, approvalResult)
} catch (e: IllegalStateException) {
call.respond(HttpStatusCode.ServiceUnavailable, mapOf("error" to e.localizedMessage))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to e.localizedMessage))
}
}
// Diagnostic / status endpoint
get("/api/status") {
val devices = deviceRegistry.getAllDevices().map { mapOf("id" to it.id, "type" to it.type.name) }
call.respond(mapOf("status" to "OK", "connectedDevices" to devices))
}
}
}
private fun parseAttentionTier(value: String?): AttentionTier {
return value
?.let { runCatching { AttentionTier.valueOf(it.uppercase()) }.getOrNull() }
?: AttentionTier.BACKGROUND_NOTIFICATION
}
private fun parseDeviceTypes(value: String?): List<DeviceType> {
return value
?.split(",")
?.mapNotNull { rawType -> runCatching { DeviceType.valueOf(rawType.trim().uppercase()) }.getOrNull() }
?.distinct()
?: emptyList()
}

View File

@@ -0,0 +1,31 @@
package meatbag.backend
import java.util.concurrent.ConcurrentHashMap
class DeviceRegistry {
private val devices = ConcurrentHashMap<String, ActiveDevice>()
fun register(device: ActiveDevice) {
devices[device.id] = device
}
fun unregister(deviceId: String) {
devices.remove(deviceId)
}
fun getDevice(deviceId: String): ActiveDevice? {
return devices[deviceId]
}
fun getAllDevices(): List<ActiveDevice> {
return devices.values.toList()
}
fun getDevicesByType(deviceTypes: Set<DeviceType>): List<ActiveDevice> {
if (deviceTypes.isEmpty()) return emptyList()
return devices.values
.filter { it.type in deviceTypes }
.sortedWith(compareBy<ActiveDevice> { it.type.ordinal }.thenBy { it.id })
}
}

View File

@@ -0,0 +1,53 @@
package meatbag.backend
import io.ktor.websocket.*
import meatbag.common.AttentionTier
import meatbag.common.UiPrimitive
enum class DeviceType {
WEAR_OS_WATCH,
PHONE,
DESKTOP
}
data class ActiveDevice(
val id: String,
val type: DeviceType,
val session: WebSocketSession
)
data class AgentRequestContext(
val uiPrimitive: UiPrimitive,
val requestedAttention: AttentionTier = AttentionTier.BACKGROUND_NOTIFICATION,
val urgent: Boolean = false,
val preferredDeviceTypes: List<DeviceType> = emptyList()
)
data class UserRuleSet(
val id: String = "mock-default",
val criticalKeywords: Set<String> = setOf(
"rm -rf",
"delete",
"destroy",
"kill",
"urgent",
"critical",
"sudo",
"permission",
"allow"
),
val watchFirstForSilentHaptic: Boolean = true,
val fanOutOverrideScreen: Boolean = true
)
data class RoutingDecision(
val attentionTier: AttentionTier,
val deviceTypes: List<DeviceType>,
val fanOut: Boolean = false
)
data class DeliveryResult(
val requestId: String,
val attentionTier: AttentionTier,
val deviceIds: List<String>
)

View File

@@ -0,0 +1,235 @@
package meatbag.backend
import io.ktor.websocket.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.serialization.encodeToString
import meatbag.common.*
import java.util.concurrent.ConcurrentHashMap
interface AgentCallback {
suspend fun onHumanResponse(response: ApprovalResponse)
}
class RecordingAgentCallback : AgentCallback {
private val responses = ConcurrentHashMap<String, ApprovalResponse>()
override suspend fun onHumanResponse(response: ApprovalResponse) {
responses[response.requestId] = response
}
fun getResponse(requestId: String): ApprovalResponse? = responses[requestId]
}
class RoutingEngine(
private val deviceRegistry: DeviceRegistry,
private val userRuleSet: UserRuleSet = UserRuleSet(),
private val agentCallback: AgentCallback = RecordingAgentCallback()
) {
// Tracks pending requests: requestId -> CompletableDeferred<ApprovalResponse>
private val pendingRequests = ConcurrentHashMap<String, CompletableDeferred<ApprovalResponse>>()
suspend fun routeRequest(
uiPrimitive: UiPrimitive,
isUrgent: Boolean,
requestedAttention: AttentionTier
): ApprovalResponse {
return routeRequest(
AgentRequestContext(
uiPrimitive = uiPrimitive,
requestedAttention = requestedAttention,
urgent = isUrgent
)
)
}
suspend fun routeRequest(context: AgentRequestContext): ApprovalResponse {
val activeDevices = deviceRegistry.getAllDevices()
if (activeDevices.isEmpty()) {
throw IllegalStateException("No active client devices connected")
}
val decision = evaluateRules(context)
val targetDevices = selectDevices(decision, activeDevices)
val deferred = CompletableDeferred<ApprovalResponse>()
pendingRequests[context.uiPrimitive.id] = deferred
println(
"Routing request ${context.uiPrimitive.id} to devices " +
targetDevices.joinToString { "${it.id} (${it.type})" } +
" with tier ${decision.attentionTier}"
)
try {
targetDevices.forEach { device ->
device.session.send(Frame.Text(payloadJson(context, decision, device)))
}
return deferred.await()
} catch (e: Exception) {
pendingRequests.remove(context.uiPrimitive.id)
throw e
}
}
suspend fun deliverRequest(context: AgentRequestContext): DeliveryResult {
val activeDevices = deviceRegistry.getAllDevices()
if (activeDevices.isEmpty()) {
throw IllegalStateException("No active client devices connected")
}
val decision = evaluateRules(context)
val targetDevices = selectDevices(decision, activeDevices)
targetDevices.forEach { device ->
device.session.send(Frame.Text(payloadJson(context, decision, device)))
}
return DeliveryResult(
requestId = context.uiPrimitive.id,
attentionTier = decision.attentionTier,
deviceIds = targetDevices.map { it.id }
)
}
fun evaluateRules(context: AgentRequestContext): RoutingDecision {
if (context.preferredDeviceTypes.isNotEmpty()) {
return RoutingDecision(
attentionTier = context.requestedAttention,
deviceTypes = context.preferredDeviceTypes,
fanOut = context.preferredDeviceTypes.size > 1
)
}
val promptRequiresAttention = (context.uiPrimitive as? ApprovalRequest)
?.prompt
?.lowercase()
?.let { prompt -> userRuleSet.criticalKeywords.any(prompt::contains) }
?: false
val attentionTier = when {
context.requestedAttention == AttentionTier.OVERRIDE_SCREEN -> AttentionTier.OVERRIDE_SCREEN
context.urgent || promptRequiresAttention -> AttentionTier.SILENT_HAPTIC
else -> context.requestedAttention
}
val deviceTypes = when (attentionTier) {
AttentionTier.SILENT_HAPTIC -> {
if (userRuleSet.watchFirstForSilentHaptic) {
listOf(DeviceType.WEAR_OS_WATCH, DeviceType.PHONE, DeviceType.DESKTOP)
} else {
listOf(DeviceType.PHONE, DeviceType.WEAR_OS_WATCH, DeviceType.DESKTOP)
}
}
AttentionTier.OVERRIDE_SCREEN -> {
if (userRuleSet.fanOutOverrideScreen) {
listOf(DeviceType.PHONE, DeviceType.DESKTOP, DeviceType.WEAR_OS_WATCH)
} else {
listOf(DeviceType.PHONE, DeviceType.DESKTOP)
}
}
AttentionTier.BACKGROUND_NOTIFICATION -> listOf(DeviceType.PHONE, DeviceType.DESKTOP, DeviceType.WEAR_OS_WATCH)
}
return RoutingDecision(
attentionTier = attentionTier,
deviceTypes = deviceTypes,
fanOut = attentionTier == AttentionTier.OVERRIDE_SCREEN && userRuleSet.fanOutOverrideScreen
)
}
suspend fun handleResponse(response: ApprovalResponse): Boolean {
val deferred = pendingRequests.remove(response.requestId)
if (deferred != null) {
agentCallback.onHumanResponse(response)
deferred.complete(response)
println("Completed request ${response.requestId} with approved=${response.approved}")
return true
} else {
println("No pending request found for ID ${response.requestId}")
return false
}
}
private fun selectDevices(decision: RoutingDecision, activeDevices: List<ActiveDevice>): List<ActiveDevice> {
val orderedMatches = decision.deviceTypes.flatMap { desiredType ->
activeDevices.filter { it.type == desiredType }.sortedBy { it.id }
}
return when {
orderedMatches.isNotEmpty() && decision.fanOut -> orderedMatches
orderedMatches.isNotEmpty() -> listOf(orderedMatches.first())
else -> listOf(activeDevices.sortedWith(compareBy<ActiveDevice> { it.type.ordinal }.thenBy { it.id }).first())
}
}
private fun payloadJson(
context: AgentRequestContext,
decision: RoutingDecision,
device: ActiveDevice
): String {
return SDUIJson.json.encodeToString(
WebSocketPayload(
attentionTier = decision.attentionTier,
uiPrimitive = context.uiPrimitive,
request = RequestContext(
requestId = context.uiPrimitive.id,
source = RequestSource(
system = "agent",
reason = if (context.urgent) "urgent" else null
)
),
deviceContext = device.toDeviceContext(),
attentionPolicy = AttentionPolicy(
tier = decision.attentionTier,
hapticPattern = decision.attentionTier.toHapticPattern(),
presentation = decision.toPresentationMode()
)
)
)
}
private fun ActiveDevice.toDeviceContext(): DeviceContext {
return when (type) {
DeviceType.WEAR_OS_WATCH -> DeviceContext(
deviceId = id,
deviceClass = DeviceClass.WEARABLE,
platform = DevicePlatform.WEAR_OS,
capabilities = setOf(DeviceCapability.HAPTICS, DeviceCapability.NOTIFICATIONS)
)
DeviceType.PHONE -> DeviceContext(
deviceId = id,
deviceClass = DeviceClass.PHONE,
platform = DevicePlatform.ANDROID,
capabilities = setOf(
DeviceCapability.HAPTICS,
DeviceCapability.NOTIFICATIONS,
DeviceCapability.FULL_SCREEN_INTENT,
DeviceCapability.TEXT_INPUT
)
)
DeviceType.DESKTOP -> DeviceContext(
deviceId = id,
deviceClass = DeviceClass.DESKTOP,
platform = DevicePlatform.DESKTOP,
capabilities = setOf(DeviceCapability.NOTIFICATIONS, DeviceCapability.TEXT_INPUT)
)
}
}
private fun AttentionTier.toHapticPattern(): HapticPattern {
return when (this) {
AttentionTier.SILENT_HAPTIC -> HapticPattern.DOUBLE_SHORT
AttentionTier.BACKGROUND_NOTIFICATION -> HapticPattern.SHORT
AttentionTier.OVERRIDE_SCREEN -> HapticPattern.URGENT_LOOP
}
}
private fun RoutingDecision.toPresentationMode(): PresentationMode {
return when {
fanOut -> PresentationMode.ALL_DEVICES
deviceTypes.firstOrNull() == DeviceType.WEAR_OS_WATCH -> PresentationMode.WATCH_ONLY
deviceTypes.firstOrNull() == DeviceType.PHONE -> PresentationMode.PHONE_ONLY
else -> PresentationMode.AUTO
}
}
}

View File

@@ -0,0 +1,14 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
</configuration>

View File

@@ -0,0 +1,170 @@
package meatbag.backend
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.websocket.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.testing.*
import io.ktor.websocket.*
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import meatbag.common.*
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class RoutingEngineTest {
@Test
fun testCompleteAgentRoutingFlow() = testApplication {
// Configure Ktor backend module
application {
module()
}
// Client for WebSocket connection
val wsClient = createClient {
install(WebSockets)
install(ContentNegotiation) {
json(SDUIJson.json)
}
}
// Client for HTTP API calls
val httpClient = createClient {
install(ContentNegotiation) {
json(SDUIJson.json)
}
}
// Run the client WebSocket session and agent HTTP request concurrently
wsClient.webSocket("/ws/connect?deviceType=WEAR_OS_WATCH&deviceId=watch_123") {
val agentResponseDeferred = async {
val approvalReq = ApprovalRequest(
id = "req_999",
prompt = "Urgent: Allow system update?",
target = "system-agent"
)
// Post approval request to backend polymorphically
httpClient.post("/api/request") {
contentType(ContentType.Application.Json)
setBody<UiPrimitive>(approvalReq)
}
}
// Client receives the websocket notification frame
val frame = incoming.receive()
assertTrue(frame is Frame.Text, "Expected text frame")
val textFrame = frame as Frame.Text
val payloadJson = textFrame.readText()
// Decode payload using common module's serializer
val payload = SDUIJson.json.decodeFromString<WebSocketPayload>(payloadJson)
// Due to "Urgent" in prompt, it should resolve to SILENT_HAPTIC and target watch_123
assertEquals(AttentionTier.SILENT_HAPTIC, payload.attentionTier)
assertEquals("req_999", payload.uiPrimitive.id)
assertTrue(payload.uiPrimitive is ApprovalRequest)
assertEquals("Urgent: Allow system update?", (payload.uiPrimitive as ApprovalRequest).prompt)
// Simulate user clicking Yes on their watch by sending response to /api/respond
val respondResponse = httpClient.post("/api/respond") {
contentType(ContentType.Application.Json)
setBody(ApprovalResponse(requestId = "req_999", approved = true))
}
assertEquals(HttpStatusCode.OK, respondResponse.status)
// Await agent response and verify the callback output
val agentResponse = agentResponseDeferred.await()
assertEquals(HttpStatusCode.OK, agentResponse.status)
val result = agentResponse.body<ApprovalResponse>()
assertEquals("req_999", result.requestId)
assertEquals(true, result.approved)
}
}
@Test
fun testOverrideScreenFansOutToPhoneAndDesktop() = testApplication {
application {
module()
}
val wsClient = createClient {
install(WebSockets)
install(ContentNegotiation) {
json(SDUIJson.json)
}
}
val httpClient = createClient {
install(ContentNegotiation) {
json(SDUIJson.json)
}
}
val phonePayloadDeferred = async {
wsClient.webSocket("/ws/connect?deviceType=PHONE&deviceId=phone_123") {
val frame = incoming.receive()
assertTrue(frame is Frame.Text, "Expected text frame")
SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
}
}
val desktopPayloadDeferred = async {
wsClient.webSocket("/ws/connect?deviceType=DESKTOP&deviceId=desktop_123") {
val frame = incoming.receive()
assertTrue(frame is Frame.Text, "Expected text frame")
SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
}
}
var connected = false
var attempts = 0
while (!connected && attempts < 20) {
val response = httpClient.get("/api/status")
val body = response.bodyAsText()
if (body.contains("phone_123") && body.contains("desktop_123")) {
connected = true
break
}
attempts += 1
delay(25)
}
assertTrue(connected, "Expected both devices to connect before routing")
val agentResponseDeferred = async {
httpClient.post("/api/request?attentionTier=OVERRIDE_SCREEN") {
contentType(ContentType.Application.Json)
setBody<UiPrimitive>(
ApprovalRequest(
id = "req_override",
prompt = "Approve remote screen takeover?",
target = "system-agent"
)
)
}
}
val phonePayload = phonePayloadDeferred.await()
val desktopPayload = desktopPayloadDeferred.await()
assertEquals(AttentionTier.OVERRIDE_SCREEN, phonePayload.attentionTier)
assertEquals(AttentionTier.OVERRIDE_SCREEN, desktopPayload.attentionTier)
assertEquals("req_override", phonePayload.uiPrimitive.id)
assertEquals("req_override", desktopPayload.uiPrimitive.id)
val respondResponse = httpClient.post("/api/respond") {
contentType(ContentType.Application.Json)
setBody(ApprovalResponse(requestId = "req_override", approved = false))
}
assertEquals(HttpStatusCode.OK, respondResponse.status)
val agentResponse = agentResponseDeferred.await()
assertEquals(HttpStatusCode.OK, agentResponse.status)
val result = agentResponse.body<ApprovalResponse>()
assertEquals("req_override", result.requestId)
assertEquals(false, result.approved)
}
}

9
build.gradle.kts Normal file
View File

@@ -0,0 +1,9 @@
plugins {
// Note: Use matching versions of Kotlin and Compose Multiplatform
kotlin("multiplatform") version "1.9.23" apply false
kotlin("jvm") version "1.9.23" apply false
kotlin("plugin.serialization") version "1.9.23" apply false
id("org.jetbrains.compose") version "1.6.11" apply false
id("io.ktor.plugin") version "2.3.11" apply false
id("com.android.library") version "8.2.2" apply false
}

81
client/build.gradle.kts Normal file
View File

@@ -0,0 +1,81 @@
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
kotlin("plugin.serialization")
id("com.android.library")
}
kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = "17"
}
}
}
jvm("desktop") {
compilations.all {
kotlinOptions {
jvmTarget = "17"
}
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(project(":common"))
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
// Ktor client dependencies for WebSocket connection
implementation("io.ktor:ktor-client-core:2.3.11")
implementation("io.ktor:ktor-client-websockets:2.3.11")
implementation("io.ktor:ktor-client-cio:2.3.11")
implementation("io.ktor:ktor-client-content-negotiation:2.3.11")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.11")
}
}
val desktopMain by getting {
dependencies {
implementation(compose.desktop.currentOs)
}
}
val wearMain by creating {
dependsOn(commonMain)
}
val androidMain by getting {
dependsOn(wearMain)
dependencies {
implementation("androidx.activity:activity-compose:1.8.2")
}
}
}
}
android {
namespace = "meatbag.client"
compileSdk = 35
defaultConfig {
minSdk = 26
}
}
compose.desktop {
application {
mainClass = "meatbag.client.MainKt"
nativeDistributions {
targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg)
packageName = "meatbag-client"
packageVersion = "1.0.0"
}
}
}

View File

@@ -0,0 +1,25 @@
package meatbag.client
import meatbag.common.AttentionTier
class AndroidAttentionFeedback(
private val notify: suspend (AndroidAttentionSignal) -> Unit
) : AttentionFeedback {
override suspend fun onAttentionTier(tier: AttentionTier) {
notify(tier.toAndroidAttentionSignal())
}
}
enum class AndroidAttentionSignal {
SilentHaptic,
BackgroundNotification,
OverrideScreen
}
fun AttentionTier.toAndroidAttentionSignal(): AndroidAttentionSignal {
return when (this) {
AttentionTier.SILENT_HAPTIC -> AndroidAttentionSignal.SilentHaptic
AttentionTier.BACKGROUND_NOTIFICATION -> AndroidAttentionSignal.BackgroundNotification
AttentionTier.OVERRIDE_SCREEN -> AndroidAttentionSignal.OverrideScreen
}
}

View File

@@ -0,0 +1,11 @@
package meatbag.client
import meatbag.common.AttentionTier
interface AttentionFeedback {
suspend fun onAttentionTier(tier: AttentionTier)
}
object NoOpAttentionFeedback : AttentionFeedback {
override suspend fun onAttentionTier(tier: AttentionTier) = Unit
}

View File

@@ -0,0 +1,476 @@
package meatbag.client
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.draw.clip
import androidx.compose.ui.graphics.Brush
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.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import meatbag.common.ActionList
import meatbag.common.ApprovalResponse
import meatbag.common.ApprovalRequest
import meatbag.common.Message
import meatbag.common.TextInput
import meatbag.common.UiPrimitive
import meatbag.common.WebSocketPayload
val DeepBg = Color(0xFF0F111A)
val CardBg = Color(0xFF1E2235)
val AccentTeal = Color(0xFF00F2FE)
val AccentPurple = Color(0xFF9B51E0)
val DangerRed = Color(0xFFFF4D4D)
val SuccessGreen = Color(0xFF27AE60)
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFF8E92B2)
data class ComponentRenderState(
val payload: WebSocketPayload
)
sealed interface ComponentIntent {
val requestId: String
data class ApprovalDecision(
override val requestId: String,
val approved: Boolean
) : ComponentIntent
data class ActionSelected(
override val requestId: String,
val actionId: String
) : ComponentIntent
data class TextSubmitted(
override val requestId: String,
val text: String
) : ComponentIntent
data class Acknowledged(
override val requestId: String,
val actionId: String? = null
) : ComponentIntent
}
fun ComponentIntent.toApprovalResponse(): ApprovalResponse {
return when (this) {
is ComponentIntent.Acknowledged -> ApprovalResponse(
requestId = requestId,
approved = true,
actionId = actionId
)
is ComponentIntent.ActionSelected -> ApprovalResponse(
requestId = requestId,
approved = true,
actionId = actionId
)
is ComponentIntent.ApprovalDecision -> ApprovalResponse(
requestId = requestId,
approved = approved
)
is ComponentIntent.TextSubmitted -> ApprovalResponse(
requestId = requestId,
approved = true,
text = text
)
}
}
@Composable
fun MeatbagComponentHost(
state: ComponentRenderState,
onIntent: (ComponentIntent) -> Unit,
modifier: Modifier = Modifier
) {
ComponentRegistry.Render(
primitive = state.payload.uiPrimitive,
onIntent = onIntent,
modifier = modifier
)
}
object ComponentRegistry {
private val renderers = listOf(
ApprovalRequestRenderer,
ActionListRenderer,
TextInputRenderer,
MessageRenderer
)
@Composable
fun Render(
primitive: UiPrimitive,
onIntent: (ComponentIntent) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(16.dp)
.clip(RoundedCornerShape(24.dp))
.background(CardBg)
.border(
1.dp,
Brush.linearGradient(
listOf(
AccentTeal.copy(alpha = 0.3f),
AccentPurple.copy(alpha = 0.3f)
)
),
RoundedCornerShape(24.dp)
)
.padding(24.dp),
contentAlignment = Alignment.Center
) {
val renderer = renderers.firstOrNull { it.accepts(primitive) }
?: error("No renderer registered for primitive id=${primitive.id}")
renderer.Render(primitive, onIntent)
}
}
}
private interface PrimitiveRenderer {
fun accepts(primitive: UiPrimitive): Boolean
@Composable
fun Render(
primitive: UiPrimitive,
onIntent: (ComponentIntent) -> Unit
)
}
private object ApprovalRequestRenderer : PrimitiveRenderer {
override fun accepts(primitive: UiPrimitive): Boolean = primitive is ApprovalRequest
@Composable
override fun Render(
primitive: UiPrimitive,
onIntent: (ComponentIntent) -> Unit
) {
ApprovalRequestComponent(
request = primitive as ApprovalRequest,
onDecision = { approved ->
onIntent(ComponentIntent.ApprovalDecision(primitive.id, approved))
}
)
}
}
private object ActionListRenderer : PrimitiveRenderer {
override fun accepts(primitive: UiPrimitive): Boolean = primitive is ActionList
@Composable
override fun Render(
primitive: UiPrimitive,
onIntent: (ComponentIntent) -> Unit
) {
ActionListComponent(
list = primitive as ActionList,
onActionSelected = { actionId ->
onIntent(ComponentIntent.ActionSelected(primitive.id, actionId))
}
)
}
}
private object TextInputRenderer : PrimitiveRenderer {
override fun accepts(primitive: UiPrimitive): Boolean = primitive is TextInput
@Composable
override fun Render(
primitive: UiPrimitive,
onIntent: (ComponentIntent) -> Unit
) {
TextInputComponent(
input = primitive as TextInput,
onSubmit = { text ->
onIntent(ComponentIntent.TextSubmitted(primitive.id, text))
}
)
}
}
private object MessageRenderer : PrimitiveRenderer {
override fun accepts(primitive: UiPrimitive): Boolean = primitive is Message
@Composable
override fun Render(
primitive: UiPrimitive,
onIntent: (ComponentIntent) -> Unit
) {
MessageComponent(
message = primitive as Message,
onAcknowledge = { actionId ->
onIntent(ComponentIntent.Acknowledged(primitive.id, actionId))
}
)
}
}
@Composable
fun ApprovalRequestComponent(
request: ApprovalRequest,
onDecision: (approved: Boolean) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "TARGET: ${request.target.uppercase()}",
color = AccentTeal,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.background(AccentTeal.copy(alpha = 0.1f), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
)
Text(
text = "Approval Request",
color = TextPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(DeepBg, RoundedCornerShape(12.dp))
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.padding(16.dp)
) {
Text(
text = request.prompt,
color = Color.White,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Button(
onClick = { onDecision(false) },
colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.weight(1f)
.height(48.dp)
) {
Text("Reject", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
Button(
onClick = { onDecision(true) },
colors = ButtonDefaults.buttonColors(backgroundColor = SuccessGreen),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.weight(1f)
.height(48.dp)
) {
Text("Approve", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
}
}
}
@Composable
fun ActionListComponent(
list: ActionList,
onActionSelected: (actionId: String) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = list.title,
color = TextPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
list.actions.forEach { actionItem ->
Button(
onClick = { onActionSelected(actionItem.id) },
colors = ButtonDefaults.buttonColors(backgroundColor = DeepBg),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.border(
1.dp,
Brush.linearGradient(
listOf(
AccentTeal.copy(alpha = 0.5f),
AccentPurple.copy(alpha = 0.5f)
)
),
RoundedCornerShape(12.dp)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = actionItem.label,
color = TextPrimary,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
Text(
text = ">",
color = AccentTeal,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
@Composable
fun TextInputComponent(
input: TextInput,
onSubmit: (text: String) -> Unit
) {
var textVal by remember(input.id) { mutableStateOf("") }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Input Required",
color = TextPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Text(
text = input.prompt,
color = TextSecondary,
fontSize = 14.sp,
textAlign = TextAlign.Center
)
OutlinedTextField(
value = textVal,
onValueChange = { textVal = it },
placeholder = { Text(input.placeholder, color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(
textColor = TextPrimary,
focusedBorderColor = AccentTeal,
unfocusedBorderColor = TextSecondary.copy(alpha = 0.5f),
cursorColor = AccentTeal
),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = { onSubmit(textVal) },
colors = ButtonDefaults.buttonColors(backgroundColor = AccentPurple),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
enabled = textVal.isNotBlank()
) {
Text("Submit", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
}
}
@Composable
fun MessageComponent(
message: Message,
onAcknowledge: (actionId: String?) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = message.title,
color = TextPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(DeepBg, RoundedCornerShape(12.dp))
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.padding(16.dp)
) {
Text(
text = message.body,
color = TextSecondary,
fontSize = 14.sp,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
}
Button(
onClick = { onAcknowledge(message.primaryAction?.id) },
colors = ButtonDefaults.buttonColors(backgroundColor = AccentTeal),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
enabled = message.primaryAction?.enabled ?: true
) {
Text(
text = message.primaryAction?.label ?: "Acknowledge",
color = DeepBg,
fontWeight = FontWeight.Bold,
fontSize = 15.sp
)
}
}
}

View File

@@ -0,0 +1,169 @@
package meatbag.client
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.websocket.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.websocket.readText
import io.ktor.serialization.kotlinx.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.json.Json
import meatbag.common.AttentionTier
import meatbag.common.WebSocketPayload
import meatbag.common.ApprovalResponse
import meatbag.common.toWebSocketPayload
import meatbag.common.toJson
sealed interface ClientEvent {
data class HapticTrigger(val tier: AttentionTier, val message: String) : ClientEvent
data class LogMessage(val text: String, val type: LogType = LogType.INFO) : ClientEvent
}
enum class LogType {
INFO, SUCCESS, WARNING, ERROR, HAPTIC
}
class MeatbagClient(
private val host: String = "localhost",
private val port: Int = 8080,
private val attentionFeedback: AttentionFeedback = NoOpAttentionFeedback
) {
private val jsonConfig = Json {
ignoreUnknownKeys = true
classDiscriminator = "type"
}
private val client = HttpClient(CIO) {
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(jsonConfig)
}
install(ContentNegotiation) {
json(jsonConfig)
}
}
private val _currentPayload = MutableStateFlow<WebSocketPayload?>(null)
val currentPayload = _currentPayload.asStateFlow()
private val _events = MutableSharedFlow<ClientEvent>(extraBufferCapacity = 100)
val events = _events.asSharedFlow()
private val _isConnected = MutableStateFlow(false)
val isConnected = _isConnected.asStateFlow()
private var wsSession: DefaultClientWebSocketSession? = null
private var connectionJob: Job? = null
fun connect(deviceType: String) {
connectionJob?.cancel()
connectionJob = CoroutineScope(Dispatchers.Default).launch {
while (isActive) {
try {
log("Connecting to ws://$host:$port/ws/connect?deviceType=$deviceType ...")
client.webSocket(
method = HttpMethod.Get,
host = host,
port = port,
path = "/ws/connect?deviceType=$deviceType"
) {
wsSession = this
_isConnected.value = true
log("Connected successfully as $deviceType!", LogType.SUCCESS)
for (frame in incoming) {
when (frame) {
is io.ktor.websocket.Frame.Text -> {
val text = frame.readText()
log("Received WebSocket frame: $text")
try {
val payload = text.toWebSocketPayload()
log("Parsed payload: primitive=${payload.uiPrimitive.id}, tier=${payload.attentionTier}", LogType.SUCCESS)
// Trigger haptics, alerts and audio simulations
handleAttentionTier(payload.attentionTier)
// Update local state
_currentPayload.value = payload
} catch (e: Exception) {
log("Failed to parse WebSocketPayload: ${e.message}", LogType.ERROR)
}
}
else -> {}
}
}
}
} catch (e: Exception) {
_isConnected.value = false
log("WebSocket error/disconnect: ${e.message}. Reconnecting in 3s...", LogType.WARNING)
delay(3000)
} finally {
_isConnected.value = false
wsSession = null
}
}
}
}
fun disconnect() {
connectionJob?.cancel()
wsSession = null
_isConnected.value = false
log("Disconnected by user.")
}
private suspend fun handleAttentionTier(tier: AttentionTier) {
attentionFeedback.onAttentionTier(tier)
val message = when (tier) {
AttentionTier.SILENT_HAPTIC -> {
"[VIBRATION] Double short haptic pulses triggered silently on Wear OS."
}
AttentionTier.BACKGROUND_NOTIFICATION -> {
"[SOUND/BANNER] Play standard notification chime + display overlay banner."
}
AttentionTier.OVERRIDE_SCREEN -> {
"[OVERRIDE] Play high-priority alert loop + lock display and takeover interface."
}
}
_events.emit(ClientEvent.HapticTrigger(tier, message))
log("Attention Action: $message", LogType.HAPTIC)
}
suspend fun respond(response: ApprovalResponse): Boolean {
return try {
log("Sending response to POST http://$host:$port/api/respond : ${response.toJson()}")
val httpResponse = client.post("http://$host:$port/api/respond") {
contentType(ContentType.Application.Json)
setBody(response)
}
if (httpResponse.status.isSuccess()) {
log("Response submitted successfully! Server returned 200 OK", LogType.SUCCESS)
if (_currentPayload.value?.uiPrimitive?.id == response.requestId) {
_currentPayload.value = null
}
true
} else {
log("Server rejected response: ${httpResponse.status} - ${httpResponse.bodyAsText()}", LogType.ERROR)
false
}
} catch (e: Exception) {
log("Error sending response: ${e.message}", LogType.ERROR)
false
}
}
private fun log(message: String, type: LogType = LogType.INFO) {
_events.tryEmit(ClientEvent.LogMessage(message, type))
}
fun injectMockEvent(event: ClientEvent) {
_events.tryEmit(event)
}
}

View File

@@ -0,0 +1,7 @@
package meatbag.client
import meatbag.common.AttentionTier
class DesktopAttentionFeedback : AttentionFeedback {
override suspend fun onAttentionTier(tier: AttentionTier) = Unit
}

View File

@@ -0,0 +1,658 @@
package meatbag.client
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import meatbag.common.*
import java.time.LocalTime
import java.time.format.DateTimeFormatter
// Theme Palette (Dark Cyberpunk / Glassmorphism)
val SpaceDark = Color(0xFF090A0F)
val SurfaceDark = Color(0xFF131520)
val ConsoleDark = Color(0xFF07080D)
val AccentCyan = Color(0xFF00E5FF)
val AccentPurpleGlow = Color(0xFFBD00FF)
val ActiveGreen = Color(0xFF00FF66)
val AlertOrange = Color(0xFFFF9900)
data class LogEntry(
val timestamp: String,
val text: String,
val type: LogType
)
private fun handleComponentIntent(
intent: ComponentIntent,
localMockPayload: WebSocketPayload?,
client: MeatbagClient,
onMockResolved: () -> Unit,
scope: CoroutineScope
) {
val response = intent.toApprovalResponse()
scope.launch {
if (localMockPayload?.uiPrimitive?.id == response.requestId) {
client.injectMockEvent(
ClientEvent.LogMessage(
"Mock response triggered: ${response.toJson()}",
LogType.SUCCESS
)
)
onMockResolved()
} else {
client.respond(response)
}
}
}
fun main() = application {
val windowState = rememberWindowState(width = 1280.dp, height = 800.dp)
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Meatbag SDUI Client Simulator"
) {
MaterialTheme(
colors = darkColors(
primary = AccentTeal,
secondary = AccentPurple,
background = SpaceDark,
surface = SurfaceDark
)
) {
SimulatorApp()
}
}
}
@Composable
fun SimulatorApp() {
var client by remember { mutableStateOf(MeatbagClient(attentionFeedback = DesktopAttentionFeedback())) }
val scope = rememberCoroutineScope()
val networkPayload by client.currentPayload.collectAsState()
var localMockPayload by remember { mutableStateOf<WebSocketPayload?>(null) }
val activePayload = localMockPayload ?: networkPayload
val isConnected by client.isConnected.collectAsState()
var deviceType by remember { mutableStateOf("WEAR_OS_WATCH") }
var hostInput by remember { mutableStateOf("localhost") }
var portInput by remember { mutableStateOf("8080") }
val logs = remember { mutableStateListOf<LogEntry>() }
val lazyListState = rememberLazyListState()
var bannerMessage by remember { mutableStateOf<String?>(null) }
var bannerTier by remember { mutableStateOf<AttentionTier?>(null) }
var showBanner by remember { mutableStateOf(false) }
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
// Listen to client events
LaunchedEffect(client) {
client.events.collectLatest { event ->
val timestamp = LocalTime.now().format(timeFormatter)
when (event) {
is ClientEvent.LogMessage -> {
logs.add(LogEntry(timestamp, event.text, event.type))
// Auto scroll to bottom
if (logs.size > 0) {
lazyListState.animateScrollToItem(logs.size - 1)
}
}
is ClientEvent.HapticTrigger -> {
// Show visual banner in simulator
bannerTier = event.tier
bannerMessage = event.message
showBanner = true
// Auto hide banner unless it is OVERRIDE_SCREEN
if (event.tier != AttentionTier.OVERRIDE_SCREEN) {
scope.launch {
kotlinx.coroutines.delay(4000)
showBanner = false
}
}
}
}
}
}
// Reset banner when payload is resolved/dismissed
LaunchedEffect(activePayload) {
if (activePayload == null) {
showBanner = false
bannerMessage = null
bannerTier = null
}
}
Row(
modifier = Modifier
.fillMaxSize()
.background(SpaceDark)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// COLUMN 1: CONTROL PANEL & MOCKS
Column(
modifier = Modifier
.width(320.dp)
.fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Connection Card
Card(
backgroundColor = SurfaceDark,
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Connection Settings",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
OutlinedTextField(
value = hostInput,
onValueChange = { hostInput = it },
label = { Text("Host", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = portInput,
onValueChange = { portInput = it },
label = { Text("Port", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
// Device Selector
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Simulated Device Profile", color = TextSecondary, fontSize = 12.sp)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
val profiles = listOf("WEAR_OS_WATCH", "PHONE", "DESKTOP")
profiles.forEach { profile ->
val isSelected = deviceType == profile
Button(
onClick = {
deviceType = profile
if (isConnected) {
client.disconnect()
client.connect(profile)
}
},
colors = ButtonDefaults.buttonColors(
backgroundColor = if (isSelected) AccentTeal else SpaceDark
),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.weight(1f).height(36.dp),
contentPadding = PaddingValues(2.dp)
) {
Text(
profile.replace("WEAR_OS_", ""),
color = if (isSelected) DeepBg else Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
// Connection Action
Button(
onClick = {
if (isConnected) {
client.disconnect()
} else {
client.disconnect()
client = MeatbagClient(
host = hostInput,
port = portInput.toIntOrNull() ?: 8080,
attentionFeedback = DesktopAttentionFeedback()
)
client.connect(deviceType)
}
},
colors = ButtonDefaults.buttonColors(
backgroundColor = if (isConnected) DangerRed else SuccessGreen
),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth().height(48.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (isConnected) ActiveGreen else Color.Gray)
)
Text(
if (isConnected) "Disconnect" else "Connect WebSocket",
color = Color.White,
fontWeight = FontWeight.Bold
)
}
}
}
}
// Mock Data Injector Card
Card(
backgroundColor = SurfaceDark,
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)),
modifier = Modifier.weight(1f)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Local Ping-Pong Mocking",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Text(
"Trigger mock payloads to test layout and events without Ktor server running.",
color = TextSecondary,
fontSize = 12.sp
)
Button(
onClick = {
localMockPayload = WebSocketPayload(
attentionTier = AttentionTier.SILENT_HAPTIC,
uiPrimitive = ApprovalRequest(
id = "mock_approval_1",
prompt = "Allow agent to execute 'git push --force origin main'?",
target = "deploy-bot"
)
)
client.injectMockEvent(ClientEvent.LogMessage("Mock Triggered: ApprovalRequest with SILENT_HAPTIC", LogType.INFO))
client.injectMockEvent(ClientEvent.HapticTrigger(AttentionTier.SILENT_HAPTIC, "[VIBRATION] Double short haptic pulses triggered silently on Wear OS."))
},
colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark),
shape = RoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth().height(44.dp).border(1.dp, AccentTeal.copy(alpha = 0.4f), RoundedCornerShape(10.dp))
) {
Text("Mock Approval (Silent Haptic)", color = AccentTeal, fontSize = 12.sp)
}
Button(
onClick = {
localMockPayload = WebSocketPayload(
attentionTier = AttentionTier.BACKGROUND_NOTIFICATION,
uiPrimitive = ActionList(
id = "mock_action_2",
title = "Select Environment to Deploy",
actions = listOf(
ActionItem("staging", "Staging Environment (UAT)"),
ActionItem("prod", "Production (US-EAST-1)"),
ActionItem("sandbox", "Sandbox Playpen")
)
)
)
client.injectMockEvent(ClientEvent.LogMessage("Mock Triggered: ActionList with BACKGROUND_NOTIFICATION", LogType.INFO))
client.injectMockEvent(ClientEvent.HapticTrigger(AttentionTier.BACKGROUND_NOTIFICATION, "[SOUND/BANNER] Play standard notification chime + display overlay banner."))
},
colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark),
shape = RoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth().height(44.dp).border(1.dp, AccentTeal.copy(alpha = 0.4f), RoundedCornerShape(10.dp))
) {
Text("Mock ActionList (Banner Alert)", color = AccentTeal, fontSize = 12.sp)
}
Button(
onClick = {
localMockPayload = WebSocketPayload(
attentionTier = AttentionTier.OVERRIDE_SCREEN,
uiPrimitive = TextInput(
id = "mock_input_3",
prompt = "CRITICAL: Database Migration has failed. Type 'CONTINUE' to bypass or 'ABORT' to rollback.",
placeholder = "Type command here..."
)
)
client.injectMockEvent(ClientEvent.LogMessage("Mock Triggered: TextInput with OVERRIDE_SCREEN", LogType.INFO))
client.injectMockEvent(ClientEvent.HapticTrigger(AttentionTier.OVERRIDE_SCREEN, "[OVERRIDE] Play high-priority alert loop + lock display and takeover interface."))
},
colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark),
shape = RoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth().height(44.dp).border(1.dp, AccentTeal.copy(alpha = 0.4f), RoundedCornerShape(10.dp))
) {
Text("Mock TextInput (Override)", color = AccentTeal, fontSize = 12.sp)
}
Spacer(modifier = Modifier.weight(1f))
if (activePayload != null) {
Button(
onClick = {
localMockPayload = null
client.injectMockEvent(ClientEvent.LogMessage("Mock state cleared.", LogType.INFO))
},
colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed.copy(alpha = 0.2f)),
shape = RoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth().height(40.dp).border(1.dp, DangerRed, RoundedCornerShape(10.dp))
) {
Text("Clear Active UI", color = DangerRed, fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
}
}
}
// COLUMN 2: SIMULATOR CANVAS (CENTER)
Column(
modifier = Modifier
.weight(1.5f)
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Visual Attention Banner (Overlay effect)
if (showBanner && bannerMessage != null) {
val bannerColor = when (bannerTier) {
AttentionTier.SILENT_HAPTIC -> AccentTeal
AttentionTier.BACKGROUND_NOTIFICATION -> AlertOrange
AttentionTier.OVERRIDE_SCREEN -> DangerRed
else -> AccentTeal
}
Card(
backgroundColor = bannerColor,
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = when (bannerTier) {
AttentionTier.SILENT_HAPTIC -> Icons.Default.Notifications
AttentionTier.BACKGROUND_NOTIFICATION -> Icons.Default.Info
AttentionTier.OVERRIDE_SCREEN -> Icons.Default.Warning
else -> Icons.Default.Notifications
},
contentDescription = "Alert",
tint = Color.White
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "ATTENTION TARGET: ${bannerTier?.name}",
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 11.sp
)
Text(
text = bannerMessage ?: "",
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
}
if (bannerTier != AttentionTier.OVERRIDE_SCREEN) {
IconButton(onClick = { showBanner = false }) {
Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White)
}
}
}
}
}
Text(
text = "SIMULATOR DEVICE: $deviceType",
color = TextSecondary,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
// Device frame rendering
if (deviceType == "WEAR_OS_WATCH") {
// Round watch frame
Box(
modifier = Modifier
.size(360.dp)
.clip(CircleShape)
.background(Color.Black)
.border(6.dp, Color(0xFF3A3E52), CircleShape)
.border(10.dp, Brush.sweepGradient(listOf(AccentTeal, AccentPurple, AccentTeal)), CircleShape)
.padding(24.dp),
contentAlignment = Alignment.Center
) {
if (activePayload != null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
MeatbagComponentHost(
state = ComponentRenderState(activePayload!!),
onIntent = { intent ->
handleComponentIntent(
intent = intent,
localMockPayload = localMockPayload,
client = client,
onMockResolved = { localMockPayload = null },
scope = scope
)
}
)
}
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Refresh, contentDescription = "Idle", tint = TextSecondary, modifier = Modifier.size(32.dp))
Spacer(modifier = Modifier.height(8.dp))
Text(
"Watch Idle",
color = TextSecondary,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
Text(
"Awaiting payload...",
color = TextSecondary.copy(alpha = 0.6f),
fontSize = 11.sp
)
}
}
}
} else {
// Phone / Desktop rectangular frame
val frameWidth = if (deviceType == "PHONE") 340.dp else 460.dp
val frameHeight = if (deviceType == "PHONE") 580.dp else 360.dp
Box(
modifier = Modifier
.size(width = frameWidth, height = frameHeight)
.clip(RoundedCornerShape(24.dp))
.background(SpaceDark)
.border(6.dp, Color(0xFF2C2F3E), RoundedCornerShape(24.dp))
.border(1.dp, AccentPurple, RoundedCornerShape(24.dp))
.padding(16.dp),
contentAlignment = Alignment.Center
) {
if (activePayload != null) {
MeatbagComponentHost(
state = ComponentRenderState(activePayload!!),
onIntent = { intent ->
handleComponentIntent(
intent = intent,
localMockPayload = localMockPayload,
client = client,
onMockResolved = { localMockPayload = null },
scope = scope
)
}
)
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Refresh, contentDescription = "Idle", tint = TextSecondary, modifier = Modifier.size(36.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(
"$deviceType Idle",
color = TextSecondary,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
Text(
"Waiting for Ktor WebSocket payload",
color = TextSecondary.copy(alpha = 0.5f),
fontSize = 12.sp
)
}
}
}
}
}
// COLUMN 3: EVENT LOGS
Card(
backgroundColor = SurfaceDark,
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)),
modifier = Modifier
.width(420.dp)
.fillMaxHeight()
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Event Console Logs",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Button(
onClick = { logs.clear() },
colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.height(30.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) {
Text("Clear", color = TextSecondary, fontSize = 11.sp)
}
}
// Logs list view
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(ConsoleDark)
.border(1.dp, Color.White.copy(alpha = 0.05f), RoundedCornerShape(8.dp))
.padding(8.dp)
) {
LazyColumn(
state = lazyListState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(logs) { log ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "[${log.timestamp}]",
color = TextSecondary,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
val typeText = when (log.type) {
LogType.INFO -> "INFO"
LogType.SUCCESS -> "SUCCESS"
LogType.WARNING -> "WARN"
LogType.ERROR -> "ERROR"
LogType.HAPTIC -> "HAPTIC"
}
val badgeColor = when (log.type) {
LogType.INFO -> TextSecondary
LogType.SUCCESS -> ActiveGreen
LogType.WARNING -> AlertOrange
LogType.ERROR -> DangerRed
LogType.HAPTIC -> AccentPurple
}
Text(
text = "$typeText:",
color = badgeColor,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
modifier = Modifier.width(60.dp)
)
Text(
text = log.text,
color = Color.White,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,25 @@
package meatbag.client
import meatbag.common.AttentionTier
class WearAttentionFeedback(
private val trigger: suspend (WearHapticPattern) -> Unit
) : AttentionFeedback {
override suspend fun onAttentionTier(tier: AttentionTier) {
trigger(tier.toWearHapticPattern())
}
}
enum class WearHapticPattern {
DoubleShort,
Notification,
UrgentLoop
}
fun AttentionTier.toWearHapticPattern(): WearHapticPattern {
return when (this) {
AttentionTier.SILENT_HAPTIC -> WearHapticPattern.DoubleShort
AttentionTier.BACKGROUND_NOTIFICATION -> WearHapticPattern.Notification
AttentionTier.OVERRIDE_SCREEN -> WearHapticPattern.UrgentLoop
}
}

22
common/build.gradle.kts Normal file
View File

@@ -0,0 +1,22 @@
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
}
kotlin {
jvm() // Targets the JVM. Additional targets (android, ios, etc.) can be configured later.
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
}
}

View File

@@ -0,0 +1,351 @@
package meatbag.common
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
sealed class UiPrimitive {
abstract val id: String
}
@Serializable
@SerialName("ApprovalRequest")
data class ApprovalRequest(
override val id: String,
val prompt: String,
val target: String,
val title: String = "Approval required",
val body: String? = null,
val approveAction: ActionItem = ActionItem(
id = "approve",
label = "Approve",
intent = ActionIntent.APPROVE,
),
val rejectAction: ActionItem = ActionItem(
id = "reject",
label = "Reject",
intent = ActionIntent.REJECT,
style = ActionStyle.DESTRUCTIVE,
),
val timeoutMs: Long? = null,
val callback: CallbackRequest? = null,
) : UiPrimitive()
@Serializable
@SerialName("ActionList")
data class ActionList(
override val id: String,
val title: String,
val actions: List<ActionItem>,
val subtitle: String? = null,
val selectionMode: SelectionMode = SelectionMode.SINGLE,
) : UiPrimitive()
@Serializable
@SerialName("TextInput")
data class TextInput(
override val id: String,
val prompt: String,
val placeholder: String,
val title: String = prompt,
val submitAction: ActionItem = ActionItem(
id = "submit",
label = "Submit",
intent = ActionIntent.SUBMIT,
),
val callback: CallbackRequest? = null,
) : UiPrimitive()
@Serializable
@SerialName("Message")
data class Message(
override val id: String,
val title: String,
val body: String,
val tone: MessageTone = MessageTone.INFO,
val primaryAction: ActionItem? = null,
) : UiPrimitive()
@Serializable
data class ActionItem(
val id: String,
val label: String,
val intent: ActionIntent = ActionIntent.CUSTOM,
val style: ActionStyle = ActionStyle.DEFAULT,
val callback: CallbackRequest? = null,
val enabled: Boolean = true,
)
@Serializable
enum class ActionIntent {
@SerialName("APPROVE")
APPROVE,
@SerialName("REJECT")
REJECT,
@SerialName("ACKNOWLEDGE")
ACKNOWLEDGE,
@SerialName("SUBMIT")
SUBMIT,
@SerialName("CUSTOM")
CUSTOM,
}
@Serializable
enum class ActionStyle {
@SerialName("DEFAULT")
DEFAULT,
@SerialName("PRIMARY")
PRIMARY,
@SerialName("DESTRUCTIVE")
DESTRUCTIVE,
}
@Serializable
enum class SelectionMode {
@SerialName("SINGLE")
SINGLE,
@SerialName("MULTIPLE")
MULTIPLE,
}
@Serializable
enum class MessageTone {
@SerialName("INFO")
INFO,
@SerialName("SUCCESS")
SUCCESS,
@SerialName("WARNING")
WARNING,
@SerialName("ERROR")
ERROR,
}
@Serializable
enum class AttentionTier {
@SerialName("SILENT_HAPTIC")
SILENT_HAPTIC,
@SerialName("BACKGROUND_NOTIFICATION")
BACKGROUND_NOTIFICATION,
@SerialName("OVERRIDE_SCREEN")
OVERRIDE_SCREEN,
}
@Serializable
data class AttentionPolicy(
val tier: AttentionTier,
val hapticPattern: HapticPattern? = null,
val presentation: PresentationMode = PresentationMode.AUTO,
val expiresAtEpochMs: Long? = null,
)
@Serializable
enum class HapticPattern {
@SerialName("NONE")
NONE,
@SerialName("SHORT")
SHORT,
@SerialName("DOUBLE_SHORT")
DOUBLE_SHORT,
@SerialName("URGENT_LOOP")
URGENT_LOOP,
}
@Serializable
enum class PresentationMode {
@SerialName("AUTO")
AUTO,
@SerialName("WATCH_ONLY")
WATCH_ONLY,
@SerialName("PHONE_ONLY")
PHONE_ONLY,
@SerialName("ALL_DEVICES")
ALL_DEVICES,
}
@Serializable
data class RequestContext(
val requestId: String,
val source: RequestSource,
val correlationId: String? = null,
val createdAtEpochMs: Long? = null,
val callback: CallbackRequest? = null,
)
@Serializable
data class RequestSource(
val system: String,
val actor: String? = null,
val reason: String? = null,
)
@Serializable
data class DeviceContext(
val deviceId: String,
val deviceClass: DeviceClass,
val platform: DevicePlatform,
val capabilities: Set<DeviceCapability> = emptySet(),
val locale: String? = null,
val timezone: String? = null,
)
@Serializable
enum class DeviceClass {
@SerialName("PHONE")
PHONE,
@SerialName("WEARABLE")
WEARABLE,
@SerialName("DESKTOP")
DESKTOP,
@SerialName("TABLET")
TABLET,
}
@Serializable
enum class DevicePlatform {
@SerialName("ANDROID")
ANDROID,
@SerialName("WEAR_OS")
WEAR_OS,
@SerialName("IOS")
IOS,
@SerialName("DESKTOP")
DESKTOP,
@SerialName("WEB")
WEB,
}
@Serializable
enum class DeviceCapability {
@SerialName("HAPTICS")
HAPTICS,
@SerialName("NOTIFICATIONS")
NOTIFICATIONS,
@SerialName("FULL_SCREEN_INTENT")
FULL_SCREEN_INTENT,
@SerialName("TEXT_INPUT")
TEXT_INPUT,
}
@Serializable
data class CallbackRequest(
val id: String,
val target: CallbackTarget,
val method: CallbackMethod = CallbackMethod.POST,
)
@Serializable
sealed class CallbackTarget {
@Serializable
@SerialName("Http")
data class Http(
val url: String,
val headers: Map<String, String> = emptyMap(),
) : CallbackTarget()
@Serializable
@SerialName("WebSocket")
data class WebSocket(
val channel: String,
) : CallbackTarget()
@Serializable
@SerialName("Local")
data class Local(
val handler: String,
) : CallbackTarget()
}
@Serializable
enum class CallbackMethod {
@SerialName("POST")
POST,
@SerialName("PUT")
PUT,
}
@Serializable
data class WebSocketPayload(
val attentionTier: AttentionTier,
val uiPrimitive: UiPrimitive,
val request: RequestContext? = null,
val deviceContext: DeviceContext? = null,
val attentionPolicy: AttentionPolicy = AttentionPolicy(attentionTier),
)
@Serializable
data class ApprovalResponse(
val requestId: String,
val approved: Boolean,
val actionId: String? = null,
val text: String? = null,
val callbackId: String? = null,
val deviceId: String? = null,
)
object SDUIJson {
val json = Json {
ignoreUnknownKeys = true
prettyPrint = true
classDiscriminator = "type"
}
fun encodeToString(primitive: UiPrimitive): String {
return json.encodeToString(primitive)
}
fun decodeFromString(jsonString: String): UiPrimitive {
return json.decodeFromString(jsonString)
}
fun encodeAttentionTier(tier: AttentionTier): String {
return json.encodeToString(tier)
}
fun decodeAttentionTier(jsonString: String): AttentionTier {
return json.decodeFromString(jsonString)
}
}
fun UiPrimitive.toJson(): String = SDUIJson.encodeToString(this)
fun String.toUiPrimitive(): UiPrimitive = SDUIJson.decodeFromString(this)
fun AttentionTier.toJson(): String = SDUIJson.encodeAttentionTier(this)
fun String.toAttentionTier(): AttentionTier = SDUIJson.decodeAttentionTier(this)
fun WebSocketPayload.toJson(): String = SDUIJson.json.encodeToString(this)
fun String.toWebSocketPayload(): WebSocketPayload = SDUIJson.json.decodeFromString(this)
fun ApprovalResponse.toJson(): String = SDUIJson.json.encodeToString(this)
fun String.toApprovalResponse(): ApprovalResponse = SDUIJson.json.decodeFromString(this)

View File

@@ -0,0 +1,145 @@
package meatbag.common
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class UiPrimitiveTest {
@Test
fun testApprovalRequestSerialization() {
val approval = ApprovalRequest(
id = "req-1",
prompt = "Grant admin access?",
target = "prod-db",
callback = CallbackRequest(
id = "cb-1",
target = CallbackTarget.Http("https://backend.test/approval"),
),
)
val json = approval.toJson()
assertTrue(json.contains("\"type\": \"ApprovalRequest\""))
assertTrue(json.contains("\"id\": \"req-1\""))
assertTrue(json.contains("\"prompt\": \"Grant admin access?\""))
assertTrue(json.contains("\"target\": \"prod-db\""))
assertTrue(json.contains("\"intent\": \"APPROVE\""))
assertTrue(json.contains("\"type\": \"Http\""))
val decoded = json.toUiPrimitive()
assertTrue(decoded is ApprovalRequest)
assertEquals(approval.id, decoded.id)
assertEquals(approval.prompt, decoded.prompt)
assertEquals(approval.target, decoded.target)
assertEquals(ActionIntent.APPROVE, decoded.approveAction.intent)
assertTrue(decoded.callback?.target is CallbackTarget.Http)
}
@Test
fun testActionListSerialization() {
val actionList = ActionList(
id = "act-1",
title = "Choose Action",
actions = listOf(
ActionItem("1", "Acknowledge"),
ActionItem("2", "Reject")
)
)
val json = actionList.toJson()
assertTrue(json.contains("\"type\": \"ActionList\""))
assertTrue(json.contains("\"title\": \"Choose Action\""))
val decoded = json.toUiPrimitive()
assertTrue(decoded is ActionList)
assertEquals(2, decoded.actions.size)
assertEquals("Acknowledge", decoded.actions[0].label)
assertEquals(ActionIntent.CUSTOM, decoded.actions[0].intent)
assertEquals(SelectionMode.SINGLE, decoded.selectionMode)
}
@Test
fun testTextInputSerialization() {
val textInput = TextInput(
id = "txt-1",
prompt = "Enter passcode",
placeholder = "1234"
)
val json = textInput.toJson()
assertTrue(json.contains("\"type\": \"TextInput\""))
val decoded = json.toUiPrimitive()
assertTrue(decoded is TextInput)
assertEquals("1234", decoded.placeholder)
}
@Test
fun testAttentionTierSerialization() {
val tier = AttentionTier.SILENT_HAPTIC
val json = tier.toJson()
assertEquals("\"SILENT_HAPTIC\"", json.trim())
val decoded = json.toAttentionTier()
assertEquals(tier, decoded)
}
@Test
fun testWebSocketPayloadSerialization() {
val payload = WebSocketPayload(
attentionTier = AttentionTier.OVERRIDE_SCREEN,
uiPrimitive = Message(
id = "msg-1",
title = "Manual intervention",
body = "A deployment requires attention.",
tone = MessageTone.WARNING,
),
request = RequestContext(
requestId = "req-2",
source = RequestSource(system = "deployments", actor = "agent-7"),
callback = CallbackRequest(
id = "cb-2",
target = CallbackTarget.WebSocket(channel = "approvals"),
),
),
deviceContext = DeviceContext(
deviceId = "watch-1",
deviceClass = DeviceClass.WEARABLE,
platform = DevicePlatform.WEAR_OS,
capabilities = setOf(
DeviceCapability.HAPTICS,
DeviceCapability.FULL_SCREEN_INTENT,
),
),
attentionPolicy = AttentionPolicy(
tier = AttentionTier.OVERRIDE_SCREEN,
hapticPattern = HapticPattern.URGENT_LOOP,
presentation = PresentationMode.ALL_DEVICES,
),
)
val json = payload.toJson()
assertTrue(json.contains("\"type\": \"Message\""))
assertTrue(json.contains("\"type\": \"WebSocket\""))
assertTrue(json.contains("\"tier\": \"OVERRIDE_SCREEN\""))
assertTrue(json.contains("\"deviceClass\": \"WEARABLE\""))
val decoded = json.toWebSocketPayload()
assertEquals(AttentionTier.OVERRIDE_SCREEN, decoded.attentionTier)
assertTrue(decoded.uiPrimitive is Message)
assertEquals("req-2", decoded.request?.requestId)
assertEquals(DevicePlatform.WEAR_OS, decoded.deviceContext?.platform)
assertEquals(HapticPattern.URGENT_LOOP, decoded.attentionPolicy.hapticPattern)
}
@Test
fun testApprovalResponseSerialization() {
val response = ApprovalResponse(
requestId = "req-3",
approved = true,
actionId = "approve",
callbackId = "cb-3",
deviceId = "phone-1",
)
val decoded = response.toJson().toApprovalResponse()
assertEquals(response, decoded)
}
}

2
gradle.properties Normal file
View File

@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
kotlin.native.ignoreDisabledTargets=true

35
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,35 @@
[versions]
kotlin = "1.9.23"
agp = "8.2.2"
compose-multiplatform = "1.6.1"
kotlinx-serialization = "1.6.3"
androidx-lifecycle = "2.7.0"
androidx-savedstate = "1.2.1"
androidx-activity = "1.8.2"
wear-compose = "1.3.1"
play-services-wearable = "18.1.0"
[libraries]
kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
compose-gradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-multiplatform" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
# Android Lifecycle IME dependencies
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" }
androidx-savedstate = { module = "androidx.savedstate:savedstate-ktx", version.ref = "androidx-savedstate" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
# Wear OS dependencies
wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "wear-compose" }
wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "wear-compose" }
play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "play-services-wearable" }
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

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.6-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
gradlew vendored Executable file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or 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 UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# 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
;;
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"
which java >/dev/null 2>&1 || 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
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

View File

@@ -0,0 +1,63 @@
# Project Meatbag: Session Handoff
**Date**: June 27, 2026
**Ecosystem**: WhetForge
**Goal**: Scaffolding and Implementing the MVP for **Meatbag**, a multiplatform "Human Latency Optimization Tool" for server-driven UI (SDUI).
---
## 1. What We Did This Session
We initiated the project from scratch, structured it as a Kotlin Multiplatform Gradle project, and developed a stateful routing backend and a stateless Compose Multiplatform client. The session focused on achieving a successful end-to-end ping-pong loop:
1. An agent submits an `ApprovalRequest` via HTTP POST to the backend.
2. The backend determines the target device and attention tier based on user context rules, then suspends the request.
3. The backend notifies the target device via WebSockets.
4. The client simulates haptics (or alerts) based on the `AttentionTier`, parses the JSON UI primitive, and inflates the UI dynamically.
5. The human taps "Yes" / interacts, sending a response to the backend.
6. The backend resumes the suspended agent request and returns the response.
---
## 2. Core Project Architecture
The project directory is structured as a multi-module Kotlin project:
* **`:common` (Kotlin Multiplatform)**
* [UiPrimitive.kt](file:///Users/blindmouse/meatbag/common/src/commonMain/kotlin/meatbag/common/UiPrimitive.kt): Declares the sealed hierarchy representing JSON UI elements (`UiPrimitive` with sub-types `ApprovalRequest`, `ActionList`, `TextInput`), the `AttentionTier` enum (`SILENT_HAPTIC`, `BACKGROUND_NOTIFICATION`, `OVERRIDE_SCREEN`), and common payload envelopes (`WebSocketPayload`, `ApprovalResponse`). Includes polymorphic serialization helpers.
* [UiPrimitiveTest.kt](file:///Users/blindmouse/meatbag/common/src/commonTest/kotlin/meatbag/common/UiPrimitiveTest.kt): Verifies correct serialization and parsing with class discriminators.
* **`:backend` (Ktor/JVM)**
* [Application.kt](file:///Users/blindmouse/meatbag/backend/src/main/kotlin/meatbag/backend/Application.kt): Scaffolds Ktor Netty, sets up CORS/WebSockets/ContentNegotiation plugins, exposes client-facing WebSocket (`/ws/connect`), response handler (`/api/respond`), agent request (`/api/request`), and diagnostic endpoints. Includes context-aware urgency auto-detection.
* [DeviceRegistry.kt](file:///Users/blindmouse/meatbag/backend/src/main/kotlin/meatbag/backend/DeviceRegistry.kt): Registers connected active client devices dynamically.
* [RoutingEngine.kt](file:///Users/blindmouse/meatbag/backend/src/main/kotlin/meatbag/backend/RoutingEngine.kt): Implements attention-based routing (e.g. routes urgent/silent haptics to Wear OS if active, otherwise falls back to phone/desktop) and holds coroutine suspend contexts via `CompletableDeferred` pending user callbacks.
* [RoutingEngineTest.kt](file:///Users/blindmouse/meatbag/backend/src/test/kotlin/meatbag/backend/RoutingEngineTest.kt): Fully verifies WebSocket connections, payload transmission, attention tier routing, and callback resume resolution under Ktor's `testApplication` engine.
* **`:client` (Compose Multiplatform / Desktop target)**
* [ComponentRegistry.kt](file:///Users/blindmouse/meatbag/client/src/commonMain/kotlin/meatbag/client/ComponentRegistry.kt): dynamic Compose renderer converting `UiPrimitive` polymorphically into premium, dark cyberpunk UI components using Unidirectional Data Flow (UDF) callbacks.
* [MeatbagClient.kt](file:///Users/blindmouse/meatbag/client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt): Manages WebSocket connections and POST responses. Decodes payloads and triggers simulated attention effects (vibrations, sound chimes, or display takeover overlays).
* [Main.kt](file:///Users/blindmouse/meatbag/client/src/desktopMain/kotlin/meatbag/client/Main.kt): Cyberpunk glassmorphic client simulator. Displays connection logs and simulates haptic profiles (Smartwatch vs. Phone vs. Desktop) and injects local offline mock payloads for isolated UI debugging.
---
## 3. Configuration & Dependency Fixes Applied
During compilation and verification, several compatibility fixes were successfully applied:
1. **Plugin Portal Registration**: Configured `gradlePluginPortal()` in `settings.gradle.kts` to allow resolution of the Ktor and Kotlin JVM plugins.
2. **Kotlin Compiler Version Alignment**: Managed Kotlin compiler plugin versions centrally in the root `build.gradle.kts` and applied them cleanly inside sub-modules to prevent classloader duplicate classpath conflicts.
3. **Obsolete Property Removal**: Removed the obsolete `kotlin.mpp.enableGranularSourceSetsMetadata=true` from `gradle.properties` to fix Kotlin Gradle compiler configuration errors.
4. **Client Test Classpath**: Added Ktor Client content negotiation and websocket JVM targets to `:backend` test dependencies so that Ktor's test client works flawlessly during JUnit runs.
5. **Client Simulator Fixes**:
* Fixed window size dimensions to use `Dp` units (`1280.dp`, `800.dp`).
* Resolved Ktor 2.x `Frame.Text` read extension function resolution by importing `io.ktor.websocket.readText`.
* Refactored client `log` helpers to be non-suspending (using flow `tryEmit` instead of `emit`), and introduced a public `injectMockEvent` helper to safely feed mock events from the simulator UI without coroutine thread blocks.
---
## 4. Current Status
* **Compilation Status**: `BUILD SUCCESSFUL` for all modules and targets.
* **Testing Status**: Common serialization and backend integration routing tests pass successfully.
* **Active Subagents**: Cleaned up and terminated all background subagent processes.
---
## 5. Next Steps
1. **Develop Android/Wear OS Platform Code**: Implement physical haptic motor triggers (`Vibrator` and `VibratorManager`) in `wearMain` and push banner notifications in `androidMain`.
2. **Persistence Layer**: Implement a database (e.g., SQLite via SQLDelight or PostgreSQL) to persist user rule-sets and past agent requests.
3. **Authentication**: Secure endpoints using API keys for CLI agents and OAuth/Device Tokens for clients.

View File

@@ -0,0 +1,79 @@
# Meatbag Research: SDUI Inflation, Wear OS Haptics, and Server Push
This document summarizes architectural research for the Meatbag platform, focusing on performance, latency, and platform-specific haptic patterns.
---
## 1. Server-Driven UI (SDUI) in Compose Multiplatform
### The Reflection-Free Pattern
To meet performance constraints and avoid runtime performance degradation on lightweight clients (such as Wear OS watches), Meatbag implements a strictly typed, reflection-free Component Registry.
```kotlin
@Composable
fun ComponentRegistry(primitive: UiPrimitive, onAction: (ApprovalResponse) -> Unit) {
when (primitive) {
is ApprovalRequest -> ApprovalRequestCard(primitive, onAction)
is ActionList -> ActionListContainer(primitive, onAction)
is TextInput -> TextInputField(primitive, onAction)
}
}
```
* **Advantage**: Compile-time safety. Polymorphic serialization (via `kotlinx.serialization` with custom class discriminators like `"type"`) ensures payloads map directly to Kotlin data types without dynamic inspection.
* **State Management**: Adheres to Unidirectional Data Flow (UDF). Primitives are read-only configuration blueprints; interactive widgets emit state updates upward through explicit event callbacks (`onAction`), keeping clients entirely stateless.
---
## 2. Wear OS Haptic Feedback APIs
To minimize human response times, we utilize precise physical notifications based on the resolved `AttentionTier`.
### Android / Wear OS API Mapping
For triggering haptics on physical Wear OS smartwatches, the system integrates with Android's hardware `Vibrator` API.
#### 1. Predefined Effects (Android SDK 29+)
Modern Wear OS devices support the `VibrationEffect` API, which yields crisp, hardware-optimized clicks rather than fuzzy motor rumbling.
* **`AttentionTier.SILENT_HAPTIC`**: Double short pulses to signal prompt urgency without making sound.
```kotlin
val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
val effect = VibrationEffect.startComposition()
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 1.0f)
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 1.0f, 150) // 150ms delay
.compose()
vibrator.vibrate(effect)
```
* **`AttentionTier.BACKGROUND_NOTIFICATION`**: A light tick effect.
```kotlin
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
```
* **`AttentionTier.OVERRIDE_SCREEN`**: Repetitive pulsing alert composition.
```kotlin
val timings = longArrayOf(0, 200, 100, 200, 100, 500) // delay, vibe, pause, vibe...
val amplitudes = intArrayOf(0, 255, 0, 255, 0, 255)
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
```
#### 2. Legacy Fallbacks (Android SDK < 29)
* For older Wear OS versions, fallback to deprecated pattern arrays:
```kotlin
vibrator.vibrate(longArrayOf(0, 80, 100, 80), -1)
```
---
## 3. Server Push Architecture: WebSockets vs. SSE
Smartwatches are subject to battery constraints and intermittent connectivity. The push transport layer must be responsive yet lightweight.
| Feature | WebSockets | Server-Sent Events (SSE) |
| :--- | :--- | :--- |
| **Protocol** | Bi-directional, custom framing | Uni-directional (Server -> Client), HTTP/1.1 or HTTP/2 |
| **Connection Overhead** | Low (post-handshake) | Extremely low (standard HTTP connection) |
| **Battery Impact** | Higher (constant connection maintenance) | Lower (leverages HTTP client connection pooling) |
| **Firewall Traversal** | Can be blocked by strict proxy rules | Standard HTTP, easily passes through proxies |
| **Meatbag MVP Selection** | **WebSockets** (Allows immediate client confirmation frames and bi-directional pings) | **Candidate for mobile clients** (Fallback for phone targets via standard APNS / FCM) |
### Recommended Hybrid Strategy
1. **Wear OS Smartwatches**: Utilize persistent WebSockets with optimized ping-pong intervals (e.g. 30 seconds) when the app is active in the foreground.
2. **Mobile Phones (iOS & Android)**: Fall back to native push notification systems (FCM/APNS) to wake up the client, which then opens a short-lived WebSocket or POST connection to render the SDUI primitive.

20
settings.gradle.kts Normal file
View File

@@ -0,0 +1,20 @@
pluginManagement {
repositories {
google()
gradlePluginPortal()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "meatbag"
include(":common")
include(":backend")
include(":client")