feat(mobile): session Dashboard on iOS and Android via authenticated Control UI webview (#112163)

* feat(mobile): session dashboard screens on iOS and Android via authenticated Control UI webview

* fix(android): keep configured Control UI base path in session dashboard URL

* docs(android): note system-trust boundary of the shared Control UI webview

* fix(android): origin-only document-start rule for Control UI auth script

* chore(i18n): refresh native inventory on rebased head

* fix(ios): swiftlint closure form in session dashboard toolbar

* fix(i18n): tolerate workflow-owned pending native rows in PR alignment checks

* fix(android): KTX toUri per lint and refresh native inventory

* fix(android): ktlint import order incl. main-inherited fleet test, refresh inventory
This commit is contained in:
Peter Steinberger
2026-07-21 01:13:23 -07:00
committed by GitHub
parent 0cf4b24ad6
commit 262deec72c
19 changed files with 1090 additions and 524 deletions
@@ -8258,10 +8258,11 @@ internal fun backgroundGatewayFleetPlan(
val desiredSet = desiredStableIds.toSet()
val entriesByStableId = entries.associateBy(GatewayRegistryEntry::stableId)
val resolvedEndpoints =
desiredStableIds.mapNotNull { stableId ->
val entry = entriesByStableId[stableId] ?: return@mapNotNull null
resolveEndpoint(entry)?.let { stableId to it }
}.toMap()
desiredStableIds
.mapNotNull { stableId ->
val entry = entriesByStableId[stableId] ?: return@mapNotNull null
resolveEndpoint(entry)?.let { stableId to it }
}.toMap()
// Discovery gaps remove the current route from resolvedEndpoints, but the desired ID remains.
// Disconnect only when the user disables, forgets, or focuses the gateway.
@@ -0,0 +1,119 @@
package ai.openclaw.app.ui
import ai.openclaw.app.NodeRuntime
import android.annotation.SuppressLint
import android.view.View
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/** Authenticated, hardened WebView host for gateway-served Control UI pages. */
@SuppressLint("SetJavaScriptEnabled")
// Deprecated file-URL settings are still force-disabled defensively, like the canvas host.
@Suppress("DEPRECATION")
@Composable
internal fun ControlUiWebView(
page: NodeRuntime.GatewayControlPage,
url: String,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val webViewRef = remember { arrayOfNulls<WebView>(1) }
DisposableEffect(Unit) {
onDispose {
val webView = webViewRef[0] ?: return@onDispose
webView.stopLoading()
webView.destroy()
webViewRef[0] = null
}
}
AndroidView(
modifier = modifier,
factory = {
val webView = WebView(context)
val webSettings = webView.settings
webSettings.setAllowContentAccess(false)
webSettings.setAllowFileAccess(false)
webSettings.setAllowFileAccessFromFileURLs(false)
webSettings.setAllowUniversalAccessFromFileURLs(false)
webSettings.setSafeBrowsingEnabled(true)
webSettings.javaScriptEnabled = true
webSettings.domStorageEnabled = true
webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
webSettings.builtInZoomControls = false
webSettings.displayZoomControls = false
webSettings.setSupportZoom(false)
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(webSettings, false)
}
webView.overScrollMode = View.OVER_SCROLL_NEVER
// System trust only, matching the terminal host this was extracted from:
// fingerprint-pinned (self-signed) gateways render natively but not here.
// Tracked follow-up: verified SSL handling shared with the native pin.
webView.webViewClient = WebViewClient()
installControlUiAuthScript(webView, page)
webView.loadUrl(url)
webViewRef[0] = webView
webView
},
)
}
/**
* Hands gateway credentials to the Control UI through its native startup
* contract. The script is restricted to the connected gateway origin, so
* credentials never appear in page URLs or WebView history.
*/
private fun installControlUiAuthScript(
webView: WebView,
page: NodeRuntime.GatewayControlPage,
) {
if (page.token == null && page.password == null) return
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return
// Document-start rules are origins (scheme://host[:port]); a base-path URL
// is an invalid rule and throws while constructing the WebView.
val originRule = controlUiOriginRule(page.baseUrl) ?: return
val gatewayUrl = page.baseUrl.replaceFirst("http", "ws")
val payload =
buildJsonObject {
put("gatewayUrl", gatewayUrl)
page.token?.let { put("token", it) }
page.password?.let { put("password", it) }
}
val script =
"""
(() => {
try {
Object.defineProperty(window, "__OPENCLAW_NATIVE_CONTROL_AUTH__", {
value: $payload,
configurable: true,
});
} catch (e) {}
})();
""".trimIndent()
WebViewCompat.addDocumentStartJavaScript(webView, script, setOf(originRule))
}
/** scheme://host[:port] origin for WebView script rules; brackets IPv6 hosts. */
internal fun controlUiOriginRule(baseUrl: String): String? {
val uri = baseUrl.toUri()
val scheme = uri.scheme ?: return null
val host = uri.host ?: return null
val hostPart = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
val port = if (uri.port != -1) ":${uri.port}" else ""
return "$scheme://$hostPart$port"
}
@@ -0,0 +1,120 @@
package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Dashboard
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
/** Gateway Control UI dashboard for one chat session. */
@Composable
internal fun SessionDashboardScreen(
viewModel: MainViewModel,
sessionKey: String,
onBack: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val controlPage by viewModel.gatewayControlPage.collectAsState()
ClawScaffold(
contentPadding = PaddingValues(start = ClawTheme.spacing.lg, top = 14.dp, end = ClawTheme.spacing.lg, bottom = 6.dp),
) {
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
ClawPlainIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = nativeString("Back"),
onClick = onBack,
)
Text(
text = nativeString("Dashboard"),
style = ClawTheme.type.title,
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = Icons.Outlined.Dashboard,
contentDescription = null,
tint = ClawTheme.colors.textMuted,
)
}
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
val page = controlPage
if (isConnected && page != null) {
key(page, sessionKey) {
ControlUiWebView(
page = page,
url = sessionDashboardUrl(baseUrl = page.baseUrl, sessionKey = sessionKey),
modifier = Modifier.fillMaxSize(),
)
}
} else {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = nativeString("Dashboard needs a connected gateway"),
style = ClawTheme.type.section,
color = ClawTheme.colors.text,
)
Text(
text = nativeString("Connect to your gateway to open this session dashboard."),
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
}
}
}
}
}
}
/**
* Builds the one-shot dashboard route without placing credentials in the URL.
* Appends to the served base like the terminal screen so a Control UI mounted
* under gateway.controlUi.basePath keeps its prefix.
*/
internal fun sessionDashboardUrl(
baseUrl: String,
sessionKey: String,
): String =
baseUrl
.trimEnd('/')
.toUri()
.buildUpon()
.appendPath("chat")
.clearQuery()
.fragment(null)
.appendQueryParameter("session", sessionKey)
.appendQueryParameter("face", "dashboard")
.build()
.toString()
@@ -15,11 +15,14 @@ internal class ShellNavigation(
settingsRoute: SettingsRoute = SettingsRoute.Home,
returnTab: Tab? = null,
settingsRouteFromHome: Boolean = false,
dashboardSessionKey: String = "main",
) {
var activeTab by mutableStateOf(activeTab.unifiedChatTab())
private set
var settingsRoute by mutableStateOf(settingsRoute)
private set
var dashboardSessionKey by mutableStateOf(dashboardSessionKey)
private set
// Single-slot origin: Back from a cross-tab detail (settings route, Sessions,
// Providers) returns to the tab that opened it; deeper history intentionally
@@ -59,6 +62,12 @@ internal class ShellNavigation(
activeTab = destination
}
/** Opens the web dashboard for the chat session that initiated navigation. */
fun openSessionDashboard(sessionKey: String) {
dashboardSessionKey = sessionKey
openDetailTab(Tab.Dashboard)
}
/** Unwinds one Back step: settings detail to Home or origin, otherwise tab to origin or Overview. */
fun back() {
if (activeTab == Tab.Settings && settingsRoute != SettingsRoute.Home) {
@@ -68,6 +77,7 @@ internal class ShellNavigation(
return
}
}
if (activeTab == Tab.Dashboard) dashboardSessionKey = "main"
activeTab = returnTab ?: Tab.Overview
returnTab = null
}
@@ -77,7 +87,13 @@ internal class ShellNavigation(
val Saver =
listSaver<ShellNavigation, String>(
save = { nav ->
listOf(nav.activeTab.name, nav.settingsRoute.name, nav.returnTab?.name.orEmpty(), nav.settingsRouteFromHome.toString())
listOf(
nav.activeTab.name,
nav.settingsRoute.name,
nav.returnTab?.name.orEmpty(),
nav.settingsRouteFromHome.toString(),
nav.dashboardSessionKey,
)
},
restore = { saved ->
ShellNavigation(
@@ -85,6 +101,7 @@ internal class ShellNavigation(
settingsRoute = SettingsRoute.valueOf(saved[1]),
returnTab = saved[2].takeIf { it.isNotEmpty() }?.let(Tab::valueOf),
settingsRouteFromHome = saved[3].toBoolean(),
dashboardSessionKey = saved.getOrNull(4) ?: "main",
)
},
)
@@ -92,6 +92,7 @@ import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.outlined.AccessTime
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Dashboard
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material.icons.outlined.MicNone
@@ -138,6 +139,7 @@ internal enum class Tab(
Settings(key = "settings", label = nativeText("Settings"), icon = Icons.Outlined.Settings),
ProvidersModels(key = "providers-models", label = nativeText("Providers"), icon = Icons.Outlined.Inventory2),
Files(key = "files", label = nativeText("Files"), icon = Icons.Outlined.Folder),
Dashboard(key = "dashboard", label = nativeText("Dashboard"), icon = Icons.Outlined.Dashboard),
}
private val shellNavTabs = listOf(Tab.Overview, Tab.Chat, Tab.Settings)
@@ -254,6 +256,7 @@ fun ShellScreen(
UnifiedChatShellScreen(
viewModel = viewModel,
onOpenSessions = { nav.openDetailTab(Tab.Sessions) },
onOpenDashboard = nav::openSessionDashboard,
onOpenGatewaySettings = { nav.openSettingsRoute(SettingsRoute.Gateway) },
)
Tab.Voice ->
@@ -278,6 +281,12 @@ fun ShellScreen(
viewModel = viewModel,
onBack = nav::back,
)
Tab.Dashboard ->
SessionDashboardScreen(
viewModel = viewModel,
sessionKey = nav.dashboardSessionKey,
onBack = nav::back,
)
Tab.Settings ->
SettingsShellScreen(
viewModel = viewModel,
@@ -1,16 +1,10 @@
package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.NodeRuntime
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawTheme
import android.annotation.SuppressLint
import android.view.View
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -26,22 +20,13 @@ import androidx.compose.material.icons.outlined.Terminal
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* Full-height terminal surface: embeds the gateway-served terminal-only
@@ -74,7 +59,11 @@ internal fun TerminalSettingsScreen(
// Recreate the WebView only when the gateway page or credentials
// change; recompositions must not restart live shell sessions.
key(page) {
TerminalWebView(page = page, modifier = Modifier.fillMaxSize())
ControlUiWebView(
page = page,
url = "${page.baseUrl}/?view=terminal",
modifier = Modifier.fillMaxSize(),
)
}
} else {
Column(modifier = Modifier.fillMaxWidth().padding(top = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -86,89 +75,3 @@ internal fun TerminalSettingsScreen(
}
}
}
/** Minimal WebView host for the terminal page; no script bridges needed. */
@SuppressLint("SetJavaScriptEnabled")
// Deprecated file-URL settings are still force-disabled defensively, like the canvas host.
@Suppress("DEPRECATION")
@Composable
private fun TerminalWebView(
page: NodeRuntime.GatewayControlPage,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val webViewRef = remember { arrayOfNulls<WebView>(1) }
DisposableEffect(Unit) {
onDispose {
val webView = webViewRef[0] ?: return@onDispose
webView.stopLoading()
webView.destroy()
webViewRef[0] = null
}
}
AndroidView(
modifier = modifier,
factory = {
val webView = WebView(context)
val webSettings = webView.settings
webSettings.setAllowContentAccess(false)
webSettings.setAllowFileAccess(false)
webSettings.setAllowFileAccessFromFileURLs(false)
webSettings.setAllowUniversalAccessFromFileURLs(false)
webSettings.setSafeBrowsingEnabled(true)
webSettings.javaScriptEnabled = true
webSettings.domStorageEnabled = true
webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
webSettings.builtInZoomControls = false
webSettings.displayZoomControls = false
webSettings.setSupportZoom(false)
// targetSdk 33+ ignores Force Dark APIs; the terminal page owns its own
// dark palette, so opt out of algorithmic darkening like the canvas host.
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(webSettings, false)
}
webView.overScrollMode = View.OVER_SCROLL_NEVER
webView.webViewClient = WebViewClient()
installTerminalAuthScript(webView, page)
webView.loadUrl("${page.baseUrl}/?view=terminal")
webViewRef[0] = webView
webView
},
)
}
/**
* Hands the gateway credentials to the Control UI via its
* `__OPENCLAW_NATIVE_CONTROL_AUTH__` startup contract (the same mechanism the
* macOS Dashboard and iOS Terminal hub use), origin-locked by the platform's
* allowed-origin rules, so the token never appears in the page URL. Without
* document-start script support the page simply shows its own login gate.
*/
private fun installTerminalAuthScript(
webView: WebView,
page: NodeRuntime.GatewayControlPage,
) {
if (page.token == null && page.password == null) return
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return
val gatewayUrl = page.baseUrl.replaceFirst("http", "ws")
val payload =
buildJsonObject {
put("gatewayUrl", gatewayUrl)
page.token?.let { put("token", it) }
page.password?.let { put("password", it) }
}
val script =
"""
(() => {
try {
Object.defineProperty(window, "__OPENCLAW_NATIVE_CONTROL_AUTH__", {
value: $payload,
configurable: true,
});
} catch (e) {}
})();
""".trimIndent()
WebViewCompat.addDocumentStartJavaScript(webView, script, setOf(page.baseUrl))
}
@@ -19,6 +19,7 @@ import androidx.compose.ui.unit.dp
internal fun UnifiedChatShellScreen(
viewModel: MainViewModel,
onOpenSessions: () -> Unit,
onOpenDashboard: (String) -> Unit,
onOpenGatewaySettings: () -> Unit,
) {
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
@@ -40,6 +41,7 @@ internal fun UnifiedChatShellScreen(
}
},
onOpenSessions = onOpenSessions,
onOpenDashboard = onOpenDashboard,
onOpenGatewaySettings = onOpenGatewaySettings,
)
}
@@ -94,6 +94,7 @@ import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.KeyboardArrowDown
@@ -235,6 +236,7 @@ fun ChatScreen(
talkActive: Boolean,
onToggleTalk: () -> Unit,
onOpenSessions: () -> Unit,
onOpenDashboard: (String) -> Unit,
onOpenGatewaySettings: () -> Unit,
) {
val messages by viewModel.chatMessages.collectAsState()
@@ -600,6 +602,7 @@ fun ChatScreen(
viewModel.refreshChat()
viewModel.refreshChatSessions(limit = 100)
},
onOpenDashboard = { onOpenDashboard(sessionKey) },
onOpenBackgroundTasks = { showBackgroundTasks = true },
)
@@ -968,6 +971,7 @@ private fun ChatHeader(
onNewChat: () -> Unit,
onNewChatInWorktree: () -> Unit,
onRefresh: () -> Unit,
onOpenDashboard: () -> Unit,
onOpenBackgroundTasks: () -> Unit,
) {
var actionsMenuExpanded by remember { mutableStateOf(false) }
@@ -1017,6 +1021,14 @@ private fun ChatHeader(
onRefresh()
},
)
DropdownMenuItem(
text = { Text(nativeString("Dashboard")) },
leadingIcon = { Icon(Icons.Default.Dashboard, contentDescription = null) },
onClick = {
actionsMenuExpanded = false
onOpenDashboard()
},
)
DropdownMenuItem(
text = { Text(nativeString("Background tasks")) },
leadingIcon = { Icon(Icons.Default.HourglassEmpty, contentDescription = null) },
@@ -1,8 +1,8 @@
package ai.openclaw.app
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayRegistryEntry
import ai.openclaw.app.gateway.GatewayRegistryEntryKind
import ai.openclaw.app.gateway.GatewayEndpoint
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -0,0 +1,48 @@
package ai.openclaw.app.ui
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SessionDashboardScreenTest {
@Test
fun dashboardUrlAppendsChatRouteAndEncodesSessionKey() {
val url =
sessionDashboardUrl(
baseUrl = "https://gateway.example.com:8443/",
sessionKey = "agent:main/phone & qa?x=1",
)
assertEquals(
"https://gateway.example.com:8443/chat?session=agent%3Amain%2Fphone%20%26%20qa%3Fx%3D1&face=dashboard",
url,
)
}
@Test
fun originRuleDropsBasePathAndKeepsPort() {
assertEquals(
"https://gateway.example.com:8443",
controlUiOriginRule("https://gateway.example.com:8443/openclaw"),
)
assertEquals("http://[::1]:18789", controlUiOriginRule("http://[::1]:18789"))
}
@Test
fun dashboardUrlKeepsConfiguredControlUiBasePath() {
val url =
sessionDashboardUrl(
baseUrl = "https://gateway.example.com:8443/openclaw",
sessionKey = "agent:main:qa",
)
assertEquals(
"https://gateway.example.com:8443/openclaw/chat?session=agent%3Amain%3Aqa&face=dashboard",
url,
)
}
}
@@ -138,6 +138,19 @@ class ShellScreenLogicTest {
assertEquals(Tab.Chat, nav.activeTab)
}
@Test
fun sessionDashboardRoutePreservesTheOpeningSessionAndReturnsToChat() {
val nav = ShellNavigation()
nav.selectTab(Tab.Chat)
nav.openSessionDashboard("agent:main:phone")
assertEquals(Tab.Dashboard, nav.activeTab)
assertEquals("agent:main:phone", nav.dashboardSessionKey)
nav.back()
assertEquals(Tab.Chat, nav.activeTab)
}
@Test
fun tabBarSelectionClearsCrossTabReturnOrigin() {
val nav = ShellNavigation()