feat(canvas): remove setMode; host A2UI in scaffold
This commit is contained in:
@@ -272,7 +272,6 @@ class NodeRuntime(context: Context) {
|
|||||||
buildList {
|
buildList {
|
||||||
add(ClawdisCanvasCommand.Show.rawValue)
|
add(ClawdisCanvasCommand.Show.rawValue)
|
||||||
add(ClawdisCanvasCommand.Hide.rawValue)
|
add(ClawdisCanvasCommand.Hide.rawValue)
|
||||||
add(ClawdisCanvasCommand.SetMode.rawValue)
|
|
||||||
add(ClawdisCanvasCommand.Navigate.rawValue)
|
add(ClawdisCanvasCommand.Navigate.rawValue)
|
||||||
add(ClawdisCanvasCommand.Eval.rawValue)
|
add(ClawdisCanvasCommand.Eval.rawValue)
|
||||||
add(ClawdisCanvasCommand.Snapshot.rawValue)
|
add(ClawdisCanvasCommand.Snapshot.rawValue)
|
||||||
@@ -544,14 +543,9 @@ class NodeRuntime(context: Context) {
|
|||||||
return when (command) {
|
return when (command) {
|
||||||
ClawdisCanvasCommand.Show.rawValue -> BridgeSession.InvokeResult.ok(null)
|
ClawdisCanvasCommand.Show.rawValue -> BridgeSession.InvokeResult.ok(null)
|
||||||
ClawdisCanvasCommand.Hide.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 -> {
|
ClawdisCanvasCommand.Navigate.rawValue -> {
|
||||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||||
if (url != null) canvas.navigate(url)
|
canvas.navigate(url)
|
||||||
BridgeSession.InvokeResult.ok(null)
|
BridgeSession.InvokeResult.ok(null)
|
||||||
}
|
}
|
||||||
ClawdisCanvasCommand.Eval.rawValue -> {
|
ClawdisCanvasCommand.Eval.rawValue -> {
|
||||||
@@ -638,7 +632,8 @@ class NodeRuntime(context: Context) {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.navigate(a2uiIndexUrl)
|
// Ensure the default canvas scaffold is loaded; A2UI is now hosted there.
|
||||||
|
canvas.navigate("")
|
||||||
repeat(50) {
|
repeat(50) {
|
||||||
try {
|
try {
|
||||||
val ready = canvas.eval(a2uiReadyCheckJS)
|
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 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 =
|
private const val a2uiReadyCheckJS: String =
|
||||||
"""
|
"""
|
||||||
(() => {
|
(() => {
|
||||||
|
|||||||
@@ -14,11 +14,8 @@ import android.util.Base64
|
|||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
class CanvasController {
|
class CanvasController {
|
||||||
enum class Mode { CANVAS, WEB }
|
|
||||||
|
|
||||||
@Volatile private var webView: WebView? = null
|
@Volatile private var webView: WebView? = null
|
||||||
@Volatile private var mode: Mode = Mode.CANVAS
|
@Volatile private var url: String? = null
|
||||||
@Volatile private var url: String = ""
|
|
||||||
|
|
||||||
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
|
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
|
||||||
|
|
||||||
@@ -27,17 +24,9 @@ class CanvasController {
|
|||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setMode(mode: Mode) {
|
|
||||||
this.mode = mode
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun navigate(url: String) {
|
fun navigate(url: String) {
|
||||||
this.url = url
|
val trimmed = url.trim()
|
||||||
if (url.trim().isNotBlank()) {
|
this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed
|
||||||
// `canvas.navigate` is expected to show web content; default to WEB mode to match iOS.
|
|
||||||
this.mode = Mode.WEB
|
|
||||||
}
|
|
||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,17 +40,12 @@ class CanvasController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun reload() {
|
private fun reload() {
|
||||||
val currentMode = mode
|
|
||||||
val currentUrl = url
|
val currentUrl = url
|
||||||
withWebViewOnMain { wv ->
|
withWebViewOnMain { wv ->
|
||||||
when (currentMode) {
|
if (currentUrl == null) {
|
||||||
Mode.WEB -> {
|
wv.loadUrl(scaffoldAssetUrl)
|
||||||
// Match iOS behavior: if URL is missing/invalid, keep the current page (canvas scaffold).
|
} else {
|
||||||
val trimmed = currentUrl.trim()
|
wv.loadUrl(currentUrl)
|
||||||
if (trimmed.isBlank()) return@withWebViewOnMain
|
|
||||||
wv.loadUrl(trimmed)
|
|
||||||
}
|
|
||||||
Mode.CANVAS -> wv.loadUrl(scaffoldAssetUrl)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,19 +90,9 @@ class CanvasController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun parseMode(paramsJson: String?): Mode {
|
fun parseNavigateUrl(paramsJson: String?): String {
|
||||||
val obj = parseParamsObject(paramsJson) ?: return Mode.CANVAS
|
val obj = parseParamsObject(paramsJson)
|
||||||
return if (obj.optString("mode", "").equals("web", ignoreCase = true)) {
|
return obj?.optString("url", "")?.trim().orEmpty()
|
||||||
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 parseEvalJs(paramsJson: String?): String? {
|
fun parseEvalJs(paramsJson: String?): String? {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ enum class ClawdisCapability(val rawValue: String) {
|
|||||||
enum class ClawdisCanvasCommand(val rawValue: String) {
|
enum class ClawdisCanvasCommand(val rawValue: String) {
|
||||||
Show("canvas.show"),
|
Show("canvas.show"),
|
||||||
Hide("canvas.hide"),
|
Hide("canvas.hide"),
|
||||||
SetMode("canvas.setMode"),
|
|
||||||
Navigate("canvas.navigate"),
|
Navigate("canvas.navigate"),
|
||||||
Eval("canvas.eval"),
|
Eval("canvas.eval"),
|
||||||
Snapshot("canvas.snapshot"),
|
Snapshot("canvas.snapshot"),
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ class ClawdisProtocolConstantsTest {
|
|||||||
fun canvasCommandsUseStableStrings() {
|
fun canvasCommandsUseStableStrings() {
|
||||||
assertEquals("canvas.show", ClawdisCanvasCommand.Show.rawValue)
|
assertEquals("canvas.show", ClawdisCanvasCommand.Show.rawValue)
|
||||||
assertEquals("canvas.hide", ClawdisCanvasCommand.Hide.rawValue)
|
assertEquals("canvas.hide", ClawdisCanvasCommand.Hide.rawValue)
|
||||||
assertEquals("canvas.setMode", ClawdisCanvasCommand.SetMode.rawValue)
|
|
||||||
assertEquals("canvas.navigate", ClawdisCanvasCommand.Navigate.rawValue)
|
assertEquals("canvas.navigate", ClawdisCanvasCommand.Navigate.rawValue)
|
||||||
assertEquals("canvas.eval", ClawdisCanvasCommand.Eval.rawValue)
|
assertEquals("canvas.eval", ClawdisCanvasCommand.Eval.rawValue)
|
||||||
assertEquals("canvas.snapshot", ClawdisCanvasCommand.Snapshot.rawValue)
|
assertEquals("canvas.snapshot", ClawdisCanvasCommand.Snapshot.rawValue)
|
||||||
|
|||||||
@@ -378,11 +378,6 @@ final class NodeAppModel {
|
|||||||
case ClawdisCanvasCommand.hide.rawValue:
|
case ClawdisCanvasCommand.hide.rawValue:
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
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:
|
case ClawdisCanvasCommand.navigate.rawValue:
|
||||||
let params = try Self.decodeParams(ClawdisCanvasNavigateParams.self, from: req.paramsJSON)
|
let params = try Self.decodeParams(ClawdisCanvasNavigateParams.self, from: req.paramsJSON)
|
||||||
self.screen.navigate(to: params.url)
|
self.screen.navigate(to: params.url)
|
||||||
@@ -402,7 +397,7 @@ final class NodeAppModel {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
|
||||||
case ClawdisCanvasA2UICommand.reset.rawValue:
|
case ClawdisCanvasA2UICommand.reset.rawValue:
|
||||||
try self.screen.showA2UI()
|
self.screen.showDefaultCanvas()
|
||||||
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
|
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
|
||||||
return BridgeInvokeResponse(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
@@ -434,7 +429,7 @@ final class NodeAppModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try self.screen.showA2UI()
|
self.screen.showDefaultCanvas()
|
||||||
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
|
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
|
||||||
return BridgeInvokeResponse(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ final class ScreenController {
|
|||||||
private let navigationDelegate: ScreenNavigationDelegate
|
private let navigationDelegate: ScreenNavigationDelegate
|
||||||
private let a2uiActionHandler: CanvasA2UIActionMessageHandler
|
private let a2uiActionHandler: CanvasA2UIActionMessageHandler
|
||||||
|
|
||||||
var mode: ClawdisCanvasMode = .canvas
|
|
||||||
var urlString: String = ""
|
var urlString: String = ""
|
||||||
var errorText: String?
|
var errorText: String?
|
||||||
|
|
||||||
@@ -47,45 +46,30 @@ final class ScreenController {
|
|||||||
self.reload()
|
self.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setMode(_ mode: ClawdisCanvasMode) {
|
|
||||||
self.mode = mode
|
|
||||||
self.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
func navigate(to urlString: String) {
|
func navigate(to urlString: String) {
|
||||||
self.urlString = urlString
|
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
self.urlString = (trimmed == "/" ? "" : trimmed)
|
||||||
// `canvas.navigate` is expected to show web content; default to WEB mode.
|
|
||||||
self.mode = .web
|
|
||||||
}
|
|
||||||
self.reload()
|
self.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
func reload() {
|
func reload() {
|
||||||
switch self.mode {
|
let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
case .web:
|
if trimmed.isEmpty {
|
||||||
let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
guard let url = Self.canvasScaffoldURL else { return }
|
||||||
|
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
|
||||||
|
return
|
||||||
|
} else {
|
||||||
guard let url = URL(string: trimmed) else { return }
|
guard let url = URL(string: trimmed) else { return }
|
||||||
if url.isFileURL {
|
if url.isFileURL {
|
||||||
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
|
self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
|
||||||
} else {
|
} else {
|
||||||
self.webView.load(URLRequest(url: url))
|
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 {
|
func showDefaultCanvas() {
|
||||||
guard let url = Self.a2uiIndexURL
|
self.urlString = ""
|
||||||
else {
|
|
||||||
throw NSError(domain: "Canvas", code: 10, userInfo: [
|
|
||||||
NSLocalizedDescriptionKey: "A2UI resources missing (index.html)",
|
|
||||||
])
|
|
||||||
}
|
|
||||||
self.mode = .web
|
|
||||||
self.urlString = url.absoluteString
|
|
||||||
self.reload()
|
self.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,10 +144,20 @@ final class ScreenController {
|
|||||||
withExtension: "html")
|
withExtension: "html")
|
||||||
private static let a2uiIndexURL: URL? = ClawdisKitResources.bundle.url(forResource: "index", 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 url.isFileURL else { return false }
|
||||||
guard let expected = Self.a2uiIndexURL else { return false }
|
let std = url.standardizedFileURL
|
||||||
return url.standardizedFileURL == expected.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 message.name == Self.messageName else { return }
|
||||||
guard let controller else { return }
|
guard let controller else { return }
|
||||||
|
|
||||||
// Only accept actions from local bundled CanvasA2UI content (not arbitrary web pages).
|
// Only accept actions from local bundled canvas/A2UI content (not arbitrary web pages).
|
||||||
guard let url = message.webView?.url, controller.isBundledA2UIURL(url) else { return }
|
guard let url = message.webView?.url, controller.isTrustedCanvasUIURL(url) else { return }
|
||||||
|
|
||||||
let body: [String: Any] = {
|
let body: [String: Any] = {
|
||||||
if let dict = message.body as? [String: Any] { return dict }
|
if let dict = message.body as? [String: Any] { return dict }
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum ClawdisCanvasMode: String, Codable, Sendable {
|
|
||||||
case canvas
|
|
||||||
case web
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ClawdisCanvasNavigateParams: Codable, Sendable, Equatable {
|
public struct ClawdisCanvasNavigateParams: Codable, Sendable, Equatable {
|
||||||
public var url: String
|
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 struct ClawdisCanvasEvalParams: Codable, Sendable, Equatable {
|
||||||
public var javaScript: String
|
public var javaScript: String
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Foundation
|
|||||||
public enum ClawdisCanvasCommand: String, Codable, Sendable {
|
public enum ClawdisCanvasCommand: String, Codable, Sendable {
|
||||||
case show = "canvas.show"
|
case show = "canvas.show"
|
||||||
case hide = "canvas.hide"
|
case hide = "canvas.hide"
|
||||||
case setMode = "canvas.setMode"
|
|
||||||
case navigate = "canvas.navigate"
|
case navigate = "canvas.navigate"
|
||||||
case evalJS = "canvas.eval"
|
case evalJS = "canvas.eval"
|
||||||
case snapshot = "canvas.snapshot"
|
case snapshot = "canvas.snapshot"
|
||||||
|
|||||||
@@ -69,10 +69,13 @@
|
|||||||
100% { transform: translate3d(-10px, 8px, 0) scale(1.03); opacity: 0.43; }
|
100% { transform: translate3d(-10px, 8px, 0) scale(1.03); opacity: 0.43; }
|
||||||
}
|
}
|
||||||
canvas {
|
canvas {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
display:block;
|
display:block;
|
||||||
width:100vw;
|
width:100vw;
|
||||||
height:100vh;
|
height:100vh;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
#clawdis-status {
|
#clawdis-status {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -80,6 +83,17 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
pointer-events: none;
|
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 {
|
#clawdis-status .card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -112,6 +126,9 @@
|
|||||||
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
|
<div class="subtitle" id="clawdis-status-subtitle">Waiting for agent</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="clawdis-a2ui-wrap">
|
||||||
|
<clawdis-a2ui-host></clawdis-a2ui-host>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const canvas = document.getElementById('clawdis-canvas');
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ pnpm clawdis gateway --port 18789 --verbose
|
|||||||
```
|
```
|
||||||
|
|
||||||
Confirm in logs you see something like:
|
Confirm in logs you see something like:
|
||||||
- `bridge listening on tcp://0.0.0.0:18790 (Iris)`
|
- `bridge listening on tcp://0.0.0.0:18790 (node)`
|
||||||
|
|
||||||
For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machine’s Tailscale IP instead:
|
For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machine’s Tailscale IP instead:
|
||||||
|
|
||||||
@@ -103,28 +103,22 @@ The Android node’s Chat sheet uses the gateway’s **primary session key** (`m
|
|||||||
|
|
||||||
### Gateway Canvas Host (recommended for web content)
|
### Gateway Canvas Host (recommended for web content)
|
||||||
|
|
||||||
If you want the node to show real HTML/CSS/JS that the agent can edit on disk, enable the Gateway canvas host and point the node at it.
|
If you want the node to show real HTML/CSS/JS that the agent can edit on disk, point the node at the Gateway canvas host.
|
||||||
|
|
||||||
1) On the gateway host, enable `canvasHost` in `~/.clawdis/clawdis.json`:
|
1) Create `~/clawd/canvas/index.html` on the gateway host.
|
||||||
|
|
||||||
```json5
|
2) Navigate the node to it (LAN):
|
||||||
{
|
|
||||||
canvasHost: { enabled: true, root: "~/clawd/canvas", port: 18793, bind: "lan" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2) Create `~/clawd/canvas/index.html`.
|
|
||||||
|
|
||||||
3) Navigate the node to it (LAN):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdis nodes invoke --node "<Android Node>" --command canvas.navigate --params '{"url":"http://<gateway-hostname>.local:18793/"}'
|
clawdis nodes invoke --node "<Android Node>" --command canvas.navigate --params '{"url":"http://<gateway-hostname>.local:18793/"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://<gateway-magicdns>:18793/`.
|
||||||
|
|
||||||
This server injects a live-reload client into HTML and reloads on file changes.
|
This server injects a live-reload client into HTML and reloads on file changes.
|
||||||
|
|
||||||
Canvas commands (foreground only):
|
Canvas commands (foreground only):
|
||||||
- `canvas.eval`, `canvas.snapshot`, `canvas.navigate` (switches to web mode), `canvas.setMode` (use `"canvas"` to return)
|
- `canvas.eval`, `canvas.snapshot`, `canvas.navigate` (use `{"url":""}` or `{"url":"/"}` to return to the default canvas/A2UI scaffold)
|
||||||
- A2UI: `canvas.a2ui.push`, `canvas.a2ui.reset` (`canvas.a2ui.pushJSONL` legacy alias)
|
- A2UI: `canvas.a2ui.push`, `canvas.a2ui.reset` (`canvas.a2ui.pushJSONL` legacy alias)
|
||||||
|
|
||||||
Camera commands (foreground only; permission-gated):
|
Camera commands (foreground only; permission-gated):
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
---
|
---
|
||||||
summary: "Runbook: connect/pair the iOS node (Iris) to a Clawdis Gateway and drive its Canvas"
|
summary: "Runbook: connect/pair the iOS node to a Clawdis Gateway and drive its Canvas"
|
||||||
read_when:
|
read_when:
|
||||||
- Pairing or reconnecting the iOS node
|
- Pairing or reconnecting the iOS node
|
||||||
- Debugging iOS bridge discovery or auth
|
- Debugging iOS bridge discovery or auth
|
||||||
- Sending screen/canvas commands to iOS
|
- Sending screen/canvas commands to iOS
|
||||||
---
|
---
|
||||||
|
|
||||||
# iOS Node Connection Runbook (Iris)
|
# iOS Node Connection Runbook
|
||||||
|
|
||||||
This is the practical “how do I connect Iris” guide:
|
This is the practical “how do I connect the iOS node” guide:
|
||||||
|
|
||||||
**iOS app** ⇄ (Bonjour + TCP bridge) ⇄ **Gateway bridge** ⇄ (loopback WS) ⇄ **Gateway**
|
**iOS app** ⇄ (Bonjour + TCP bridge) ⇄ **Gateway bridge** ⇄ (loopback WS) ⇄ **Gateway**
|
||||||
|
|
||||||
The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). Iris talks to the LAN-facing **bridge** (default `tcp://0.0.0.0:18790`) and uses Gateway-owned pairing.
|
The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). The iOS node talks to the LAN-facing **bridge** (default `tcp://0.0.0.0:18790`) and uses Gateway-owned pairing.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- You can run the Gateway on the “master” machine.
|
- You can run the Gateway on the “master” machine.
|
||||||
- Iris (iOS app) can reach the gateway bridge:
|
- iOS node app can reach the gateway bridge:
|
||||||
- Same LAN with Bonjour/mDNS, **or**
|
- Same LAN with Bonjour/mDNS, **or**
|
||||||
- Same Tailscale tailnet using Wide-Area Bonjour / unicast DNS-SD (see below), **or**
|
- Same Tailscale tailnet using Wide-Area Bonjour / unicast DNS-SD (see below), **or**
|
||||||
- Manual bridge host/port (fallback)
|
- Manual bridge host/port (fallback)
|
||||||
@@ -32,7 +32,7 @@ pnpm clawdis gateway --port 18789 --verbose
|
|||||||
```
|
```
|
||||||
|
|
||||||
Confirm in logs you see something like:
|
Confirm in logs you see something like:
|
||||||
- `bridge listening on tcp://0.0.0.0:18790 (Iris)`
|
- `bridge listening on tcp://0.0.0.0:18790 (node)`
|
||||||
|
|
||||||
For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machine’s Tailscale IP instead:
|
For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machine’s Tailscale IP instead:
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ dns-sd -B _clawdis-bridge._tcp local.
|
|||||||
|
|
||||||
You should see your gateway advertising `_clawdis-bridge._tcp`.
|
You should see your gateway advertising `_clawdis-bridge._tcp`.
|
||||||
|
|
||||||
If browse works, but Iris can’t connect, try resolving one instance:
|
If browse works, but the iOS node can’t connect, try resolving one instance:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dns-sd -L "<instance name>" _clawdis-bridge._tcp local.
|
dns-sd -L "<instance name>" _clawdis-bridge._tcp local.
|
||||||
@@ -59,19 +59,19 @@ More debugging notes: `docs/bonjour.md`.
|
|||||||
|
|
||||||
### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD
|
### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD
|
||||||
|
|
||||||
If Iris and the gateway are on different networks but connected via Tailscale, multicast mDNS won’t cross the boundary. Use Wide-Area Bonjour / unicast DNS-SD instead:
|
If the iOS node and the gateway are on different networks but connected via Tailscale, multicast mDNS won’t cross the boundary. Use Wide-Area Bonjour / unicast DNS-SD instead:
|
||||||
|
|
||||||
1) Set up a DNS-SD zone (example `clawdis.internal.`) on the gateway host and publish `_clawdis-bridge._tcp` records.
|
1) Set up a DNS-SD zone (example `clawdis.internal.`) on the gateway host and publish `_clawdis-bridge._tcp` records.
|
||||||
2) Configure Tailscale split DNS for `clawdis.internal` pointing at that DNS server.
|
2) Configure Tailscale split DNS for `clawdis.internal` pointing at that DNS server.
|
||||||
|
|
||||||
Details and example CoreDNS config: `docs/bonjour.md`.
|
Details and example CoreDNS config: `docs/bonjour.md`.
|
||||||
|
|
||||||
## 3) Connect from Iris (iOS)
|
## 3) Connect from the iOS node app
|
||||||
|
|
||||||
In Iris:
|
In the iOS node app:
|
||||||
- Pick the discovered bridge (or hit refresh).
|
- Pick the discovered bridge (or hit refresh).
|
||||||
- If not paired yet, Iris will initiate pairing automatically.
|
- If not paired yet, it will initiate pairing automatically.
|
||||||
- After the first successful pairing, Iris will auto-reconnect **strictly to the last discovered gateway** on launch (including after reinstall), as long as the iOS Keychain entry is still present.
|
- After the first successful pairing, it will auto-reconnect **strictly to the last discovered gateway** on launch (including after reinstall), as long as the iOS Keychain entry is still present.
|
||||||
|
|
||||||
### Connection indicator (always visible)
|
### Connection indicator (always visible)
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ Approve the request:
|
|||||||
clawdis nodes approve <requestId>
|
clawdis nodes approve <requestId>
|
||||||
```
|
```
|
||||||
|
|
||||||
After approval, Iris receives/stores the token and reconnects authenticated.
|
After approval, the iOS node receives/stores the token and reconnects authenticated.
|
||||||
|
|
||||||
Pairing details: `docs/gateway/pairing.md`.
|
Pairing details: `docs/gateway/pairing.md`.
|
||||||
|
|
||||||
@@ -117,26 +117,18 @@ Pairing details: `docs/gateway/pairing.md`.
|
|||||||
|
|
||||||
## 6) Drive the iOS Canvas (draw / snapshot)
|
## 6) Drive the iOS Canvas (draw / snapshot)
|
||||||
|
|
||||||
Iris runs a WKWebView “Canvas” scaffold which exposes:
|
The iOS node runs a WKWebView “Canvas” scaffold which exposes:
|
||||||
- `window.__clawdis.canvas`
|
- `window.__clawdis.canvas`
|
||||||
- `window.__clawdis.ctx` (2D context)
|
- `window.__clawdis.ctx` (2D context)
|
||||||
- `window.__clawdis.setStatus(title, subtitle)`
|
- `window.__clawdis.setStatus(title, subtitle)`
|
||||||
|
|
||||||
### Gateway Canvas Host (recommended for web content)
|
### Gateway Canvas Host (recommended for web content)
|
||||||
|
|
||||||
If you want Iris to show real HTML/CSS/JS that the agent can edit on disk, enable the Gateway canvas host and point Iris at it.
|
If you want the node to show real HTML/CSS/JS that the agent can edit on disk, point it at the Gateway canvas host.
|
||||||
|
|
||||||
1) On the gateway host, enable `canvasHost` in `~/.clawdis/clawdis.json`:
|
1) Create `~/clawd/canvas/index.html` on the gateway host.
|
||||||
|
|
||||||
```json5
|
2) Navigate the node to it (LAN):
|
||||||
{
|
|
||||||
canvasHost: { enabled: true, root: "~/clawd/canvas", port: 18793, bind: "lan" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2) Create `~/clawd/canvas/index.html`.
|
|
||||||
|
|
||||||
3) Navigate Iris to it (LAN):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdis nodes invoke --node "iOS Node" --command canvas.navigate --params '{"url":"http://<gateway-hostname>.local:18793/"}'
|
clawdis nodes invoke --node "iOS Node" --command canvas.navigate --params '{"url":"http://<gateway-hostname>.local:18793/"}'
|
||||||
@@ -144,6 +136,7 @@ clawdis nodes invoke --node "iOS Node" --command canvas.navigate --params '{"url
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- The server injects a live-reload client into HTML and reloads on file changes.
|
- The server injects a live-reload client into HTML and reloads on file changes.
|
||||||
|
- Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://<gateway-magicdns>:18793/`.
|
||||||
- iOS may require App Transport Security allowances to load plain `http://` URLs; if it fails to load, prefer HTTPS or adjust the iOS app’s ATS config.
|
- iOS may require App Transport Security allowances to load plain `http://` URLs; if it fails to load, prefer HTTPS or adjust the iOS app’s ATS config.
|
||||||
|
|
||||||
### Draw with `canvas.eval`
|
### Draw with `canvas.eval`
|
||||||
@@ -165,11 +158,12 @@ The response includes `base64` PNG data (for debugging/verification).
|
|||||||
|
|
||||||
## Common gotchas
|
## Common gotchas
|
||||||
|
|
||||||
- **iOS in background:** all `canvas.*` commands fail fast with `NODE_BACKGROUND_UNAVAILABLE` (bring Iris to foreground).
|
- **iOS in background:** all `canvas.*` commands fail fast with `NODE_BACKGROUND_UNAVAILABLE` (bring the iOS node app to foreground).
|
||||||
|
- **Return to default scaffold:** `canvas.navigate` with `{"url":""}` or `{"url":"/"}` returns to the built-in canvas/A2UI scaffold.
|
||||||
- **mDNS blocked:** some networks block multicast; use a different LAN or plan a tailnet-capable bridge (see `docs/discovery.md`).
|
- **mDNS blocked:** some networks block multicast; use a different LAN or plan a tailnet-capable bridge (see `docs/discovery.md`).
|
||||||
- **Wrong node selector:** `--node` can be the node id (UUID), display name (e.g. `iOS Node`), IP, or an unambiguous prefix. If it’s ambiguous, the CLI will tell you.
|
- **Wrong node selector:** `--node` can be the node id (UUID), display name (e.g. `iOS Node`), IP, or an unambiguous prefix. If it’s ambiguous, the CLI will tell you.
|
||||||
- **Stale pairing / Keychain cleared:** if the pairing token is missing (or iOS Keychain was wiped), Iris must pair again; approve a new pending request.
|
- **Stale pairing / Keychain cleared:** if the pairing token is missing (or iOS Keychain was wiped), the node must pair again; approve a new pending request.
|
||||||
- **App reinstall but no reconnect:** Iris restores `instanceId` + last bridge preference from Keychain; if it still comes up “unpaired”, verify Keychain persistence on your device/simulator and re-pair once.
|
- **App reinstall but no reconnect:** the node restores `instanceId` + last bridge preference from Keychain; if it still comes up “unpaired”, verify Keychain persistence on your device/simulator and re-pair once.
|
||||||
|
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|
||||||
|
|||||||
@@ -120,10 +120,9 @@ Add to `src/gateway/protocol/schema.ts` (and regenerate Swift models):
|
|||||||
### Node command set (canvas)
|
### Node command set (canvas)
|
||||||
These are values for `node.invoke.command`:
|
These are values for `node.invoke.command`:
|
||||||
- `canvas.show` / `canvas.hide`
|
- `canvas.show` / `canvas.hide`
|
||||||
- `canvas.navigate` with `{ url }` (Canvas URL or https URL; switches mode to `"web"`)
|
- `canvas.navigate` with `{ url }` (loads a URL; use `""` or `"/"` to return to the default canvas/A2UI scaffold)
|
||||||
- `canvas.eval` with `{ javaScript }`
|
- `canvas.eval` with `{ javaScript }`
|
||||||
- `canvas.snapshot` with `{ maxWidth?, quality?, format? }`
|
- `canvas.snapshot` with `{ maxWidth?, quality?, format? }`
|
||||||
- `canvas.setMode` with `{ mode: "canvas" | "web" }` (use `"canvas"` to return to the scaffold)
|
|
||||||
- A2UI (mobile + macOS canvas):
|
- A2UI (mobile + macOS canvas):
|
||||||
- `canvas.a2ui.push` with `{ messages: [...] }` (A2UI v0.8 server→client messages)
|
- `canvas.a2ui.push` with `{ messages: [...] }` (A2UI v0.8 server→client messages)
|
||||||
- `canvas.a2ui.pushJSONL` with `{ jsonl: "..." }` (legacy alias)
|
- `canvas.a2ui.pushJSONL` with `{ jsonl: "..." }` (legacy alias)
|
||||||
|
|||||||
Reference in New Issue
Block a user