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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
import OpenClawKit
import SwiftUI
/// Session-scoped dashboard rendered by the gateway Control UI.
struct SessionDashboardScreen: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.dismiss) private var dismiss
let sessionKey: String
var body: some View {
let config = self.appModel.activeGatewayConnectConfig
let storedOperatorToken = AuthenticatedControlUI.storedOperatorToken(config: config)
ZStack {
OpenClawProBackground()
if let url = Self.dashboardURL(config: config, sessionKey: self.sessionKey) {
AuthenticatedControlUIWebView(
url: url,
authScript: AuthenticatedControlUI.authUserScript(
config: config,
pageURL: url,
storedOperatorToken: storedOperatorToken))
.id(AuthenticatedControlUI.webContentIdentity(
config: config,
storedOperatorToken: storedOperatorToken))
.ignoresSafeArea(.container, edges: .bottom)
} else {
self.unavailableCard
}
}
.navigationTitle("Dashboard")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
self.dismiss()
} label: {
Text("Done")
.font(OpenClawType.subheadSemiBold)
}
}
}
}
private var unavailableCard: some View {
VStack(spacing: 12) {
ProIconBadge(systemName: "rectangle.grid.2x2", color: OpenClawBrand.accent)
Text("Dashboard needs a connected gateway")
.font(OpenClawType.subheadSemiBold)
Text("Connect to your gateway to open this session dashboard.")
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(24)
}
/// Converts the active gateway WebSocket endpoint into the one-shot
/// authenticated Control UI route. Credentials stay in the startup script.
static func dashboardURL(config: GatewayConnectConfig?, sessionKey: String) -> URL? {
AuthenticatedControlUI.pageURL(
config: config,
path: "/chat",
queryItems: [
URLQueryItem(name: "session", value: sessionKey),
URLQueryItem(name: "face", value: "dashboard"),
])
}
}

View File

