mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
0cf4b24ad6
commit
262deec72c
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user