feat(canvas): remove setMode; host A2UI in scaffold
This commit is contained in:
@@ -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 =
|
||||
"""
|
||||
(() => {
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user