Skip to content

Commit fcd7eb6

Browse files
committed
feat(android): terminal settings screen hosting the gateway terminal page
1 parent 211e0d7 commit fcd7eb6

5 files changed

Lines changed: 210 additions & 5 deletions

File tree

apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ class MainViewModel(
113113
val notificationForwardingSessionKey: StateFlow<String?> = prefs.notificationForwardingSessionKey
114114

115115
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
116+
val gatewayControlPage: StateFlow<NodeRuntime.GatewayControlPage?> =
117+
runtimeState(initial = null) { it.gatewayControlPage }
116118
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
117119
val nodeCapabilityApproval: StateFlow<GatewayNodeCapabilityApproval> =
118120
runtimeState(initial = GatewayNodeCapabilityApproval.Loading) { it.nodeCapabilityApproval }

apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package ai.openclaw.app
22

3-
import ai.openclaw.app.chat.ChatController
43
import ai.openclaw.app.chat.ChatCommandEntry
4+
import ai.openclaw.app.chat.ChatController
55
import ai.openclaw.app.chat.ChatMessage
66
import ai.openclaw.app.chat.ChatPendingToolCall
77
import ai.openclaw.app.chat.ChatSessionEntry
@@ -16,6 +16,7 @@ import ai.openclaw.app.gateway.GatewayTlsProbeFailure
1616
import ai.openclaw.app.gateway.GatewayTlsProbeResult
1717
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
1818
import ai.openclaw.app.gateway.NodeEventSendOutcome
19+
import ai.openclaw.app.gateway.formatGatewayAuthority
1920
import ai.openclaw.app.gateway.normalizeGatewayApprovalRequestId
2021
import ai.openclaw.app.gateway.normalizeGatewayTlsFingerprint
2122
import ai.openclaw.app.gateway.parseChatSendAck
@@ -302,6 +303,17 @@ class NodeRuntime(
302303
val password: String?,
303304
)
304305

306+
/**
307+
* HTTP(S) page origin of the connected gateway plus the shared credential a
308+
* gateway-served page (e.g. the `?view=terminal` Control UI document) can
309+
* authenticate with. Derived from the same endpoint/auth the WS sessions use.
310+
*/
311+
data class GatewayControlPage(
312+
val baseUrl: String,
313+
val token: String?,
314+
val password: String?,
315+
)
316+
305317
private val appContext = context.applicationContext
306318
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
307319
private val deviceAuthStore = DeviceAuthStore(prefs)
@@ -490,6 +502,8 @@ class NodeRuntime(
490502

491503
private val _isConnected = MutableStateFlow(false)
492504
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
505+
private val _gatewayControlPage = MutableStateFlow<GatewayControlPage?>(null)
506+
val gatewayControlPage: StateFlow<GatewayControlPage?> = _gatewayControlPage.asStateFlow()
493507
private val _nodeConnected = MutableStateFlow(false)
494508
val nodeConnected: StateFlow<Boolean> = _nodeConnected.asStateFlow()
495509
private val _nodeCapabilityApproval = MutableStateFlow<GatewayNodeCapabilityApproval>(GatewayNodeCapabilityApproval.Loading)
@@ -1964,6 +1978,12 @@ class NodeRuntime(
19641978
) {
19651979
if (!isCurrentConnectAttempt(connectAttemptId)) return
19661980
connectedEndpoint = endpoint
1981+
_gatewayControlPage.value =
1982+
GatewayControlPage(
1983+
baseUrl = gatewayControlPageBaseUrl(endpoint),
1984+
token = auth.token?.trim()?.takeIf { it.isNotEmpty() },
1985+
password = auth.password?.trim()?.takeIf { it.isNotEmpty() },
1986+
)
19671987
updateStatus {
19681988
operatorConnectionProblem = null
19691989
nodeConnectionProblem = null
@@ -1984,6 +2004,12 @@ class NodeRuntime(
19842004
beginConnect(endpoint = endpoint, auth = resolveGatewayConnectAuth(auth))
19852005
}
19862006

2007+
/** HTTP(S) origin serving the connected gateway's Control UI pages. */
2008+
private fun gatewayControlPageBaseUrl(endpoint: GatewayEndpoint): String {
2009+
val scheme = if (endpoint.tlsEnabled) "https" else "http"
2010+
return "$scheme://${formatGatewayAuthority(endpoint.host, endpoint.port)}"
2011+
}
2012+
19872013
internal fun resolveGatewayConnectAuth(explicitAuth: GatewayConnectAuth? = null): GatewayConnectAuth =
19882014
explicitAuth
19892015
?: GatewayConnectAuth(
@@ -2075,6 +2101,7 @@ class NodeRuntime(
20752101
connectAttemptSeq.incrementAndGet()
20762102
stopActiveVoiceSession()
20772103
connectedEndpoint = null
2104+
_gatewayControlPage.value = null
20782105
activeGatewayAuth = null
20792106
updateStatus {
20802107
operatorConnectionProblem = null
@@ -3635,11 +3662,9 @@ internal fun parseGatewayNodeApprovalState(raw: String?): GatewayNodeApprovalSta
36353662
else -> GatewayNodeApprovalState.Loading
36363663
}
36373664

3638-
internal fun gatewayEventInvalidatesNodesDevices(event: String): Boolean =
3639-
event == "node.pair.requested" || event == "node.pair.resolved"
3665+
internal fun gatewayEventInvalidatesNodesDevices(event: String): Boolean = event == "node.pair.requested" || event == "node.pair.resolved"
36403666

3641-
internal fun nodeConnectFailureNeedsApprovalRefresh(error: GatewaySession.ErrorShape): Boolean =
3642-
error.details?.code == "PAIRING_REQUIRED"
3667+
internal fun nodeConnectFailureNeedsApprovalRefresh(error: GatewaySession.ErrorShape): Boolean = error.details?.code == "PAIRING_REQUIRED"
36433668

36443669
internal fun currentNodeCapabilityApproval(
36453670
nodes: List<GatewayNodeSummary>,

apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ internal enum class SettingsRoute {
140140
Channels,
141141
Dreaming,
142142
Canvas,
143+
Terminal,
143144
Notifications,
144145
PhoneCapabilities,
145146
Gateway,
@@ -172,6 +173,7 @@ internal fun SettingsDetailScreen(
172173
SettingsRoute.Channels -> ChannelsSettingsScreen(viewModel = viewModel, onBack = onBack)
173174
SettingsRoute.Dreaming -> DreamingSettingsScreen(viewModel = viewModel, onBack = onBack)
174175
SettingsRoute.Canvas -> CanvasSettingsScreen(viewModel = viewModel, onBack = onBack)
176+
SettingsRoute.Terminal -> TerminalSettingsScreen(viewModel = viewModel, onBack = onBack)
175177
SettingsRoute.Notifications -> NotificationSettingsScreen(viewModel = viewModel, onBack = onBack)
176178
SettingsRoute.PhoneCapabilities -> PhoneCapabilitiesScreen(viewModel = viewModel, onBack = onBack)
177179
SettingsRoute.Gateway -> GatewaySettingsScreen(viewModel = viewModel, onBack = onBack)

apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import androidx.compose.material.icons.outlined.ChatBubbleOutline
7777
import androidx.compose.material.icons.outlined.Inventory2
7878
import androidx.compose.material.icons.outlined.MicNone
7979
import androidx.compose.material.icons.outlined.Settings
80+
import androidx.compose.material.icons.outlined.Terminal
8081
import androidx.compose.material3.AlertDialog
8182
import androidx.compose.material3.HorizontalDivider
8283
import androidx.compose.material3.Icon
@@ -1443,6 +1444,7 @@ private fun SettingsShellScreen(
14431444
SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = SettingsRoute.Usage),
14441445
SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = SettingsRoute.Skills),
14451446
SettingsRow("Dreaming", dreamingSummaryText(dreamingSummary), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming),
1447+
SettingsRow("Terminal", "Shell in the agent workspace", Icons.Outlined.Terminal, status = isConnected, route = SettingsRoute.Terminal),
14461448
SettingsRow("Voice", if (speakerEnabled) "Speaker on" else "Speaker muted", Icons.Default.Mic, route = SettingsRoute.Voice),
14471449
SettingsRow("Canvas", "Screen surface", Icons.AutoMirrored.Filled.ScreenShare, status = isConnected, route = SettingsRoute.Canvas),
14481450
SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications),
@@ -1649,6 +1651,7 @@ internal fun settingsSectionTitleForRoute(route: SettingsRoute): String =
16491651
SettingsRoute.Usage,
16501652
SettingsRoute.Skills,
16511653
SettingsRoute.Dreaming,
1654+
SettingsRoute.Terminal,
16521655
-> "Agents & automation"
16531656

16541657
SettingsRoute.Voice,
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package ai.openclaw.app.ui
2+
3+
import ai.openclaw.app.MainViewModel
4+
import ai.openclaw.app.NodeRuntime
5+
import ai.openclaw.app.ui.design.ClawPlainIconButton
6+
import ai.openclaw.app.ui.design.ClawScaffold
7+
import ai.openclaw.app.ui.design.ClawTheme
8+
import android.annotation.SuppressLint
9+
import android.view.View
10+
import android.webkit.WebSettings
11+
import android.webkit.WebView
12+
import android.webkit.WebViewClient
13+
import androidx.compose.foundation.layout.Arrangement
14+
import androidx.compose.foundation.layout.Box
15+
import androidx.compose.foundation.layout.Column
16+
import androidx.compose.foundation.layout.PaddingValues
17+
import androidx.compose.foundation.layout.Row
18+
import androidx.compose.foundation.layout.fillMaxSize
19+
import androidx.compose.foundation.layout.fillMaxWidth
20+
import androidx.compose.foundation.layout.imePadding
21+
import androidx.compose.foundation.layout.padding
22+
import androidx.compose.material.icons.Icons
23+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
24+
import androidx.compose.material.icons.outlined.Terminal
25+
import androidx.compose.material3.Icon
26+
import androidx.compose.material3.Text
27+
import androidx.compose.runtime.Composable
28+
import androidx.compose.runtime.DisposableEffect
29+
import androidx.compose.runtime.collectAsState
30+
import androidx.compose.runtime.getValue
31+
import androidx.compose.runtime.key
32+
import androidx.compose.runtime.remember
33+
import androidx.compose.ui.Alignment
34+
import androidx.compose.ui.Modifier
35+
import androidx.compose.ui.platform.LocalContext
36+
import androidx.compose.ui.text.style.TextOverflow
37+
import androidx.compose.ui.unit.dp
38+
import androidx.compose.ui.viewinterop.AndroidView
39+
import androidx.webkit.WebSettingsCompat
40+
import androidx.webkit.WebViewCompat
41+
import androidx.webkit.WebViewFeature
42+
import kotlinx.serialization.json.buildJsonObject
43+
import kotlinx.serialization.json.put
44+
45+
/**
46+
* Full-height terminal surface: embeds the gateway-served terminal-only
47+
* Control UI document (`/?view=terminal`, the same ghostty-web surface the
48+
* desktop Control UI uses) for the currently connected gateway.
49+
*/
50+
@Composable
51+
internal fun TerminalSettingsScreen(
52+
viewModel: MainViewModel,
53+
onBack: () -> Unit,
54+
) {
55+
val isConnected by viewModel.isConnected.collectAsState()
56+
val controlPage by viewModel.gatewayControlPage.collectAsState()
57+
ClawScaffold(
58+
contentPadding = PaddingValues(start = ClawTheme.spacing.lg, top = 14.dp, end = ClawTheme.spacing.lg, bottom = 6.dp),
59+
) {
60+
Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
61+
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
62+
ClawPlainIconButton(
63+
icon = Icons.AutoMirrored.Filled.ArrowBack,
64+
contentDescription = "Back",
65+
onClick = onBack,
66+
)
67+
Text(text = "Terminal", style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
68+
Icon(imageVector = Icons.Outlined.Terminal, contentDescription = null, tint = ClawTheme.colors.textMuted)
69+
}
70+
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
71+
val page = controlPage
72+
if (isConnected && page != null) {
73+
// Recreate the WebView only when the gateway page or credentials
74+
// change; recompositions must not restart live shell sessions.
75+
key(page) {
76+
TerminalWebView(page = page, modifier = Modifier.fillMaxSize())
77+
}
78+
} else {
79+
Column(modifier = Modifier.fillMaxWidth().padding(top = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
80+
Text(text = "Terminal needs a connected gateway", style = ClawTheme.type.section, color = ClawTheme.colors.text)
81+
Text(text = "Connect to your gateway to open a shell in the agent workspace.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
82+
}
83+
}
84+
}
85+
}
86+
}
87+
}
88+
89+
/** Minimal WebView host for the terminal page; no script bridges needed. */
90+
@SuppressLint("SetJavaScriptEnabled")
91+
// Deprecated file-URL settings are still force-disabled defensively, like the canvas host.
92+
@Suppress("DEPRECATION")
93+
@Composable
94+
private fun TerminalWebView(
95+
page: NodeRuntime.GatewayControlPage,
96+
modifier: Modifier = Modifier,
97+
) {
98+
val context = LocalContext.current
99+
val webViewRef = remember { arrayOfNulls<WebView>(1) }
100+
101+
DisposableEffect(Unit) {
102+
onDispose {
103+
val webView = webViewRef[0] ?: return@onDispose
104+
webView.stopLoading()
105+
webView.destroy()
106+
webViewRef[0] = null
107+
}
108+
}
109+
110+
AndroidView(
111+
modifier = modifier,
112+
factory = {
113+
val webView = WebView(context)
114+
val webSettings = webView.settings
115+
webSettings.setAllowContentAccess(false)
116+
webSettings.setAllowFileAccess(false)
117+
webSettings.setAllowFileAccessFromFileURLs(false)
118+
webSettings.setAllowUniversalAccessFromFileURLs(false)
119+
webSettings.setSafeBrowsingEnabled(true)
120+
webSettings.javaScriptEnabled = true
121+
webSettings.domStorageEnabled = true
122+
webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
123+
webSettings.builtInZoomControls = false
124+
webSettings.displayZoomControls = false
125+
webSettings.setSupportZoom(false)
126+
// targetSdk 33+ ignores Force Dark APIs; the terminal page owns its own
127+
// dark palette, so opt out of algorithmic darkening like the canvas host.
128+
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
129+
WebSettingsCompat.setAlgorithmicDarkeningAllowed(webSettings, false)
130+
}
131+
webView.overScrollMode = View.OVER_SCROLL_NEVER
132+
webView.webViewClient = WebViewClient()
133+
installTerminalAuthScript(webView, page)
134+
webView.loadUrl("${page.baseUrl}/?view=terminal")
135+
webViewRef[0] = webView
136+
webView
137+
},
138+
)
139+
}
140+
141+
/**
142+
* Hands the gateway credentials to the Control UI via its
143+
* `__OPENCLAW_NATIVE_CONTROL_AUTH__` startup contract (the same mechanism the
144+
* macOS Dashboard and iOS Terminal hub use), origin-locked by the platform's
145+
* allowed-origin rules, so the token never appears in the page URL. Without
146+
* document-start script support the page simply shows its own login gate.
147+
*/
148+
private fun installTerminalAuthScript(
149+
webView: WebView,
150+
page: NodeRuntime.GatewayControlPage,
151+
) {
152+
if (page.token == null && page.password == null) return
153+
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return
154+
val gatewayUrl = page.baseUrl.replaceFirst("http", "ws")
155+
val payload =
156+
buildJsonObject {
157+
put("gatewayUrl", gatewayUrl)
158+
page.token?.let { put("token", it) }
159+
page.password?.let { put("password", it) }
160+
}
161+
val script =
162+
"""
163+
(() => {
164+
try {
165+
Object.defineProperty(window, "__OPENCLAW_NATIVE_CONTROL_AUTH__", {
166+
value: $payload,
167+
configurable: true,
168+
});
169+
} catch (e) {}
170+
})();
171+
""".trimIndent()
172+
WebViewCompat.addDocumentStartJavaScript(webView, script, setOf(page.baseUrl))
173+
}

0 commit comments

Comments
 (0)