mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 12:51:34 +00: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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
68
apps/ios/Sources/Chat/SessionDashboardScreen.swift
Normal file
68
apps/ios/Sources/Chat/SessionDashboardScreen.swift
Normal 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"),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
170
apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift
Normal file
170
apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
35
apps/ios/Tests/SessionDashboardScreenTests.swift
Normal file
35
apps/ios/Tests/SessionDashboardScreenTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user