From 9846c46434ea67a0b751fa7ab85609cab18906b8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 30 Dec 2025 03:49:24 +0100 Subject: [PATCH] fix: tag A2UI platform and boost Android canvas --- CHANGELOG.md | 1 + apps/android/app/build.gradle.kts | 1 + .../com/steipete/clawdis/node/NodeRuntime.kt | 2 +- .../clawdis/node/bridge/BridgeSession.kt | 42 +++++++++++++++++- .../clawdis/node/node/CanvasController.kt | 8 ++++ .../steipete/clawdis/node/ui/RootScreen.kt | 44 ++++++++++++++++++- apps/ios/Sources/Model/NodeAppModel.swift | 2 +- .../macos/Sources/Clawdis/CanvasManager.swift | 2 +- .../Clawdis/NodeMode/MacNodeRuntime.swift | 2 +- .../Resources/CanvasScaffold/scaffold.html | 24 ++++++++++ src/canvas-host/a2ui/index.html | 24 ++++++++++ 11 files changed, 145 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97643cee5..2ef7f5e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured. - Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android). - Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand. +- Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast. - iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first). - macOS menu: device list now uses `node.list` (devices only; no agent/tool presence entries). - macOS menu: device list now shows connected nodes only. diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 1b353d83f..db3b17dca 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -64,6 +64,7 @@ dependencies { implementation("androidx.core:core-ktx:1.17.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0") implementation("androidx.activity:activity-compose:1.12.2") + implementation("androidx.webkit:webkit:1.14.0") implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt index 21a22a428..50fbd3251 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt @@ -815,7 +815,7 @@ class NodeRuntime(context: Context) { val raw = session.currentCanvasHostUrl()?.trim().orEmpty() if (raw.isBlank()) return null val base = raw.trimEnd('/') - return "${base}/__clawdis__/a2ui/" + return "${base}/__clawdis__/a2ui/?platform=android" } private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean { diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeSession.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeSession.kt index e50488d37..5f01959ec 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeSession.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeSession.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import com.steipete.clawdis.node.BuildConfig import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -23,6 +24,7 @@ import java.io.BufferedWriter import java.io.InputStreamReader import java.io.OutputStreamWriter import java.net.InetSocketAddress +import java.net.URI import java.net.Socket import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -213,7 +215,14 @@ class BridgeSession( when (first["type"].asStringOrNull()) { "hello-ok" -> { val name = first["serverName"].asStringOrNull() ?: "Bridge" - canvasHostUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null } + val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null } + canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint) + if (BuildConfig.DEBUG) { + android.util.Log.d( + "ClawdisBridge", + "canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})", + ) + } onConnected(name, conn.remoteAddress) } "error" -> { @@ -292,6 +301,37 @@ class BridgeSession( conn.closeQuietly() } } + + private fun normalizeCanvasHostUrl(raw: String?, endpoint: BridgeEndpoint): String? { + val trimmed = raw?.trim().orEmpty() + val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { URI(it) }.getOrNull() } + val host = parsed?.host?.trim().orEmpty() + val port = parsed?.port ?: -1 + val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" } + + if (trimmed.isNotBlank() && !isLoopbackHost(host)) { + return trimmed + } + + val fallbackHost = + endpoint.tailnetDns?.trim().takeIf { !it.isNullOrEmpty() } + ?: endpoint.lanHost?.trim().takeIf { !it.isNullOrEmpty() } + ?: endpoint.host.trim() + if (fallbackHost.isEmpty()) return trimmed.ifBlank { null } + + val fallbackPort = endpoint.canvasPort ?: if (port > 0) port else 18793 + val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost + return "$scheme://$formattedHost:$fallbackPort" + } + + private fun isLoopbackHost(raw: String?): Boolean { + val host = raw?.trim()?.lowercase().orEmpty() + if (host.isEmpty()) return false + if (host == "localhost") return true + if (host == "::1") return true + if (host == "0.0.0.0" || host == "::") return true + return host.startsWith("127.") + } } private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/node/CanvasController.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/node/CanvasController.kt index 5b4a09b64..685acdcd2 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/node/CanvasController.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/node/CanvasController.kt @@ -3,6 +3,7 @@ package com.steipete.clawdis.node.node import android.graphics.Bitmap import android.graphics.Canvas import android.os.Looper +import android.util.Log import android.webkit.WebView import androidx.core.graphics.createBitmap import androidx.core.graphics.scale @@ -16,6 +17,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import com.steipete.clawdis.node.BuildConfig import kotlin.coroutines.resume class CanvasController { @@ -81,8 +83,14 @@ class CanvasController { val currentUrl = url withWebViewOnMain { wv -> if (currentUrl == null) { + if (BuildConfig.DEBUG) { + Log.d("ClawdisCanvas", "load scaffold: $scaffoldAssetUrl") + } wv.loadUrl(scaffoldAssetUrl) } else { + if (BuildConfig.DEBUG) { + Log.d("ClawdisCanvas", "load url: $currentUrl") + } wv.loadUrl(currentUrl) } } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt index 791f76325..f7681eb49 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt @@ -7,6 +7,8 @@ import android.graphics.Color import android.util.Log import android.view.View import android.webkit.JavascriptInterface +import android.webkit.ConsoleMessage +import android.webkit.WebChromeClient import android.webkit.WebView import android.webkit.WebSettings import android.webkit.WebResourceError @@ -15,6 +17,8 @@ import android.webkit.WebResourceResponse import android.webkit.WebViewClient import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -301,6 +305,15 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) // Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage. settings.domStorageEnabled = true settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { + WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF) + } + if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { + WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) + } + if (isDebuggable) { + Log.d("ClawdisWebView", "userAgent: ${settings.userAgentString}") + } isScrollContainer = true overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS isVerticalScrollBarEnabled = true @@ -331,11 +344,38 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) } override fun onPageFinished(view: WebView, url: String?) { + if (isDebuggable) { + Log.d("ClawdisWebView", "onPageFinished: $url") + } viewModel.canvas.onPageFinished() } + + override fun onRenderProcessGone( + view: WebView, + detail: android.webkit.RenderProcessGoneDetail, + ): Boolean { + if (isDebuggable) { + Log.e( + "ClawdisWebView", + "onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}", + ) + } + return true + } } - setBackgroundColor(Color.BLACK) - setLayerType(View.LAYER_TYPE_HARDWARE, null) + webChromeClient = + object : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + if (!isDebuggable) return false + val msg = consoleMessage ?: return false + Log.d( + "ClawdisWebView", + "console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}", + ) + return false + } + } + // Use default layer/background; avoid forcing a black fill over WebView content. val a2uiBridge = CanvasA2UIActionBridge { payload -> diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 8c2935ffc..554441d1f 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -150,7 +150,7 @@ final class NodeAppModel { guard let raw = await self.bridge.currentCanvasHostUrl() else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } - return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString + return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString + "?platform=ios" } private func showA2UIOnConnectIfNeeded() async { diff --git a/apps/macos/Sources/Clawdis/CanvasManager.swift b/apps/macos/Sources/Clawdis/CanvasManager.swift index c19c5d06d..32163744b 100644 --- a/apps/macos/Sources/Clawdis/CanvasManager.swift +++ b/apps/macos/Sources/Clawdis/CanvasManager.swift @@ -190,7 +190,7 @@ final class CanvasManager { private static func resolveA2UIHostUrl(from raw: String?) -> String? { let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } - return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString + return base.appendingPathComponent("__clawdis__/a2ui/").absoluteString + "?platform=macos" } // MARK: - Anchoring diff --git a/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift index 4b6c8bbc8..b46831034 100644 --- a/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdis/NodeMode/MacNodeRuntime.swift @@ -265,7 +265,7 @@ actor MacNodeRuntime { guard let raw = await GatewayConnection.shared.canvasHostUrl() else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let baseUrl = URL(string: trimmed) else { return nil } - return baseUrl.appendingPathComponent("__clawdis__/a2ui/").absoluteString + return baseUrl.appendingPathComponent("__clawdis__/a2ui/").absoluteString + "?platform=macos" } private func isA2UIReady(poll: Bool = false) async -> Bool { diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/Resources/CanvasScaffold/scaffold.html b/apps/shared/ClawdisKit/Sources/ClawdisKit/Resources/CanvasScaffold/scaffold.html index d9a9cebfd..f8942af6f 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisKit/Resources/CanvasScaffold/scaffold.html +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/Resources/CanvasScaffold/scaffold.html @@ -4,6 +4,21 @@ Canvas +