feat(canvas): remove setMode; host A2UI in scaffold

This commit is contained in:
Peter Steinberger
2025-12-18 13:18:24 +01:00
parent dda6d7f9e1
commit 790079c3b6
12 changed files with 174 additions and 144 deletions

View File

@@ -272,7 +272,6 @@ class NodeRuntime(context: Context) {
buildList {
add(ClawdisCanvasCommand.Show.rawValue)
add(ClawdisCanvasCommand.Hide.rawValue)
add(ClawdisCanvasCommand.SetMode.rawValue)
add(ClawdisCanvasCommand.Navigate.rawValue)
add(ClawdisCanvasCommand.Eval.rawValue)
add(ClawdisCanvasCommand.Snapshot.rawValue)
@@ -544,14 +543,9 @@ class NodeRuntime(context: Context) {
return when (command) {
ClawdisCanvasCommand.Show.rawValue -> BridgeSession.InvokeResult.ok(null)
ClawdisCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
ClawdisCanvasCommand.SetMode.rawValue -> {
val mode = CanvasController.parseMode(paramsJson)
canvas.setMode(mode)
BridgeSession.InvokeResult.ok(null)
}
ClawdisCanvasCommand.Navigate.rawValue -> {
val url = CanvasController.parseNavigateUrl(paramsJson)
if (url != null) canvas.navigate(url)
canvas.navigate(url)
BridgeSession.InvokeResult.ok(null)
}
ClawdisCanvasCommand.Eval.rawValue -> {
@@ -638,7 +632,8 @@ class NodeRuntime(context: Context) {
// ignore
}
canvas.navigate(a2uiIndexUrl)
// Ensure the default canvas scaffold is loaded; A2UI is now hosted there.
canvas.navigate("")
repeat(50) {
try {
val ready = canvas.eval(a2uiReadyCheckJS)
@@ -713,8 +708,6 @@ 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/CanvasA2UI/index.html"
private const val a2uiReadyCheckJS: String =
"""
(() => {

View File

@@ -14,11 +14,8 @@ import android.util.Base64
import kotlin.coroutines.resume
class CanvasController {
enum class Mode { CANVAS, WEB }
@Volatile private var webView: WebView? = null
@Volatile private var mode: Mode = Mode.CANVAS
@Volatile private var url: String = ""
@Volatile private var url: String? = null
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
@@ -27,17 +24,9 @@ class CanvasController {
reload()
}
fun setMode(mode: Mode) {
this.mode = mode
reload()
}
fun navigate(url: String) {
this.url = url
if (url.trim().isNotBlank()) {
// `canvas.navigate` is expected to show web content; default to WEB mode to match iOS.
this.mode = Mode.WEB
}
val trimmed = url.trim()
this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed
reload()
}
@@ -51,17 +40,12 @@ class CanvasController {
}
private fun reload() {
val currentMode = mode
val currentUrl = url
withWebViewOnMain { wv ->
when (currentMode) {
Mode.WEB -> {
// Match iOS behavior: if URL is missing/invalid, keep the current page (canvas scaffold).
val trimmed = currentUrl.trim()
if (trimmed.isBlank()) return@withWebViewOnMain
wv.loadUrl(trimmed)
}
Mode.CANVAS -> wv.loadUrl(scaffoldAssetUrl)
if (currentUrl == null) {
wv.loadUrl(scaffoldAssetUrl)
} else {
wv.loadUrl(currentUrl)
}
}
}
@@ -106,19 +90,9 @@ class CanvasController {
}
companion object {
fun parseMode(paramsJson: String?): Mode {
val obj = parseParamsObject(paramsJson) ?: return Mode.CANVAS
return if (obj.optString("mode", "").equals("web", ignoreCase = true)) {
Mode.WEB
} else {
Mode.CANVAS
}
}
fun parseNavigateUrl(paramsJson: String?): String? {
val obj = parseParamsObject(paramsJson) ?: return null
val url = obj.optString("url", "").trim()
return url.takeIf { it.isNotBlank() }
fun parseNavigateUrl(paramsJson: String?): String {
val obj = parseParamsObject(paramsJson)
return obj?.optString("url", "")?.trim().orEmpty()
}
fun parseEvalJs(paramsJson: String?): String? {

View File

@@ -9,7 +9,6 @@ enum class ClawdisCapability(val rawValue: String) {
enum class ClawdisCanvasCommand(val rawValue: String) {
Show("canvas.show"),
Hide("canvas.hide"),
SetMode("canvas.setMode"),
Navigate("canvas.navigate"),
Eval("canvas.eval"),
Snapshot("canvas.snapshot"),

View File

@@ -8,7 +8,6 @@ class ClawdisProtocolConstantsTest {
fun canvasCommandsUseStableStrings() {
assertEquals("canvas.show", ClawdisCanvasCommand.Show.rawValue)
assertEquals("canvas.hide", ClawdisCanvasCommand.Hide.rawValue)
assertEquals("canvas.setMode", ClawdisCanvasCommand.SetMode.rawValue)
assertEquals("canvas.navigate", ClawdisCanvasCommand.Navigate.rawValue)
assertEquals("canvas.eval", ClawdisCanvasCommand.Eval.rawValue)
assertEquals("canvas.snapshot", ClawdisCanvasCommand.Snapshot.rawValue)

View File

@@ -378,11 +378,6 @@ final class NodeAppModel {
case ClawdisCanvasCommand.hide.rawValue:
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdisCanvasCommand.setMode.rawValue:
let params = try Self.decodeParams(ClawdisCanvasSetModeParams.self, from: req.paramsJSON)
self.screen.setMode(params.mode)
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdisCanvasCommand.navigate.rawValue:
let params = try Self.decodeParams(ClawdisCanvasNavigateParams.self, from: req.paramsJSON)
self.screen.navigate(to: params.url)
@@ -402,7 +397,7 @@ final class NodeAppModel {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisCanvasA2UICommand.reset.rawValue:
try self.screen.showA2UI()
self.screen.showDefaultCanvas()
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
return BridgeInvokeResponse(
id: req.id,
@@ -434,7 +429,7 @@ final class NodeAppModel {
}
}
try self.screen.showA2UI()
self.screen.showDefaultCanvas()
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
return BridgeInvokeResponse(
id: req.id,

View File

@@ -10,7 +10,6 @@ final class ScreenController {
private let navigationDelegate: ScreenNavigationDelegate
private let a2uiActionHandler: CanvasA2UIActionMessageHandler
var mode: ClawdisCanvasMode = .canvas
var urlString: String = ""
var errorText: String?
@@ -47,45 +46,30 @@ final class ScreenController {
self.reload()
}
func setMode(_ mode: ClawdisCanvasMode) {
self.mode = mode
self.reload()
}
func navigate(to urlString: String) {
self.urlString = urlString
if !urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
// `canvas.navigate` is expected to show web content; default to WEB mode.
self.mode = .web
}
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
self.urlString = (trimmed == "/" ? "" : trimmed)
self.reload()
}
func reload() {
switch self.mode {
case .web:
let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
guard let url = Self.canvasScaffoldURL else { return }
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
return
} else {
guard let url = URL(string: trimmed) else { return }
if url.isFileURL {
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
} else {
self.webView.load(URLRequest(url: url))
}
case .canvas:
guard let url = Self.canvasScaffoldURL else { return }
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
}
}
func showA2UI() throws {
guard let url = Self.a2uiIndexURL
else {
throw NSError(domain: "Canvas", code: 10, userInfo: [
NSLocalizedDescriptionKey: "A2UI resources missing (index.html)",
])
}
self.mode = .web
self.urlString = url.absoluteString
func showDefaultCanvas() {
self.urlString = ""
self.reload()
}
@@ -160,10 +144,20 @@ final class ScreenController {
withExtension: "html")
private static let a2uiIndexURL: URL? = ClawdisKitResources.bundle.url(forResource: "index", withExtension: "html")
fileprivate func isBundledA2UIURL(_ url: URL) -> Bool {
fileprivate func isTrustedCanvasUIURL(_ url: URL) -> Bool {
guard url.isFileURL else { return false }
guard let expected = Self.a2uiIndexURL else { return false }
return url.standardizedFileURL == expected.standardizedFileURL
let std = url.standardizedFileURL
if let expected = Self.canvasScaffoldURL,
std == expected.standardizedFileURL
{
return true
}
if let expected = Self.a2uiIndexURL,
std == expected.standardizedFileURL
{
return true
}
return false
}
}
@@ -205,8 +199,8 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan
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 }
// Only accept actions from local bundled canvas/A2UI content (not arbitrary web pages).
guard let url = message.webView?.url, controller.isTrustedCanvasUIURL(url) else { return }
let body: [String: Any] = {
if let dict = message.body as? [String: Any] { return dict }

View File

@@ -1,10 +1,5 @@
import Foundation
public enum ClawdisCanvasMode: String, Codable, Sendable {
case canvas
case web
}
public struct ClawdisCanvasNavigateParams: Codable, Sendable, Equatable {
public var url: String
@@ -13,14 +8,6 @@ public struct ClawdisCanvasNavigateParams: Codable, Sendable, Equatable {
}
}
public struct ClawdisCanvasSetModeParams: Codable, Sendable, Equatable {
public var mode: ClawdisCanvasMode
public init(mode: ClawdisCanvasMode) {
self.mode = mode
}
}
public struct ClawdisCanvasEvalParams: Codable, Sendable, Equatable {
public var javaScript: String

View File

@@ -3,7 +3,6 @@ import Foundation
public enum ClawdisCanvasCommand: String, Codable, Sendable {
case show = "canvas.show"
case hide = "canvas.hide"
case setMode = "canvas.setMode"
case navigate = "canvas.navigate"
case evalJS = "canvas.eval"
case snapshot = "canvas.snapshot"

View File

@@ -69,10 +69,13 @@
100% { transform: translate3d(-10px, 8px, 0) scale(1.03); opacity: 0.43; }
}
canvas {
position: fixed;
inset: 0;
display:block;
width:100vw;
height:100vh;
touch-action: none;
z-index: 1;
}
#clawdis-status {
position: fixed;
@@ -80,6 +83,17 @@
display: grid;
place-items: center;
pointer-events: none;
z-index: 3;
}
#clawdis-a2ui-wrap {
position: fixed;
inset: 0;
display: none;
z-index: 2;
}
#clawdis-a2ui-wrap clawdis-a2ui-host {
display: block;
height: 100%;
}
#clawdis-status .card {
text-align: center;
@@ -112,6 +126,9 @@
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
</div>
</div>
<div id="clawdis-a2ui-wrap">
<clawdis-a2ui-host></clawdis-a2ui-host>
</div>
<script>
(() => {
const canvas = document.getElementById('clawdis-canvas');
@@ -152,7 +169,93 @@
}
};
})();
(() => {
const wrap = document.getElementById('clawdis-a2ui-wrap');
if (!wrap) return;
const candidates = [
// iOS (SwiftPM resources flattened)
"a2ui.bundle.js",
// Android (assets keep directory structure)
"../CanvasA2UI/a2ui.bundle.js",
"CanvasA2UI/a2ui.bundle.js",
];
const loadScript = (src) =>
new Promise((resolve, reject) => {
const el = document.createElement("script");
el.src = src;
el.async = true;
el.onload = () => resolve();
el.onerror = () => reject(new Error(`failed to load ${src}`));
document.head.appendChild(el);
});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const installVisibilityHooks = () => {
const api = globalThis.clawdisA2UI;
if (!api || typeof api.applyMessages !== "function") return false;
if (globalThis.__clawdisA2UIVisibilityHooksInstalled) return true;
globalThis.__clawdisA2UIVisibilityHooksInstalled = true;
const show = () => { wrap.style.display = "block"; };
const hide = () => { wrap.style.display = "none"; };
const sync = () => {
try {
const surfaces =
typeof api.getSurfaces === "function" ? api.getSurfaces() : [];
if (Array.isArray(surfaces) && surfaces.length > 0) show();
else hide();
} catch {
hide();
}
};
const origApply = api.applyMessages.bind(api);
api.applyMessages = (messages) => {
const res = origApply(messages);
sync();
return res;
};
const origReset = api.reset.bind(api);
api.reset = () => {
const res = origReset();
hide();
return res;
};
hide();
return true;
};
(async () => {
if (globalThis.clawdisA2UI) {
installVisibilityHooks();
return;
}
let loaded = false;
for (const src of candidates) {
try {
await loadScript(src);
loaded = true;
break;
} catch {
// try next
}
}
if (!loaded) return;
// Wait for custom element upgrade + connectedCallback to publish globalThis.clawdisA2UI.
for (let i = 0; i < 60; i += 1) {
if (installVisibilityHooks()) return;
await sleep(50);
}
})().catch(() => {});
})();
</script>
</body>
</html>