@@ -27,6 +27,11 @@ struct ChatProTab: View {
let fileURL: URL
}
private struct SessionDashboardPresentation: Identifiable {
let id = UUID()
let sessionKey: String
}
@Environment(NodeAppModel.self) private var appModel
@AppStorage("openclaw.webchat.showAssistantTrace")
private var showsAssistantTrace = true
@@ -37,6 +42,7 @@ struct ChatProTab: View {
@State private var showsBackgroundTasks = false
@State private var showsSessions = false
@State private var showsNewSessionOptions = false
@State private var sessionDashboardPresentation: SessionDashboardPresentation?
// Transport can start unscoped while the UI uses its "main" fallback.
// Track the real agent so gateway metadata replaces the captured transport.
@State private var viewModelTransportAgentID = ""
@@ -182,6 +188,11 @@ struct ChatProTab: View {
.presentationDragIndicator(.visible)
}
}
.sheet(item: self.$sessionDashboardPresentation) { presentation in
NavigationStack {
SessionDashboardScreen(sessionKey: presentation.sessionKey)
}
}
.alert(
String(localized: "Unable to Export Transcript"),
isPresented: self.$showsTranscriptExportError)
@@ -629,6 +640,19 @@ struct ChatProTab: View {
}
}
Button {
guard let sessionKey = self.viewModel?.sessionKey else { return }
self.sessionDashboardPresentation = SessionDashboardPresentation(sessionKey: sessionKey)
} label: {
Label {
Text("Dashboard")
.font(OpenClawType.body)
} icon: {
Image(systemName: "rectangle.grid.2x2")
}
}
.disabled(self.viewModel == nil)
Divider()
if let viewModel {

View File

@@ -1,6 +1,5 @@
import OpenClawKit
import SwiftUI
import WebKit
/// Control-hub Terminal destination: embeds the gateway-served terminal page
/// (`/?view=terminal`, the ghostty-web surface shared with the Control UI) in a
@@ -27,7 +26,7 @@ struct TerminalHubScreen: View {
ZStack {
OpenClawProBackground()
if let url = Self.terminalURL(config: config) {
TerminalWebView(
AuthenticatedControlUIWebView(
url: url,
authScript: Self.terminalAuthUserScript(
config: config,
@@ -91,20 +90,10 @@ struct TerminalHubScreen: View {
/// Credentials never enter the URL they are injected as a document-start
/// user script (see `terminalAuthUserScript`), matching the macOS Dashboard.
static func terminalURL(config: GatewayConnectConfig?) -> URL? {
guard let config,
var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false)
else {
return nil
}
switch components.scheme?.lowercased() {
case "wss", "https":
components.scheme = "https"
default:
components.scheme = "http"
}
components.path = "/"
components.queryItems = [URLQueryItem(name: "view", value: "terminal")]
return components.url
AuthenticatedControlUI.pageURL(
config: config,
path: "/",
queryItems: [URLQueryItem(name: "view", value: "terminal")])
}
/// Origin-gated document-start script that hands the gateway credentials to
@@ -121,128 +110,21 @@ struct TerminalHubScreen: View {
config: GatewayConnectConfig?,
storedOperatorToken: String?) -> String?
{
guard let config, let pageURL = terminalURL(config: config) else {
return nil
}
var payload: [String: String] = ["gatewayUrl": config.url.absoluteString]
let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let storedToken = storedOperatorToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !token.isEmpty {
payload["token"] = token
} else if !storedToken.isEmpty {
payload["token"] = storedToken
}
if !password.isEmpty {
payload["password"] = password
}
guard payload["token"] != nil || payload["password"] != nil else {
return nil
}
guard let data = try? JSONSerialization.data(withJSONObject: payload),
let json = String(data: data, encoding: .utf8)
else {
return nil
}
let allowedOrigin = Self.jsStringLiteral(Self.originString(for: pageURL))
return """
(() => {
try {
if (location.origin !== \(allowedOrigin)) return;
Object.defineProperty(window, "__OPENCLAW_NATIVE_CONTROL_AUTH__", {
value: \(json),
configurable: true,
});
} catch {}
})();
"""
AuthenticatedControlUI.authUserScript(
config: config,
pageURL: self.terminalURL(config: config),
storedOperatorToken: storedOperatorToken)
}
/// Identity for the embedded web view: recreate it only when the gateway
/// endpoint or credentials actually change.
static func webContentIdentity(config: GatewayConnectConfig?, storedOperatorToken: String?) -> Int {
var hasher = Hasher()
hasher.combine(config?.url)
hasher.combine(config?.token)
hasher.combine(config?.password)
hasher.combine(storedOperatorToken?.trimmingCharacters(in: .whitespacesAndNewlines))
return hasher.finalize()
AuthenticatedControlUI.webContentIdentity(
config: config,
storedOperatorToken: storedOperatorToken)
}
private static func storedOperatorToken(config: GatewayConnectConfig?) -> String? {
guard let config else { return nil }
// Endpoint handoffs may explicitly suppress device-token reuse; every auth surface
// must honor that boundary or a stale token can override the supplied password.
guard config.nodeOptions.allowStoredDeviceAuth else { return nil }
let gatewayID = config.nodeOptions.deviceAuthGatewayID ?? config.effectiveStableID
guard let identity = DeviceIdentityStore.loadOrCreatePersisted() else { return nil }
return DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "operator",
gatewayID: gatewayID)?
.token
}
static func originString(for url: URL) -> String {
guard let scheme = url.scheme, let host = url.host else {
return ""
}
let hostPart = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host
var origin = "\(scheme)://\(hostPart)"
if let port = url.port {
origin += ":\(port)"
}
return origin
}
private static func jsStringLiteral(_ value: String) -> String {
guard let data = try? JSONSerialization.data(withJSONObject: [value]),
let raw = String(data: data, encoding: .utf8),
raw.hasPrefix("["),
raw.hasSuffix("]")
else {
return "\"\""
}
return String(raw.dropFirst().dropLast())
}
}
/// Minimal WKWebView host for the terminal page. Unlike the canvas WebView it
/// needs no script bridges or deep-link routing the page is self-contained.
private struct TerminalWebView: UIViewRepresentable {
let url: URL
let authScript: String?
func makeUIView(context _: Context) -> WKWebView {
let config = WKWebViewConfiguration()
// Ephemeral store: credentials arrive per load via the auth user
// script; nothing needs to persist across loads.
config.websiteDataStore = .nonPersistent()
if let authScript {
config.userContentController.addUserScript(WKUserScript(
source: authScript,
injectionTime: .atDocumentStart,
forMainFrameOnly: true))
}
let webView = WKWebView(frame: .zero, configuration: config)
webView.isOpaque = true
webView.backgroundColor = .black
let scrollView = webView.scrollView
scrollView.backgroundColor = .black
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.contentInset = .zero
scrollView.verticalScrollIndicatorInsets = .zero
scrollView.horizontalScrollIndicatorInsets = .zero
scrollView.automaticallyAdjustsScrollIndicatorInsets = false
webView.load(URLRequest(url: self.url))
return webView
}
func updateUIView(_: WKWebView, context _: Context) {
// Connection changes recreate the view via `.id(webContentIdentity)`;
// reloading here would restart live shell sessions on unrelated passes.
AuthenticatedControlUI.storedOperatorToken(config: config)
}
}

View File

@@ -0,0 +1,170 @@
import Foundation
import OpenClawKit
import SwiftUI
import WebKit
/// URL, credential, and WebView plumbing shared by authenticated Control UI pages.
enum AuthenticatedControlUI {
private static let queryComponentAllowed = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
static func pageURL(
config: GatewayConnectConfig?,
path: String,
queryItems: [URLQueryItem]) -> URL?
{
guard let config,
var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false)
else {
return nil
}
switch components.scheme?.lowercased() {
case "wss", "https":
components.scheme = "https"
default:
components.scheme = "http"
}
components.path = path
components.fragment = nil
let encodedItems = queryItems.compactMap { item -> String? in
guard let name = Self.percentEncodedQueryComponent(item.name) else { return nil }
guard let value = item.value else { return name }
guard let encodedValue = Self.percentEncodedQueryComponent(value) else { return nil }
return "\(name)=\(encodedValue)"
}
guard encodedItems.count == queryItems.count else { return nil }
components.percentEncodedQuery = encodedItems.joined(separator: "&")
return components.url
}
/// Origin-gated document-start script for the Control UI native-auth contract.
static func authUserScript(
config: GatewayConnectConfig?,
pageURL: URL?,
storedOperatorToken: String?) -> String?
{
guard let config, let pageURL else { return nil }
var payload: [String: String] = ["gatewayUrl": config.url.absoluteString]
let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let storedToken = storedOperatorToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !token.isEmpty {
payload["token"] = token
} else if !storedToken.isEmpty {
payload["token"] = storedToken
}
if !password.isEmpty {
payload["password"] = password
}
guard payload["token"] != nil || payload["password"] != nil else { return nil }
guard let data = try? JSONSerialization.data(withJSONObject: payload),
let json = String(data: data, encoding: .utf8)
else {
return nil
}
let allowedOrigin = Self.jsStringLiteral(Self.originString(for: pageURL))
return """
(() => {
try {
if (location.origin !== \(allowedOrigin)) return;
Object.defineProperty(window, "__OPENCLAW_NATIVE_CONTROL_AUTH__", {
value: \(json),
configurable: true,
});
} catch {}
})();
"""
}
static func storedOperatorToken(config: GatewayConnectConfig?) -> String? {
guard let config else { return nil }
// Endpoint handoffs may explicitly suppress device-token reuse; every auth surface
// must honor that boundary or a stale token can override the supplied password.
guard config.nodeOptions.allowStoredDeviceAuth else { return nil }
let gatewayID = config.nodeOptions.deviceAuthGatewayID ?? config.effectiveStableID
guard let identity = DeviceIdentityStore.loadOrCreatePersisted() else { return nil }
return DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "operator",
gatewayID: gatewayID)?
.token
}
static func webContentIdentity(config: GatewayConnectConfig?, storedOperatorToken: String?) -> Int {
var hasher = Hasher()
hasher.combine(config?.url)
hasher.combine(config?.token)
hasher.combine(config?.password)
hasher.combine(storedOperatorToken?.trimmingCharacters(in: .whitespacesAndNewlines))
return hasher.finalize()
}
private static func percentEncodedQueryComponent(_ value: String) -> String? {
value.addingPercentEncoding(withAllowedCharacters: self.queryComponentAllowed)
}
private static func originString(for url: URL) -> String {
guard let scheme = url.scheme, let host = url.host else { return "" }
let hostPart = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host
var origin = "\(scheme)://\(hostPart)"
if let port = url.port {
origin += ":\(port)"
}
return origin
}
private static func jsStringLiteral(_ value: String) -> String {
guard let data = try? JSONSerialization.data(withJSONObject: [value]),
let raw = String(data: data, encoding: .utf8),
raw.hasPrefix("["),
raw.hasSuffix("]")
else {
return "\"\""
}
return String(raw.dropFirst().dropLast())
}
}
/// Ephemeral, script-hardened WKWebView for a self-contained Control UI page.
struct AuthenticatedControlUIWebView: UIViewRepresentable {
let url: URL
let authScript: String?
func makeUIView(context _: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
configuration.defaultWebpagePreferences.allowsContentJavaScript = true
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
if let authScript {
configuration.userContentController.addUserScript(WKUserScript(
source: authScript,
injectionTime: .atDocumentStart,
forMainFrameOnly: true))
}
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.isOpaque = true
webView.backgroundColor = .black
webView.allowsLinkPreview = false
webView.allowsBackForwardNavigationGestures = true
let scrollView = webView.scrollView
scrollView.backgroundColor = .black
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.contentInset = .zero
scrollView.verticalScrollIndicatorInsets = .zero
scrollView.horizontalScrollIndicatorInsets = .zero
scrollView.automaticallyAdjustsScrollIndicatorInsets = false
webView.load(URLRequest(url: self.url, cachePolicy: .reloadIgnoringLocalCacheData))
return webView
}
func updateUIView(_: WKWebView, context _: Context) {
// Connection changes recreate the view via `.id`; unrelated SwiftUI passes must not reload it.
}
static func dismantleUIView(_ webView: WKWebView, coordinator _: Void) {
webView.stopLoading()
}
}

View File

@@ -0,0 +1,35 @@
import Foundation
import Testing
@testable import OpenClaw
@testable import OpenClawKit
@MainActor
struct SessionDashboardScreenTests {
@Test func `dashboard URL encodes the session and carries the one-shot face`() throws {
let config = try GatewayConnectConfig(
url: #require(URL(string: "wss://gateway.example.com:8443/ws?old=true#fragment")),
stableID: "manual|gateway.example.com|8443",
tls: nil,
token: "secret-token",
bootstrapToken: nil,
password: nil,
nodeOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "ios",
clientMode: "node",
clientDisplayName: "Phone"))
let url = SessionDashboardScreen.dashboardURL(
config: config,
sessionKey: "agent:main/phone & qa?x=1")
#expect(
url?.absoluteString ==
"https://gateway.example.com:8443/chat?session=agent%3Amain%2Fphone%20%26%20qa%3Fx%3D1&face=dashboard")
#expect(url?.absoluteString.contains("secret-token") == false)
}
}

View File

@@ -1231,14 +1231,55 @@ function formatProblems(problems: Array<readonly [string, string[]]>): string {
].join("\n");
}
export async function syncAndroidAppI18n(options: { check?: boolean } = {}) {
/**
* Managed rows (native_*) are owned end-to-end by the post-merge locale
* refresh workflow, so a source PR may legitimately be missing (or still
* carrying) managed rows the workflow will reconcile. Every other delta —
* manual strings, attribute changes, value edits — is real drift.
*/
function onlyManagedRowsPending(current: string, expected: string): boolean {
const currentRows = new Map(parseStrings(current).map((entry) => [entry.key, entry]));
const expectedRows = new Map(parseStrings(expected).map((entry) => [entry.key, entry]));
for (const [key, entry] of currentRows) {
const expectedEntry = expectedRows.get(key);
if (!expectedEntry) {
if (!key.startsWith(MANAGED_PREFIX)) {
return false;
}
continue;
}
if (expectedEntry.attrs !== entry.attrs || expectedEntry.rawValue !== entry.rawValue) {
return false;
}
}
for (const key of expectedRows.keys()) {
if (!currentRows.has(key) && !key.startsWith(MANAGED_PREFIX)) {
return false;
}
}
return true;
}
export async function syncAndroidAppI18n(
options: { check?: boolean; tolerateManagedPending?: boolean } = {},
) {
const catalog = await buildCatalog();
const drift: string[] = [];
let sawUnmanagedDrift = false;
for (const [filePath, expected] of catalog.resources) {
const current = await readFile(filePath, "utf8").catch(() => "");
if (current === expected) {
continue;
}
if (
options.check &&
options.tolerateManagedPending &&
filePath.endsWith("strings.xml") &&
onlyManagedRowsPending(current, expected)
) {
continue;
}
sawUnmanagedDrift = true;
drift.push(path.relative(ROOT, filePath).split(path.sep).join("/"));
if (!options.check) {
await writeFile(filePath, expected);
@@ -1246,9 +1287,13 @@ export async function syncAndroidAppI18n(options: { check?: boolean } = {}) {
}
const currentKotlin = await readFile(GENERATED_KOTLIN_PATH, "utf8").catch(() => "");
if (currentKotlin !== catalog.kotlin) {
drift.push(path.relative(ROOT, GENERATED_KOTLIN_PATH).split(path.sep).join("/"));
if (!options.check) {
await writeFile(GENERATED_KOTLIN_PATH, catalog.kotlin);
// The Kotlin map derives 1:1 from the same managed catalog: tolerate it
// exactly when every resource delta above was managed-pending.
if (!(options.check && options.tolerateManagedPending && !sawUnmanagedDrift)) {
drift.push(path.relative(ROOT, GENERATED_KOTLIN_PATH).split(path.sep).join("/"));
if (!options.check) {
await writeFile(GENERATED_KOTLIN_PATH, catalog.kotlin);
}
}
}
if (options.check && drift.length > 0) {
@@ -1298,9 +1343,9 @@ export async function verifyAndroidAppI18n() {
process.stdout.write(`android-app-i18n: sourceKeys=${baseKeys.size}\n`);
}
export async function checkAndroidAppI18n() {
export async function checkAndroidAppI18n(options: { tolerateManagedPending?: boolean } = {}) {
await verifyAndroidAppI18n();
await syncAndroidAppI18n({ check: true });
await syncAndroidAppI18n({ check: true, ...options });
const localeStrings = await Promise.all(LOCALES.map(readStrings));
const base = expectDefined(localeStrings[0], "English Android string resources");
const translations = localeStrings.slice(1);

View File

@@ -12,7 +12,9 @@ import {
describe("Android app i18n resources", () => {
it("keeps generated resources, runtime coverage, and every locale aligned", async () => {
await expect(checkAndroidAppI18n()).resolves.toBeUndefined();
// Managed native_* rows are reconciled by the post-merge locale refresh
// workflow (#111557); source PRs are validated with those rows pending.
await expect(checkAndroidAppI18n({ tolerateManagedPending: true })).resolves.toBeUndefined();
const base = await readFile("apps/android/app/src/main/res/values/strings.xml", "utf8");
expect(base).toContain('xmlns:tools="http://schemas.android.com/tools"');
expect(base).toMatch(