fix: restore canvas action bridge

This commit is contained in:
Peter Steinberger
2025-12-21 00:53:11 +01:00
parent 78159a9435
commit 2b2f13ca79
10 changed files with 86 additions and 10 deletions

View File

@@ -396,8 +396,7 @@ class NodeRuntime(context: Context) {
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 name = ClawdisCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
val surfaceId =
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }

View File

@@ -1,6 +1,24 @@
package com.steipete.clawdis.node.protocol
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
object ClawdisCanvasA2UIAction {
fun extractActionName(userAction: JsonObject): String? {
val name =
(userAction["name"] as? JsonPrimitive)
?.content
?.trim()
.orEmpty()
if (name.isNotEmpty()) return name
val action =
(userAction["action"] as? JsonPrimitive)
?.content
?.trim()
.orEmpty()
return action.ifEmpty { null }
}
fun sanitizeTagValue(value: String): String {
val trimmed = value.trim().ifEmpty { "-" }
val normalized = trimmed.replace(" ", "_")
@@ -46,4 +64,3 @@ object ClawdisCanvasA2UIAction {
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
}
}

View File

@@ -191,11 +191,14 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
setBackgroundColor(Color.BLACK)
setLayerType(View.LAYER_TYPE_HARDWARE, null)
addJavascriptInterface(
val a2uiBridge =
CanvasA2UIActionBridge { payload ->
viewModel.handleCanvasA2UIActionFromWebView(payload)
},
CanvasA2UIActionBridge.interfaceName,
}
addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName)
addJavascriptInterface(
CanvasA2UIActionLegacyBridge(a2uiBridge),
CanvasA2UIActionLegacyBridge.interfaceName,
)
viewModel.canvas.attach(this)
}
@@ -215,3 +218,19 @@ private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
const val interfaceName: String = "clawdisCanvasA2UIAction"
}
}
private class CanvasA2UIActionLegacyBridge(private val bridge: CanvasA2UIActionBridge) {
@JavascriptInterface
fun canvasAction(payload: String?) {
bridge.postMessage(payload)
}
@JavascriptInterface
fun postMessage(payload: String?) {
bridge.postMessage(payload)
}
companion object {
const val interfaceName: String = "Android"
}
}

View File

@@ -1,9 +1,24 @@
package com.steipete.clawdis.node.protocol
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Test
class ClawdisCanvasA2UIActionTest {
@Test
fun extractActionNameAcceptsNameOrAction() {
val nameObj = Json.parseToJsonElement("{\"name\":\"Hello\"}").jsonObject
assertEquals("Hello", ClawdisCanvasA2UIAction.extractActionName(nameObj))
val actionObj = Json.parseToJsonElement("{\"action\":\"Wave\"}").jsonObject
assertEquals("Wave", ClawdisCanvasA2UIAction.extractActionName(actionObj))
val fallbackObj =
Json.parseToJsonElement("{\"name\":\" \",\"action\":\"Fallback\"}").jsonObject
assertEquals("Fallback", ClawdisCanvasA2UIAction.extractActionName(fallbackObj))
}
@Test
fun formatAgentMessageMatchesSharedSpec() {
val msg =
@@ -32,4 +47,3 @@ class ClawdisCanvasA2UIActionTest {
)
}
}

View File

@@ -30,6 +30,11 @@
<string>Clawdis can capture photos or short video clips when requested via the bridge.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Clawdis discovers and connects to your Clawdis bridge on the local network.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>Clawdis needs microphone access for voice wake.</string>
<key>NSSpeechRecognitionUsageDescription</key>

View File

@@ -80,7 +80,7 @@ final class NodeAppModel {
}()
guard !userAction.isEmpty else { return }
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return }
let actionId: String = {
let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return id.isEmpty ? UUID().uuidString : id

View File

@@ -24,7 +24,9 @@ final class ScreenController {
config.websiteDataStore = .nonPersistent()
let a2uiActionHandler = CanvasA2UIActionMessageHandler()
let userContentController = WKUserContentController()
userContentController.add(a2uiActionHandler, name: CanvasA2UIActionMessageHandler.messageName)
for name in CanvasA2UIActionMessageHandler.handlerNames {
userContentController.add(a2uiActionHandler, name: name)
}
config.userContentController = userContentController
self.navigationDelegate = ScreenNavigationDelegate()
self.a2uiActionHandler = a2uiActionHandler
@@ -323,6 +325,8 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
static let messageName = "clawdisCanvasA2UIAction"
static let legacyMessageNames = ["canvas", "a2ui", "userAction", "action"]
static let handlerNames = [messageName] + legacyMessageNames
weak var controller: ScreenController?

View File

@@ -639,7 +639,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
}()
guard !userAction.isEmpty else { return }
guard let name = userAction["name"] as? String, !name.isEmpty else { return }
guard let name = ClawdisCanvasA2UIAction.extractActionName(userAction) else { return }
let actionId =
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? UUID().uuidString

View File

@@ -1,6 +1,17 @@
import Foundation
public enum ClawdisCanvasA2UIAction: Sendable {
public static func extractActionName(_ userAction: [String: Any]) -> String? {
let keys = ["name", "action"]
for key in keys {
if let raw = userAction[key] as? String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
}
}
return nil
}
public static func sanitizeTagValue(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let nonEmpty = trimmed.isEmpty ? "-" : trimmed

View File

@@ -9,6 +9,13 @@ import Testing
#expect(ClawdisCanvasA2UIAction.sanitizeTagValue("macOS 26.2") == "macOS_26.2")
}
@Test func extractActionNameAcceptsNameOrAction() {
#expect(ClawdisCanvasA2UIAction.extractActionName(["name": "Hello"]) == "Hello")
#expect(ClawdisCanvasA2UIAction.extractActionName(["action": "Wave"]) == "Wave")
#expect(ClawdisCanvasA2UIAction.extractActionName(["name": " ", "action": "Fallback"]) == "Fallback")
#expect(ClawdisCanvasA2UIAction.extractActionName(["action": " "]) == nil)
}
@Test func formatAgentMessageIsTokenEfficientAndUnambiguous() {
let msg = ClawdisCanvasA2UIAction.formatAgentMessage(
actionName: "Get Weather",