A2UI: share web UI and action bridge
This commit is contained in:
@@ -9,6 +9,12 @@ android {
|
||||
namespace = "com.steipete.clawdis.node"
|
||||
compileSdk = 36
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
assets.srcDir(file("../../shared/ClawdisKit/Sources/ClawdisKit/Resources"))
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.steipete.clawdis.node"
|
||||
minSdk = 31
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,23 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Canvas</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
font: 13px -apple-system, system-ui;
|
||||
background: #0b1020;
|
||||
color: #e5e7eb;
|
||||
overflow: hidden;
|
||||
}
|
||||
clawdis-a2ui-host { display: block; height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<clawdis-a2ui-host></clawdis-a2ui-host>
|
||||
<script src="a2ui.bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -97,6 +97,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.disconnect()
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String = "main") {
|
||||
runtime.loadChat(sessionKey)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||
import com.steipete.clawdis.node.node.CanvasController
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCapability
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCameraCommand
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UIAction
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UICommand
|
||||
import com.steipete.clawdis.node.protocol.ClawdisCanvasCommand
|
||||
import com.steipete.clawdis.node.voice.VoiceWakeManager
|
||||
@@ -364,6 +365,79 @@ class NodeRuntime(context: Context) {
|
||||
session.disconnect()
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
scope.launch {
|
||||
val trimmed = payloadJson.trim()
|
||||
if (trimmed.isEmpty()) return@launch
|
||||
|
||||
val root =
|
||||
try {
|
||||
json.parseToJsonElement(trimmed).asObjectOrNull() ?: return@launch
|
||||
} catch (_: Throwable) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
val userActionObj = (root["userAction"] as? JsonObject) ?: root
|
||||
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
|
||||
java.util.UUID.randomUUID().toString()
|
||||
}
|
||||
val name = (userActionObj["name"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||
if (name.isEmpty()) return@launch
|
||||
|
||||
val surfaceId =
|
||||
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
||||
val sourceComponentId =
|
||||
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
||||
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
||||
|
||||
val sessionKey = "main"
|
||||
val message =
|
||||
ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||
actionName = name,
|
||||
sessionKey = sessionKey,
|
||||
surfaceId = surfaceId,
|
||||
sourceComponentId = sourceComponentId,
|
||||
host = displayName.value,
|
||||
instanceId = instanceId.value.lowercase(),
|
||||
contextJson = contextJson,
|
||||
)
|
||||
|
||||
val connected = isConnected.value
|
||||
var error: String? = null
|
||||
if (connected) {
|
||||
try {
|
||||
session.sendEvent(
|
||||
event = "agent.request",
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(message))
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("thinking", JsonPrimitive("low"))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
put("key", JsonPrimitive(actionId))
|
||||
}.toString(),
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
error = e.message ?: "send failed"
|
||||
}
|
||||
} else {
|
||||
error = "bridge not connected"
|
||||
}
|
||||
|
||||
try {
|
||||
canvas.eval(
|
||||
ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||
actionId = actionId,
|
||||
ok = connected && error == null,
|
||||
error = error,
|
||||
),
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String = "main") {
|
||||
chat.load(sessionKey)
|
||||
}
|
||||
@@ -581,11 +655,15 @@ class NodeRuntime(context: Context) {
|
||||
val raw = paramsJson?.trim().orEmpty()
|
||||
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
|
||||
|
||||
if (command == ClawdisCanvasA2UICommand.PushJSONL.rawValue) {
|
||||
val obj =
|
||||
json.parseToJsonElement(raw) as? JsonObject
|
||||
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
|
||||
val jsonl = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||
val obj =
|
||||
json.parseToJsonElement(raw) as? JsonObject
|
||||
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
|
||||
|
||||
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||
val hasMessagesArray = obj["messages"] is JsonArray
|
||||
|
||||
if (command == ClawdisCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
||||
val jsonl = jsonlField
|
||||
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
||||
val messages =
|
||||
jsonl
|
||||
@@ -604,9 +682,6 @@ class NodeRuntime(context: Context) {
|
||||
return JsonArray(messages).toString()
|
||||
}
|
||||
|
||||
val obj =
|
||||
json.parseToJsonElement(raw) as? JsonObject
|
||||
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
|
||||
val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required")
|
||||
val out =
|
||||
arr.mapIndexed { idx, el ->
|
||||
@@ -638,7 +713,7 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
||||
|
||||
private const val a2uiIndexUrl: String = "file:///android_asset/canvas_a2ui/index.html"
|
||||
private const val a2uiIndexUrl: String = "file:///android_asset/CanvasA2UI/index.html"
|
||||
|
||||
private const val a2uiReadyCheckJS: String =
|
||||
"""
|
||||
|
||||
@@ -20,6 +20,8 @@ class CanvasController {
|
||||
@Volatile private var mode: Mode = Mode.CANVAS
|
||||
@Volatile private var url: String = ""
|
||||
|
||||
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
|
||||
|
||||
fun attach(webView: WebView) {
|
||||
this.webView = webView
|
||||
reload()
|
||||
@@ -59,7 +61,7 @@ class CanvasController {
|
||||
if (trimmed.isBlank()) return@withWebViewOnMain
|
||||
wv.loadUrl(trimmed)
|
||||
}
|
||||
Mode.CANVAS -> wv.loadDataWithBaseURL(null, canvasHtml, "text/html", "utf-8", null)
|
||||
Mode.CANVAS -> wv.loadUrl(scaffoldAssetUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,158 +145,3 @@ class CanvasController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val canvasHtml =
|
||||
"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Canvas</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body::before, body::after { animation: none !important; }
|
||||
}
|
||||
html,body { height:100%; margin:0; }
|
||||
body {
|
||||
background: radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
|
||||
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
|
||||
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
|
||||
#000;
|
||||
overflow: hidden;
|
||||
}
|
||||
body::before {
|
||||
content:"";
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
|
||||
transparent 1px, transparent 48px),
|
||||
repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
|
||||
transparent 1px, transparent 48px);
|
||||
transform: translate3d(0,0,0) rotate(-7deg);
|
||||
will-change: transform, opacity;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
animation: clawdis-grid-drift 140s ease-in-out infinite alternate;
|
||||
}
|
||||
body::after {
|
||||
content:"";
|
||||
position: fixed;
|
||||
inset: -35%;
|
||||
background:
|
||||
radial-gradient(900px 700px at 30% 30%, rgba(42,113,255,0.16), rgba(0,0,0,0) 60%),
|
||||
radial-gradient(800px 650px at 70% 35%, rgba(255,0,138,0.12), rgba(0,0,0,0) 62%),
|
||||
radial-gradient(900px 800px at 55% 75%, rgba(0,209,255,0.10), rgba(0,0,0,0) 62%);
|
||||
filter: blur(28px);
|
||||
opacity: 0.52;
|
||||
will-change: transform, opacity;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
transform: translate3d(0,0,0);
|
||||
pointer-events: none;
|
||||
animation: clawdis-glow-drift 110s ease-in-out infinite alternate;
|
||||
}
|
||||
@supports (mix-blend-mode: screen) {
|
||||
body::after { mix-blend-mode: screen; }
|
||||
}
|
||||
@supports not (mix-blend-mode: screen) {
|
||||
body::after { opacity: 0.70; }
|
||||
}
|
||||
@keyframes clawdis-grid-drift {
|
||||
0% { transform: translate3d(-12px, 8px, 0) rotate(-7deg); opacity: 0.40; }
|
||||
50% { transform: translate3d( 10px,-7px, 0) rotate(-6.6deg); opacity: 0.56; }
|
||||
100% { transform: translate3d(-8px, 6px, 0) rotate(-7.2deg); opacity: 0.42; }
|
||||
}
|
||||
@keyframes clawdis-glow-drift {
|
||||
0% { transform: translate3d(-18px, 12px, 0) scale(1.02); opacity: 0.40; }
|
||||
50% { transform: translate3d( 14px,-10px, 0) scale(1.05); opacity: 0.52; }
|
||||
100% { transform: translate3d(-10px, 8px, 0) scale(1.03); opacity: 0.43; }
|
||||
}
|
||||
canvas {
|
||||
display:block;
|
||||
width:100vw;
|
||||
height:100vh;
|
||||
touch-action: none;
|
||||
}
|
||||
#clawdis-status {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
#clawdis-status .card {
|
||||
text-align: center;
|
||||
padding: 16px 18px;
|
||||
border-radius: 14px;
|
||||
background: rgba(18, 18, 22, 0.42);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
#clawdis-status .title {
|
||||
font: 600 20px -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
|
||||
letter-spacing: 0.2px;
|
||||
color: rgba(255,255,255,0.92);
|
||||
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
|
||||
}
|
||||
#clawdis-status .subtitle {
|
||||
margin-top: 6px;
|
||||
font: 500 12px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
color: rgba(255,255,255,0.58);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="clawdis-canvas"></canvas>
|
||||
<div id="clawdis-status">
|
||||
<div class="card">
|
||||
<div class="title" id="clawdis-status-title">Ready</div>
|
||||
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const canvas = document.getElementById('clawdis-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const statusEl = document.getElementById('clawdis-status');
|
||||
const titleEl = document.getElementById('clawdis-status-title');
|
||||
const subtitleEl = document.getElementById('clawdis-status-subtitle');
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
|
||||
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
window.__clawdis = {
|
||||
canvas,
|
||||
ctx,
|
||||
setStatus: (title, subtitle) => {
|
||||
if (!statusEl) return;
|
||||
if (!title && !subtitle) {
|
||||
statusEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
statusEl.style.display = 'grid';
|
||||
if (titleEl && typeof title === 'string') titleEl.textContent = title;
|
||||
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.steipete.clawdis.node.protocol
|
||||
|
||||
object ClawdisCanvasA2UIAction {
|
||||
fun sanitizeTagValue(value: String): String {
|
||||
val trimmed = value.trim().ifEmpty { "-" }
|
||||
val normalized = trimmed.replace(" ", "_")
|
||||
val out = StringBuilder(normalized.length)
|
||||
for (c in normalized) {
|
||||
val ok =
|
||||
c.isLetterOrDigit() ||
|
||||
c == '_' ||
|
||||
c == '-' ||
|
||||
c == '.' ||
|
||||
c == ':'
|
||||
out.append(if (ok) c else '_')
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
fun formatAgentMessage(
|
||||
actionName: String,
|
||||
sessionKey: String,
|
||||
surfaceId: String,
|
||||
sourceComponentId: String,
|
||||
host: String,
|
||||
instanceId: String,
|
||||
contextJson: String?,
|
||||
): String {
|
||||
val ctxSuffix = contextJson?.takeIf { it.isNotBlank() }?.let { " ctx=$it" }.orEmpty()
|
||||
return listOf(
|
||||
"CANVAS_A2UI",
|
||||
"action=${sanitizeTagValue(actionName)}",
|
||||
"session=${sanitizeTagValue(sessionKey)}",
|
||||
"surface=${sanitizeTagValue(surfaceId)}",
|
||||
"component=${sanitizeTagValue(sourceComponentId)}",
|
||||
"host=${sanitizeTagValue(host)}",
|
||||
"instance=${sanitizeTagValue(instanceId)}$ctxSuffix",
|
||||
"default=update_canvas",
|
||||
).joinToString(separator = " ")
|
||||
}
|
||||
|
||||
fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String {
|
||||
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
val okLiteral = if (ok) "true" else "false"
|
||||
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -24,12 +24,10 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -42,10 +40,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
@@ -80,7 +75,6 @@ fun RootScreen(viewModel: MainViewModel) {
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CanvasBackdrop(modifier = Modifier.fillMaxSize())
|
||||
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
@@ -187,73 +181,30 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
||||
)
|
||||
}
|
||||
}
|
||||
setBackgroundColor(Color.TRANSPARENT)
|
||||
setBackgroundResource(0)
|
||||
// WebView transparency + HW acceleration can render as solid black on some Android/WebView builds.
|
||||
// Prefer correct alpha blending since we render the idle backdrop in Compose underneath.
|
||||
setLayerType(View.LAYER_TYPE_SOFTWARE, null)
|
||||
setBackgroundColor(Color.BLACK)
|
||||
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
|
||||
addJavascriptInterface(
|
||||
CanvasA2UIActionBridge { payload ->
|
||||
viewModel.handleCanvasA2UIActionFromWebView(payload)
|
||||
},
|
||||
CanvasA2UIActionBridge.interfaceName,
|
||||
)
|
||||
viewModel.canvas.attach(this)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CanvasBackdrop(modifier: Modifier = Modifier) {
|
||||
val base = MaterialTheme.colorScheme.background
|
||||
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
|
||||
@JavascriptInterface
|
||||
fun postMessage(payload: String?) {
|
||||
val msg = payload?.trim().orEmpty()
|
||||
if (msg.isEmpty()) return
|
||||
onMessage(msg)
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier.background(base)) {
|
||||
// Subtle idle backdrop; also acts as fallback when WebView content is transparent or fails to load.
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.linearGradient(
|
||||
colors =
|
||||
listOf(
|
||||
ComposeColor(0xFF0A2034),
|
||||
ComposeColor(0xFF070A10),
|
||||
ComposeColor(0xFF250726),
|
||||
),
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(size.width, size.height),
|
||||
),
|
||||
)
|
||||
|
||||
val step = 48f * density
|
||||
val lineColor = ComposeColor.White.copy(alpha = 0.028f)
|
||||
var x = -step
|
||||
while (x < size.width + step) {
|
||||
drawLine(color = lineColor, start = Offset(x, 0f), end = Offset(x, size.height), strokeWidth = 1f)
|
||||
x += step
|
||||
}
|
||||
var y = -step
|
||||
while (y < size.height + step) {
|
||||
drawLine(color = lineColor, start = Offset(0f, y), end = Offset(size.width, y), strokeWidth = 1f)
|
||||
y += step
|
||||
}
|
||||
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(ComposeColor(0xFF2A71FF).copy(alpha = 0.22f), ComposeColor.Transparent),
|
||||
center = Offset(size.width * 0.15f, size.height * 0.20f),
|
||||
radius = size.minDimension * 0.9f,
|
||||
),
|
||||
)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(ComposeColor(0xFFFF008A).copy(alpha = 0.18f), ComposeColor.Transparent),
|
||||
center = Offset(size.width * 0.85f, size.height * 0.30f),
|
||||
radius = size.minDimension * 0.75f,
|
||||
),
|
||||
)
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors = listOf(ComposeColor(0xFF00D1FF).copy(alpha = 0.14f), ComposeColor.Transparent),
|
||||
center = Offset(size.width * 0.60f, size.height * 0.90f),
|
||||
radius = size.minDimension * 0.85f,
|
||||
),
|
||||
)
|
||||
companion object {
|
||||
const val interfaceName: String = "clawdisCanvasA2UIAction"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.steipete.clawdis.node.protocol
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ClawdisCanvasA2UIActionTest {
|
||||
@Test
|
||||
fun formatAgentMessageMatchesSharedSpec() {
|
||||
val msg =
|
||||
ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||
actionName = "Get Weather",
|
||||
sessionKey = "main",
|
||||
surfaceId = "main",
|
||||
sourceComponentId = "btnWeather",
|
||||
host = "Peter’s iPad",
|
||||
instanceId = "ipad16,6",
|
||||
contextJson = "{\"city\":\"Vienna\"}",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"CANVAS_A2UI action=Get_Weather session=main surface=main component=btnWeather host=Peter_s_iPad instance=ipad16_6 ctx={\"city\":\"Vienna\"} default=update_canvas",
|
||||
msg,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jsDispatchA2uiStatusIsStable() {
|
||||
val js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null)
|
||||
assertEquals(
|
||||
"window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));",
|
||||
js,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import ClawdisKit
|
||||
import Network
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
@@ -43,6 +44,90 @@ final class NodeAppModel {
|
||||
await self.handleDeepLink(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up A2UI action clicks (buttons, etc.)
|
||||
self.screen.onA2UIAction = { [weak self] body in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
await self.handleCanvasA2UIAction(body: body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCanvasA2UIAction(body: [String: Any]) async {
|
||||
let userActionAny = body["userAction"] ?? body
|
||||
let userAction: [String: Any] = {
|
||||
if let dict = userActionAny as? [String: Any] { return dict }
|
||||
if let dict = userActionAny as? [AnyHashable: Any] {
|
||||
return dict.reduce(into: [String: Any]()) { acc, pair in
|
||||
guard let key = pair.key as? String else { return }
|
||||
acc[key] = pair.value
|
||||
}
|
||||
}
|
||||
return [:]
|
||||
}()
|
||||
guard !userAction.isEmpty else { return }
|
||||
|
||||
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
|
||||
let actionId: String = {
|
||||
let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return id.isEmpty ? UUID().uuidString : id
|
||||
}()
|
||||
|
||||
let surfaceId: String = {
|
||||
let raw = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return raw.isEmpty ? "main" : raw
|
||||
}()
|
||||
let sourceComponentId: String = {
|
||||
let raw = (userAction[
|
||||
"sourceComponentId",
|
||||
] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return raw.isEmpty ? "-" : raw
|
||||
}()
|
||||
|
||||
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
|
||||
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
|
||||
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
|
||||
let sessionKey = "main"
|
||||
|
||||
let message = ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||
actionName: name,
|
||||
sessionKey: sessionKey,
|
||||
surfaceId: surfaceId,
|
||||
sourceComponentId: sourceComponentId,
|
||||
host: host,
|
||||
instanceId: instanceId,
|
||||
contextJSON: contextJSON)
|
||||
|
||||
let ok: Bool
|
||||
var errorText: String? = nil
|
||||
if await !self.isBridgeConnected() {
|
||||
ok = false
|
||||
errorText = "bridge not connected"
|
||||
} else {
|
||||
do {
|
||||
try await self.sendAgentRequest(link: AgentDeepLink(
|
||||
message: message,
|
||||
sessionKey: sessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: nil,
|
||||
timeoutSeconds: nil,
|
||||
key: actionId))
|
||||
ok = true
|
||||
} catch {
|
||||
ok = false
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
let js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: ok, error: errorText)
|
||||
do {
|
||||
_ = try await self.screen.eval(javaScript: js)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
@@ -339,8 +424,14 @@ final class NodeAppModel {
|
||||
let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
|
||||
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
|
||||
} else {
|
||||
let params = try Self.decodeParams(ClawdisCanvasA2UIPushParams.self, from: req.paramsJSON)
|
||||
messages = params.messages
|
||||
do {
|
||||
let params = try Self.decodeParams(ClawdisCanvasA2UIPushParams.self, from: req.paramsJSON)
|
||||
messages = params.messages
|
||||
} catch {
|
||||
// Be forgiving: some clients still send JSONL payloads to `canvas.a2ui.push`.
|
||||
let params = try Self.decodeParams(ClawdisCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
|
||||
messages = try ClawdisCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
|
||||
}
|
||||
}
|
||||
|
||||
try self.screen.showA2UI()
|
||||
|
||||
@@ -8,6 +8,7 @@ import WebKit
|
||||
final class ScreenController {
|
||||
let webView: WKWebView
|
||||
private let navigationDelegate: ScreenNavigationDelegate
|
||||
private let a2uiActionHandler: CanvasA2UIActionMessageHandler
|
||||
|
||||
var mode: ClawdisCanvasMode = .canvas
|
||||
var urlString: String = ""
|
||||
@@ -16,14 +17,23 @@ final class ScreenController {
|
||||
/// Callback invoked when a clawdis:// deep link is tapped in the canvas
|
||||
var onDeepLink: ((URL) -> Void)?
|
||||
|
||||
/// Callback invoked when the user clicks an A2UI action (e.g. button) inside the canvas web UI.
|
||||
var onA2UIAction: (([String: Any]) -> Void)?
|
||||
|
||||
init() {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = .nonPersistent()
|
||||
let a2uiActionHandler = CanvasA2UIActionMessageHandler()
|
||||
let userContentController = WKUserContentController()
|
||||
userContentController.add(a2uiActionHandler, name: CanvasA2UIActionMessageHandler.messageName)
|
||||
config.userContentController = userContentController
|
||||
self.navigationDelegate = ScreenNavigationDelegate()
|
||||
self.a2uiActionHandler = a2uiActionHandler
|
||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||
self.webView.isOpaque = false
|
||||
self.webView.backgroundColor = .clear
|
||||
self.webView.scrollView.backgroundColor = .clear
|
||||
// Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays.
|
||||
self.webView.isOpaque = true
|
||||
self.webView.backgroundColor = .black
|
||||
self.webView.scrollView.backgroundColor = .black
|
||||
self.webView.scrollView.contentInsetAdjustmentBehavior = .never
|
||||
self.webView.scrollView.contentInset = .zero
|
||||
self.webView.scrollView.scrollIndicatorInsets = .zero
|
||||
@@ -33,6 +43,7 @@ final class ScreenController {
|
||||
self.webView.scrollView.bounces = false
|
||||
self.webView.navigationDelegate = self.navigationDelegate
|
||||
self.navigationDelegate.controller = self
|
||||
a2uiActionHandler.controller = self
|
||||
self.reload()
|
||||
}
|
||||
|
||||
@@ -61,18 +72,16 @@ final class ScreenController {
|
||||
self.webView.load(URLRequest(url: url))
|
||||
}
|
||||
case .canvas:
|
||||
self.webView.loadHTMLString(Self.canvasScaffoldHTML, baseURL: nil)
|
||||
guard let url = Self.canvasScaffoldURL else { return }
|
||||
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
|
||||
func showA2UI() throws {
|
||||
guard let url = ClawdisKitResources.bundle.url(
|
||||
forResource: "index",
|
||||
withExtension: "html",
|
||||
subdirectory: "CanvasA2UI")
|
||||
guard let url = Self.a2uiIndexURL
|
||||
else {
|
||||
throw NSError(domain: "Canvas", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "A2UI resources missing (CanvasA2UI/index.html)",
|
||||
NSLocalizedDescriptionKey: "A2UI resources missing (index.html)",
|
||||
])
|
||||
}
|
||||
self.mode = .web
|
||||
@@ -145,164 +154,17 @@ final class ScreenController {
|
||||
return data.base64EncodedString()
|
||||
}
|
||||
|
||||
private static let canvasScaffoldHTML = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Canvas</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body::before, body::after { animation: none !important; }
|
||||
}
|
||||
html,body { height:100%; margin:0; }
|
||||
body {
|
||||
background: radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
|
||||
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
|
||||
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
|
||||
#000;
|
||||
overflow: hidden;
|
||||
}
|
||||
body::before {
|
||||
content:"";
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
|
||||
transparent 1px, transparent 48px),
|
||||
repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
|
||||
transparent 1px, transparent 48px);
|
||||
transform: translate3d(0,0,0) rotate(-7deg);
|
||||
will-change: transform, opacity;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
animation: clawdis-grid-drift 140s ease-in-out infinite alternate;
|
||||
}
|
||||
body::after {
|
||||
content:"";
|
||||
position: fixed;
|
||||
inset: -35%;
|
||||
background:
|
||||
radial-gradient(900px 700px at 30% 30%, rgba(42,113,255,0.16), rgba(0,0,0,0) 60%),
|
||||
radial-gradient(800px 650px at 70% 35%, rgba(255,0,138,0.12), rgba(0,0,0,0) 62%),
|
||||
radial-gradient(900px 800px at 55% 75%, rgba(0,209,255,0.10), rgba(0,0,0,0) 62%);
|
||||
filter: blur(28px);
|
||||
opacity: 0.52;
|
||||
will-change: transform, opacity;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
transform: translate3d(0,0,0);
|
||||
pointer-events: none;
|
||||
animation: clawdis-glow-drift 110s ease-in-out infinite alternate;
|
||||
}
|
||||
@supports (mix-blend-mode: screen) {
|
||||
body::after { mix-blend-mode: screen; }
|
||||
}
|
||||
@supports not (mix-blend-mode: screen) {
|
||||
body::after { opacity: 0.70; }
|
||||
}
|
||||
@keyframes clawdis-grid-drift {
|
||||
0% { transform: translate3d(-12px, 8px, 0) rotate(-7deg); opacity: 0.40; }
|
||||
50% { transform: translate3d( 10px,-7px, 0) rotate(-6.6deg); opacity: 0.56; }
|
||||
100% { transform: translate3d(-8px, 6px, 0) rotate(-7.2deg); opacity: 0.42; }
|
||||
}
|
||||
@keyframes clawdis-glow-drift {
|
||||
0% { transform: translate3d(-18px, 12px, 0) scale(1.02); opacity: 0.40; }
|
||||
50% { transform: translate3d( 14px,-10px, 0) scale(1.05); opacity: 0.52; }
|
||||
100% { transform: translate3d(-10px, 8px, 0) scale(1.03); opacity: 0.43; }
|
||||
}
|
||||
canvas {
|
||||
display:block;
|
||||
width:100vw;
|
||||
height:100vh;
|
||||
touch-action: none;
|
||||
}
|
||||
#clawdis-status {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
#clawdis-status .card {
|
||||
text-align: center;
|
||||
padding: 16px 18px;
|
||||
border-radius: 14px;
|
||||
background: rgba(18, 18, 22, 0.42);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
#clawdis-status .title {
|
||||
font: 600 20px -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
|
||||
letter-spacing: 0.2px;
|
||||
color: rgba(255,255,255,0.92);
|
||||
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
|
||||
}
|
||||
#clawdis-status .subtitle {
|
||||
margin-top: 6px;
|
||||
font: 500 12px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
color: rgba(255,255,255,0.58);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="clawdis-canvas"></canvas>
|
||||
<div id="clawdis-status">
|
||||
<div class="card">
|
||||
<div class="title" id="clawdis-status-title">Ready</div>
|
||||
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const canvas = document.getElementById('clawdis-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const statusEl = document.getElementById('clawdis-status');
|
||||
const titleEl = document.getElementById('clawdis-status-title');
|
||||
const subtitleEl = document.getElementById('clawdis-status-subtitle');
|
||||
// SwiftPM flattens resource directories; ensure resource filenames are unique.
|
||||
private static let canvasScaffoldURL: URL? = ClawdisKitResources.bundle.url(
|
||||
forResource: "scaffold",
|
||||
withExtension: "html")
|
||||
private static let a2uiIndexURL: URL? = ClawdisKitResources.bundle.url(forResource: "index", withExtension: "html")
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
|
||||
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
window.__clawdis = {
|
||||
canvas,
|
||||
ctx,
|
||||
setStatus: (title, subtitle) => {
|
||||
if (!statusEl) return;
|
||||
if (!title && !subtitle) {
|
||||
statusEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
statusEl.style.display = 'grid';
|
||||
if (titleEl && typeof title === 'string') titleEl.textContent = title;
|
||||
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
|
||||
// Auto-hide after 3 seconds
|
||||
clearTimeout(window.__statusTimeout);
|
||||
window.__statusTimeout = setTimeout(() => {
|
||||
statusEl.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
fileprivate func isBundledA2UIURL(_ url: URL) -> Bool {
|
||||
guard url.isFileURL else { return false }
|
||||
guard let expected = Self.a2uiIndexURL else { return false }
|
||||
return url.standardizedFileURL == expected.standardizedFileURL
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation Delegate
|
||||
@@ -333,3 +195,31 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
}
|
||||
|
||||
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
static let messageName = "clawdisCanvasA2UIAction"
|
||||
|
||||
weak var controller: ScreenController?
|
||||
|
||||
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
guard message.name == Self.messageName else { return }
|
||||
guard let controller else { return }
|
||||
|
||||
// Only accept actions from local bundled CanvasA2UI content (not arbitrary web pages).
|
||||
guard let url = message.webView?.url, controller.isBundledA2UIURL(url) else { return }
|
||||
|
||||
let body: [String: Any] = {
|
||||
if let dict = message.body as? [String: Any] { return dict }
|
||||
if let dict = message.body as? [AnyHashable: Any] {
|
||||
return dict.reduce(into: [String: Any]()) { acc, pair in
|
||||
guard let key = pair.key as? String else { return }
|
||||
acc[key] = pair.value
|
||||
}
|
||||
}
|
||||
return [:]
|
||||
}()
|
||||
guard !body.isEmpty else { return }
|
||||
|
||||
controller.onA2UIAction?(body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import WebKit
|
||||
let screen = ScreenController()
|
||||
|
||||
#expect(screen.mode == .canvas)
|
||||
#expect(screen.webView.isOpaque == false)
|
||||
#expect(screen.webView.backgroundColor == .clear)
|
||||
#expect(screen.webView.isOpaque == true)
|
||||
#expect(screen.webView.backgroundColor == .black)
|
||||
|
||||
let scrollView = screen.webView.scrollView
|
||||
#expect(scrollView.backgroundColor == .clear)
|
||||
#expect(scrollView.backgroundColor == .black)
|
||||
#expect(scrollView.contentInsetAdjustmentBehavior == .never)
|
||||
#expect(scrollView.isScrollEnabled == false)
|
||||
#expect(scrollView.bounces == false)
|
||||
|
||||
@@ -205,8 +205,8 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
}
|
||||
|
||||
private func a2uiShellPage(sessionRoot: URL) -> CanvasResponse {
|
||||
// Default Canvas UX: when no index exists, show the built-in A2UI shell.
|
||||
if let data = self.loadBundledResourceData(subdirectory: "CanvasA2UI", relativePath: "index.html") {
|
||||
// Default Canvas UX: when no index exists, show the built-in scaffold page.
|
||||
if let data = self.loadBundledResourceData(relativePath: "scaffold.html") {
|
||||
return CanvasResponse(mime: "text/html", data: data)
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
return self.html("Forbidden", title: "Canvas: 403")
|
||||
}
|
||||
|
||||
guard let data = self.loadBundledResourceData(subdirectory: "CanvasA2UI", relativePath: relative) else {
|
||||
guard let data = self.loadBundledResourceData(relativePath: relative) else {
|
||||
return self.html("Not Found", title: "Canvas: 404")
|
||||
}
|
||||
|
||||
@@ -243,12 +243,15 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
return CanvasResponse(mime: mime, data: data)
|
||||
}
|
||||
|
||||
private func loadBundledResourceData(subdirectory: String, relativePath: String) -> Data? {
|
||||
guard let base = ClawdisKitResources.bundle.resourceURL?.appendingPathComponent(subdirectory, isDirectory: true) else {
|
||||
return nil
|
||||
}
|
||||
let url = base.appendingPathComponent(relativePath, isDirectory: false)
|
||||
return try? Data(contentsOf: url)
|
||||
private func loadBundledResourceData(relativePath: String) -> Data? {
|
||||
// SwiftPM flattens resource directories; treat bundled canvas resources as uniquely-named files.
|
||||
if relativePath.contains("/") { return nil }
|
||||
let url = URL(fileURLWithPath: relativePath)
|
||||
let ext = url.pathExtension
|
||||
let name = url.deletingPathExtension().lastPathComponent
|
||||
guard !name.isEmpty, !ext.isEmpty else { return nil }
|
||||
guard let resourceURL = ClawdisKitResources.bundle.url(forResource: name, withExtension: ext) else { return nil }
|
||||
return try? Data(contentsOf: resourceURL)
|
||||
}
|
||||
|
||||
private func textEncodingName(forMimeType mimeType: String) -> String? {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AppKit
|
||||
import ClawdisIPC
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import QuartzCore
|
||||
@@ -149,7 +150,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
|
||||
canvasWindowLogger.debug("CanvasWindowController init creating WKWebView")
|
||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||
self.webView.setValue(false, forKey: "drawsBackground")
|
||||
// Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays.
|
||||
self.webView.setValue(true, forKey: "drawsBackground")
|
||||
|
||||
let sessionDir = self.sessionDir
|
||||
let webView = self.webView
|
||||
@@ -646,23 +648,18 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
||||
.nonEmpty ?? "main"
|
||||
let sourceComponentId = (userAction["sourceComponentId"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
|
||||
let host = Self.sanitizeTagValue(InstanceIdentity.displayName)
|
||||
let instanceId = InstanceIdentity.instanceId.lowercased()
|
||||
let contextJSON = Self.compactJSON(userAction["context"])
|
||||
let contextSuffix = contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
|
||||
let contextJSON = ClawdisCanvasA2UIAction.compactJSON(userAction["context"])
|
||||
|
||||
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
|
||||
let text =
|
||||
[
|
||||
"CANVAS_A2UI",
|
||||
"action=\(Self.sanitizeTagValue(name))",
|
||||
"session=\(Self.sanitizeTagValue(self.sessionKey))",
|
||||
"surface=\(Self.sanitizeTagValue(surfaceId))",
|
||||
"component=\(Self.sanitizeTagValue(sourceComponentId))",
|
||||
"host=\(host)",
|
||||
"instance=\(instanceId)\(contextSuffix)",
|
||||
"default=update_canvas",
|
||||
].joined(separator: " ")
|
||||
let text = ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||
actionName: name,
|
||||
sessionKey: self.sessionKey,
|
||||
surfaceId: surfaceId,
|
||||
sourceComponentId: sourceComponentId,
|
||||
host: InstanceIdentity.displayName,
|
||||
instanceId: instanceId,
|
||||
contextJSON: contextJSON)
|
||||
|
||||
Task { [weak webView] in
|
||||
if AppStateStore.shared.connectionMode == .local {
|
||||
@@ -680,7 +677,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
||||
|
||||
await MainActor.run {
|
||||
guard let webView else { return }
|
||||
let js = Self.jsDispatchA2UIActionStatus(actionId: actionId, ok: result.ok, error: result.error)
|
||||
let js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: result.ok, error: result.error)
|
||||
webView.evaluateJavaScript(js) { _, _ in }
|
||||
}
|
||||
if !result.ok {
|
||||
@@ -690,39 +687,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
|
||||
}
|
||||
}
|
||||
|
||||
private static func sanitizeTagValue(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
|
||||
let normalized = trimmed.replacingOccurrences(of: " ", with: "_")
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.:")
|
||||
let scalars = normalized.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
|
||||
return String(scalars)
|
||||
}
|
||||
|
||||
private static func compactJSON(_ obj: Any?) -> String? {
|
||||
guard let obj else { return nil }
|
||||
guard JSONSerialization.isValidJSONObject(obj) else { return nil }
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: []),
|
||||
let str = String(data: data, encoding: .utf8)
|
||||
else { return nil }
|
||||
return str
|
||||
}
|
||||
|
||||
private static func jsDispatchA2UIActionStatus(actionId: String, ok: Bool, error: String?) -> String {
|
||||
let payload: [String: Any] = [
|
||||
"id": actionId,
|
||||
"ok": ok,
|
||||
"error": error ?? "",
|
||||
]
|
||||
let json: String = {
|
||||
if let data = try? JSONSerialization.data(withJSONObject: payload, options: []),
|
||||
let str = String(data: data, encoding: .utf8)
|
||||
{
|
||||
return str
|
||||
}
|
||||
return "{\"id\":\"\(actionId)\",\"ok\":\(ok ? "true" : "false"),\"error\":\"\"}"
|
||||
}()
|
||||
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: \(json) }));"
|
||||
}
|
||||
// Formatting helpers live in ClawdisKit (`ClawdisCanvasA2UIAction`).
|
||||
}
|
||||
|
||||
// MARK: - Hover chrome container
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
public enum ClawdisCanvasA2UIAction: Sendable {
|
||||
public static func sanitizeTagValue(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nonEmpty = trimmed.isEmpty ? "-" : trimmed
|
||||
let normalized = nonEmpty.replacingOccurrences(of: " ", with: "_")
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.:")
|
||||
let scalars = normalized.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
|
||||
return String(scalars)
|
||||
}
|
||||
|
||||
public static func compactJSON(_ obj: Any?) -> String? {
|
||||
guard let obj else { return nil }
|
||||
guard JSONSerialization.isValidJSONObject(obj) else { return nil }
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: []),
|
||||
let str = String(data: data, encoding: .utf8)
|
||||
else { return nil }
|
||||
return str
|
||||
}
|
||||
|
||||
public static func formatAgentMessage(
|
||||
actionName: String,
|
||||
sessionKey: String,
|
||||
surfaceId: String,
|
||||
sourceComponentId: String,
|
||||
host: String,
|
||||
instanceId: String,
|
||||
contextJSON: String?)
|
||||
-> String
|
||||
{
|
||||
let ctxSuffix = contextJSON.flatMap { $0.isEmpty ? nil : " ctx=\($0)" } ?? ""
|
||||
return [
|
||||
"CANVAS_A2UI",
|
||||
"action=\(self.sanitizeTagValue(actionName))",
|
||||
"session=\(self.sanitizeTagValue(sessionKey))",
|
||||
"surface=\(self.sanitizeTagValue(surfaceId))",
|
||||
"component=\(self.sanitizeTagValue(sourceComponentId))",
|
||||
"host=\(self.sanitizeTagValue(host))",
|
||||
"instance=\(self.sanitizeTagValue(instanceId))\(ctxSuffix)",
|
||||
"default=update_canvas",
|
||||
].joined(separator: " ")
|
||||
}
|
||||
|
||||
public static func jsDispatchA2UIActionStatus(actionId: String, ok: Bool, error: String?) -> String {
|
||||
let payload: [String: Any] = [
|
||||
"id": actionId,
|
||||
"ok": ok,
|
||||
"error": error ?? "",
|
||||
]
|
||||
let json: String = {
|
||||
if let data = try? JSONSerialization.data(withJSONObject: payload, options: []),
|
||||
let str = String(data: data, encoding: .utf8)
|
||||
{
|
||||
return str
|
||||
}
|
||||
return "{\"id\":\"\(actionId)\",\"ok\":\(ok ? "true" : "false"),\"error\":\"\"}"
|
||||
}()
|
||||
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: \(json) }));"
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,3 @@ public struct ClawdisCanvasA2UIPushJSONLParams: Codable, Sendable, Equatable {
|
||||
self.jsonl = jsonl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,4 +73,3 @@ public enum ClawdisCanvasA2UIJSONL: Sendable {
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,4 +3,3 @@ import Foundation
|
||||
public enum ClawdisKitResources {
|
||||
public static let bundle: Bundle = .module
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,7 +8,7 @@
|
||||
:root { color-scheme: light dark; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
font: 13px -apple-system, system-ui;
|
||||
font: 14px system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
|
||||
background: #0b1020;
|
||||
color: #e5e7eb;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Canvas</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body::before, body::after { animation: none !important; }
|
||||
}
|
||||
html,body { height:100%; margin:0; }
|
||||
body {
|
||||
background:
|
||||
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
|
||||
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
|
||||
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
|
||||
#000;
|
||||
overflow: hidden;
|
||||
}
|
||||
body::before {
|
||||
content:"";
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
|
||||
transparent 1px, transparent 48px),
|
||||
repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
|
||||
transparent 1px, transparent 48px);
|
||||
transform: translate3d(0,0,0) rotate(-7deg);
|
||||
will-change: transform, opacity;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
animation: clawdis-grid-drift 140s ease-in-out infinite alternate;
|
||||
}
|
||||
body::after {
|
||||
content:"";
|
||||
position: fixed;
|
||||
inset: -35%;
|
||||
background:
|
||||
radial-gradient(900px 700px at 30% 30%, rgba(42,113,255,0.16), rgba(0,0,0,0) 60%),
|
||||
radial-gradient(800px 650px at 70% 35%, rgba(255,0,138,0.12), rgba(0,0,0,0) 62%),
|
||||
radial-gradient(900px 800px at 55% 75%, rgba(0,209,255,0.10), rgba(0,0,0,0) 62%);
|
||||
filter: blur(28px);
|
||||
opacity: 0.52;
|
||||
will-change: transform, opacity;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
transform: translate3d(0,0,0);
|
||||
pointer-events: none;
|
||||
animation: clawdis-glow-drift 110s ease-in-out infinite alternate;
|
||||
}
|
||||
@supports (mix-blend-mode: screen) {
|
||||
body::after { mix-blend-mode: screen; }
|
||||
}
|
||||
@supports not (mix-blend-mode: screen) {
|
||||
body::after { opacity: 0.70; }
|
||||
}
|
||||
@keyframes clawdis-grid-drift {
|
||||
0% { transform: translate3d(-12px, 8px, 0) rotate(-7deg); opacity: 0.40; }
|
||||
50% { transform: translate3d( 10px,-7px, 0) rotate(-6.6deg); opacity: 0.56; }
|
||||
100% { transform: translate3d(-8px, 6px, 0) rotate(-7.2deg); opacity: 0.42; }
|
||||
}
|
||||
@keyframes clawdis-glow-drift {
|
||||
0% { transform: translate3d(-18px, 12px, 0) scale(1.02); opacity: 0.40; }
|
||||
50% { transform: translate3d( 14px,-10px, 0) scale(1.05); opacity: 0.52; }
|
||||
100% { transform: translate3d(-10px, 8px, 0) scale(1.03); opacity: 0.43; }
|
||||
}
|
||||
canvas {
|
||||
display:block;
|
||||
width:100vw;
|
||||
height:100vh;
|
||||
touch-action: none;
|
||||
}
|
||||
#clawdis-status {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
#clawdis-status .card {
|
||||
text-align: center;
|
||||
padding: 16px 18px;
|
||||
border-radius: 14px;
|
||||
background: rgba(18, 18, 22, 0.42);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
#clawdis-status .title {
|
||||
font: 600 20px -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
|
||||
letter-spacing: 0.2px;
|
||||
color: rgba(255,255,255,0.92);
|
||||
text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
|
||||
}
|
||||
#clawdis-status .subtitle {
|
||||
margin-top: 6px;
|
||||
font: 500 12px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
color: rgba(255,255,255,0.58);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="clawdis-canvas"></canvas>
|
||||
<div id="clawdis-status">
|
||||
<div class="card">
|
||||
<div class="title" id="clawdis-status-title">Ready</div>
|
||||
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const canvas = document.getElementById('clawdis-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const statusEl = document.getElementById('clawdis-status');
|
||||
const titleEl = document.getElementById('clawdis-status-title');
|
||||
const subtitleEl = document.getElementById('clawdis-status-subtitle');
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
|
||||
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
window.__clawdis = {
|
||||
canvas,
|
||||
ctx,
|
||||
setStatus: (title, subtitle) => {
|
||||
if (!statusEl) return;
|
||||
if (!title && !subtitle) {
|
||||
statusEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
statusEl.style.display = 'grid';
|
||||
if (titleEl && typeof title === 'string') titleEl.textContent = title;
|
||||
if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
|
||||
// Auto-hide after 3 seconds.
|
||||
clearTimeout(window.__statusTimeout);
|
||||
window.__statusTimeout = setTimeout(() => {
|
||||
statusEl.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct CanvasA2UIActionTests {
|
||||
@Test func sanitizeTagValueIsStable() {
|
||||
#expect(ClawdisCanvasA2UIAction.sanitizeTagValue("Hello World!") == "Hello_World_")
|
||||
#expect(ClawdisCanvasA2UIAction.sanitizeTagValue(" ") == "-")
|
||||
#expect(ClawdisCanvasA2UIAction.sanitizeTagValue("macOS 26.2") == "macOS_26.2")
|
||||
}
|
||||
|
||||
@Test func formatAgentMessageIsTokenEfficientAndUnambiguous() {
|
||||
let msg = ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||
actionName: "Get Weather",
|
||||
sessionKey: "main",
|
||||
surfaceId: "main",
|
||||
sourceComponentId: "btnWeather",
|
||||
host: "Peter’s iPad",
|
||||
instanceId: "ipad16,6",
|
||||
contextJSON: "{\"city\":\"Vienna\"}")
|
||||
|
||||
#expect(msg.contains("CANVAS_A2UI "))
|
||||
#expect(msg.contains("action=Get_Weather"))
|
||||
#expect(msg.contains("session=main"))
|
||||
#expect(msg.contains("surface=main"))
|
||||
#expect(msg.contains("component=btnWeather"))
|
||||
#expect(msg.contains("host=Peter_s_iPad"))
|
||||
#expect(msg.contains("instance=ipad16_6 ctx={\"city\":\"Vienna\"}"))
|
||||
#expect(msg.hasSuffix(" default=update_canvas"))
|
||||
}
|
||||
|
||||
@Test func a2uiBundleSupportsAndroidBridgeFallback() throws {
|
||||
guard let url = ClawdisKitResources.bundle.url(forResource: "a2ui.bundle", withExtension: "js")
|
||||
else {
|
||||
throw NSError(domain: "Tests", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Missing resource a2ui.bundle.js",
|
||||
])
|
||||
}
|
||||
let js = try String(contentsOf: url, encoding: .utf8)
|
||||
#expect(js.contains("clawdisCanvasA2UIAction"))
|
||||
#expect(js.contains("globalThis.clawdisCanvasA2UIAction"))
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,12 @@ const empty = Object.freeze({});
|
||||
const emptyClasses = () => ({});
|
||||
const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} });
|
||||
|
||||
const isAndroid = /Android/i.test(globalThis.navigator?.userAgent ?? "");
|
||||
const cardShadow = isAndroid ? "0 2px 10px rgba(0,0,0,.18)" : "0 10px 30px rgba(0,0,0,.35)";
|
||||
const buttonShadow = isAndroid ? "0 2px 10px rgba(6, 182, 212, 0.14)" : "0 10px 25px rgba(6, 182, 212, 0.18)";
|
||||
const statusShadow = isAndroid ? "0 2px 10px rgba(0, 0, 0, 0.18)" : "0 10px 24px rgba(0, 0, 0, 0.25)";
|
||||
const statusBlur = isAndroid ? "10px" : "14px";
|
||||
|
||||
const clawdisTheme = {
|
||||
components: {
|
||||
AudioPlayer: emptyClasses(),
|
||||
@@ -85,7 +91,7 @@ const clawdisTheme = {
|
||||
border: "1px solid rgba(255,255,255,.09)",
|
||||
borderRadius: "14px",
|
||||
padding: "14px",
|
||||
boxShadow: "0 10px 30px rgba(0,0,0,.35)",
|
||||
boxShadow: cardShadow,
|
||||
},
|
||||
Column: { gap: "10px" },
|
||||
Row: { gap: "10px", alignItems: "center" },
|
||||
@@ -98,7 +104,7 @@ const clawdisTheme = {
|
||||
color: "#071016",
|
||||
fontWeight: "650",
|
||||
cursor: "pointer",
|
||||
boxShadow: "0 10px 25px rgba(6, 182, 212, 0.18)",
|
||||
boxShadow: buttonShadow,
|
||||
},
|
||||
Text: {
|
||||
...textHintStyles(),
|
||||
@@ -161,11 +167,11 @@ class ClawdisA2UIHost extends LitElement {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font: 13px/1.2 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(${statusBlur});
|
||||
-webkit-backdrop-filter: blur(${statusBlur});
|
||||
box-shadow: ${statusShadow};
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
@@ -182,11 +188,11 @@ class ClawdisA2UIHost extends LitElement {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font: 13px/1.2 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(${statusBlur});
|
||||
-webkit-backdrop-filter: blur(${statusBlur});
|
||||
box-shadow: ${statusShadow};
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
@@ -335,10 +341,17 @@ class ClawdisA2UIHost extends LitElement {
|
||||
|
||||
globalThis.__clawdisLastA2UIAction = userAction;
|
||||
|
||||
const handler = globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction;
|
||||
const handler =
|
||||
globalThis.webkit?.messageHandlers?.clawdisCanvasA2UIAction ??
|
||||
globalThis.clawdisCanvasA2UIAction;
|
||||
if (handler?.postMessage) {
|
||||
try {
|
||||
handler.postMessage({ userAction });
|
||||
// WebKit message handlers support structured objects; Android's JS interface expects strings.
|
||||
if (handler === globalThis.clawdisCanvasA2UIAction) {
|
||||
handler.postMessage(JSON.stringify({ userAction }));
|
||||
} else {
|
||||
handler.postMessage({ userAction });
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = String(e?.message ?? e);
|
||||
this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg };
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "rolldown";
|
||||
|
||||
const here = path.dirname(new URL(import.meta.url).pathname);
|
||||
const repoRoot = path.resolve(here, "../../../../../..");
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, "../../../../..");
|
||||
const fromHere = (p) => path.resolve(here, p);
|
||||
const outputFile = path.resolve(here, "../../Sources/ClawdisKit/Resources/CanvasA2UI/a2ui.bundle.js");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user