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 { 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 =
""" """
(() => { (() => {

View File

@@ -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? {

View File

@@ -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"),

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 }

View File

@@ -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

View File

@@ -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"

View File

@@ -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>

View File

@@ -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 machines Tailscale IP instead: For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machines Tailscale IP instead:
@@ -103,28 +103,22 @@ The Android nodes Chat sheet uses the gateways **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):

View File

@@ -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 machines Tailscale IP instead: For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machines 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 cant connect, try resolving one instance: If browse works, but the iOS node cant 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 wont 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 wont 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 apps 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 apps 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 its 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 its 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

View File

@@ -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)