feat: view your machine's screen from the iOS and Android apps (#123097)

* feat(ui): add mobile desktop document mode

Add a shell-free mobile desktop route that reuses the dock panel controller and lazy noVNC client, with source preselection, touch controls, keyboard input, and retryable inventory failures.

* feat(ios): add desktop viewer entry points

* feat(android): add desktop viewer

* fix(android): keep System Back inside the desktop viewer

The per-session viewer replaces SessionDashboardScreen in place instead of
pushing a shell tab, so System Back fell through to the shell-level handler
and popped the whole Dashboard tab, stranding the operator on Chat. Claim
Back while the viewer is showing.

Also carry over TerminalSettingsScreen's imePadding: the viewer's own touch
toolbar hosts the keyboard affordance, so the soft keyboard would cover it.

Proof (emulator, Medium_Phone_API_36.0, stub control UI on 18789):
pre-fix Back from the viewer lands on Chat; post-fix it returns to Dashboard.
No Robolectric regression test — no existing screen test constructs
MainViewModel, and BackHandler under Robolectric would need new scaffolding
for weaker evidence than the live repro.

* test(ui): stop the pairing views leaking dialogs into the shared document

`ui/vitest.config.ts` runs the unit project with `isolate: false`, so test files
share one jsdom document inside a worker. `view.pairing.test.ts` appends a
container to `document.body` for every case and never tears down, unlike its
sibling `channels-page.test.ts`, so whichever suite the worker scheduled next
inherited a mounted pairing dialog.

That surfaced on this PR's first CI run as ten failures in the untouched
`input-dialog.test.ts`, which found "Approve DM access" where it expected
"Rename session". A rerun went green, so the ordering is scheduler-dependent
rather than deterministic; this removes the contamination source rather than
leaving the next suite to lose the race.

Not a proven fix for that specific run — the leak reproduces only under CI's
file scheduling, and the full suite passes locally either way — but the missing
teardown is a real violation of the shared-environment contract.

* test(ui): stop the background-tasks rail asserting on a ticking clock

The rail e2e captured the main transcript's text before opening a task detail
and required it to be byte-identical afterwards. A running task renders a live
elapsed label, so the assertion failed whenever a second ticked over between
the two reads — twice while landing this PR, both times "12s" against "13s"
with no other difference.

Normalize elapsed labels on both sides instead of weakening the assertion. The
invariant it protects, that opening a detail leaves the main transcript alone,
still holds: a real content change is still caught, and only complete duration
tokens collapse, so diffstat figures like +14/-3 and phrases like "5 messages"
are untouched.
This commit is contained in:
Peter Steinberger
2026-08-13 04:10:48 -07:00
committed by GitHub
parent d5d069a09e
commit 2a8b322ebf
35 changed files with 2404 additions and 872 deletions
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,7 @@ internal object AndroidScreenshotFixture {
}
const val gatewayId = "android-screenshot-gateway"
const val controlUiBaseUrl = "http://127.0.0.1:18789"
const val mainSessionKey = "agent:main:node-screenshot"
const val primarySessionTitle = "Android release planning"
const val cronJobId = "android-release-digest"
@@ -17,6 +17,7 @@ enum class AndroidScreenshotScene(
Settings("settings", HomeDestination.Settings),
Gateway("gateway", HomeDestination.Settings, SettingsRoute.Gateway),
OpenClaw("openclaw", HomeDestination.Settings, SettingsRoute.SystemAgent),
Desktop("desktop", HomeDestination.Settings, SettingsRoute.Desktop),
VoiceWake("voice-wake", HomeDestination.Settings, SettingsRoute.Voice),
;
@@ -517,6 +517,8 @@ class MainViewModel private constructor(
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
val gatewayControlPage: StateFlow<NodeRuntime.GatewayControlPage?> =
runtimeState(initial = null) { it.gatewayControlPage }
val desktopObserveAvailable: StateFlow<Boolean> =
runtimeState(initial = false) { it.desktopObserveAvailable }
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
val nodeCapabilityApproval: StateFlow<GatewayNodeCapabilityApproval> =
runtimeState(initial = GatewayNodeCapabilityApproval.Loading) { it.nodeCapabilityApproval }
@@ -1119,6 +1119,8 @@ class NodeRuntime private constructor(
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _gatewayControlPage = MutableStateFlow<GatewayControlPage?>(null)
val gatewayControlPage: StateFlow<GatewayControlPage?> = _gatewayControlPage.asStateFlow()
private val _desktopObserveAvailable = MutableStateFlow(false)
val desktopObserveAvailable: StateFlow<Boolean> = _desktopObserveAvailable.asStateFlow()
private val _nodeConnected = MutableStateFlow(false)
val nodeConnected: StateFlow<Boolean> = _nodeConnected.asStateFlow()
private val _nodeCapabilityApproval = MutableStateFlow<GatewayNodeCapabilityApproval>(GatewayNodeCapabilityApproval.Loading)
@@ -2914,6 +2916,14 @@ class NodeRuntime private constructor(
_serverName.value = "OpenClaw Gateway"
_remoteAddress.value = "Mac Studio on local network"
_gatewayVersion.value = BuildConfig.VERSION_NAME
replaceGatewayMethods(setOf(GatewayMethod.DesktopObserve.rawValue))
_gatewayControlPage.value =
GatewayControlPage(
baseUrl = AndroidScreenshotFixture.controlUiBaseUrl,
token = null,
password = null,
tlsFingerprintSha256 = null,
)
updateGatewayDefaultAgentId("main")
_gatewayAgents.value = AndroidScreenshotFixture.agents
_modelCatalog.value = AndroidScreenshotFixture.models
@@ -7523,6 +7533,7 @@ class NodeRuntime private constructor(
synchronized(gatewayMethodsLock) {
gatewayApprovalRpcFamily = selectGatewayApprovalRpcFamily(methods)
_clawHubSkillMethodsAvailable.value = supportsClawHubSkillManagement(methods)
_desktopObserveAvailable.value = GatewayMethod.DesktopObserve.rawValue in methods
systemAgentChatSupported.value = GatewayMethod.OpenclawChat.rawValue in methods
gatewayMethodsEpoch += 1
}
@@ -0,0 +1,121 @@
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.imePadding
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.DesktopWindows
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
/** Full-height viewer for a gateway-observable desktop source. */
@Composable
internal fun DesktopScreen(
viewModel: MainViewModel,
source: String? = null,
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),
) {
// The viewer's keyboard affordance opens the soft keyboard over the canvas; without
// imePadding it would also cover the viewer's own touch toolbar.
Column(modifier = Modifier.fillMaxSize().imePadding(), 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("Desktop"),
style = ClawTheme.type.title,
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = Icons.Outlined.DesktopWindows,
contentDescription = null,
tint = ClawTheme.colors.textMuted,
)
}
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
val page = controlPage
if (isConnected && page != null) {
// GatewayControlPage equality includes credentials and the accepted TLS pin.
key(page, source) {
ControlUiWebView(
page = page,
url = desktopUrl(baseUrl = page.baseUrl, source = source),
modifier = Modifier.fillMaxSize(),
)
}
} else {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = nativeString("Desktop needs a connected gateway"),
style = ClawTheme.type.section,
color = ClawTheme.colors.text,
)
Text(
text = nativeString("Connect to your gateway to view a machine screen."),
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
}
}
}
}
}
}
/** Builds the desktop document route; credentials stay in ControlUiWebView's startup script. */
internal fun desktopUrl(
baseUrl: String,
source: String? = null,
): String {
val baseUri = baseUrl.trimEnd('/').toUri()
val routePath = "${baseUri.encodedPath.orEmpty().trimEnd('/')}/"
val builder =
baseUri
.buildUpon()
.encodedPath(routePath)
.clearQuery()
.fragment(null)
.appendQueryParameter("view", "desktop")
source?.let { builder.appendQueryParameter("source", it) }
return builder.build().toString()
}
@@ -5,6 +5,7 @@ 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.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -16,12 +17,16 @@ 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.material.icons.outlined.DesktopWindows
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.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
@@ -37,6 +42,17 @@ internal fun SessionDashboardScreen(
) {
val isConnected by viewModel.isConnected.collectAsState()
val controlPage by viewModel.gatewayControlPage.collectAsState()
val desktopObserveAvailable by viewModel.desktopObserveAvailable.collectAsState()
var showingDesktop by rememberSaveable(sessionKey) { mutableStateOf(false) }
if (showingDesktop) {
// The viewer replaces this screen in place rather than pushing a shell tab, so it must
// claim System Back itself; the shell handler would otherwise pop the whole dashboard.
BackHandler { showingDesktop = false }
// Session summaries do not advertise an environment id, so the viewer opens
// its source picker instead of guessing a gateway or node association.
DesktopScreen(viewModel = viewModel, source = null, onBack = { showingDesktop = false })
return
}
ClawScaffold(
contentPadding = PaddingValues(start = ClawTheme.spacing.lg, top = 14.dp, end = ClawTheme.spacing.lg, bottom = 6.dp),
) {
@@ -59,6 +75,13 @@ internal fun SessionDashboardScreen(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (desktopObserveAvailable) {
ClawPlainIconButton(
icon = Icons.Outlined.DesktopWindows,
contentDescription = nativeString("Open desktop"),
onClick = { showingDesktop = true },
)
}
Icon(
imageVector = Icons.Outlined.Dashboard,
contentDescription = null,
@@ -189,6 +189,7 @@ internal enum class SettingsRoute {
Dreaming,
Canvas,
Terminal,
Desktop,
Notifications,
PhoneCapabilities,
Gateway,
@@ -224,6 +225,7 @@ internal fun SettingsDetailScreen(
SettingsRoute.Dreaming -> DreamingSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Canvas -> CanvasSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Terminal -> TerminalSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Desktop -> DesktopScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Notifications -> NotificationSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.PhoneCapabilities -> PhoneCapabilitiesScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Gateway -> GatewaySettingsScreen(viewModel = viewModel, onBack = onBack)
@@ -93,6 +93,7 @@ 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.DesktopWindows
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material.icons.outlined.MicNone
@@ -1684,6 +1685,7 @@ private fun SettingsShellScreen(
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
val channelsSummary by viewModel.channelsSummary.collectAsState()
val dreamingSummary by viewModel.dreamingSummary.collectAsState()
val desktopObserveAvailable by viewModel.desktopObserveAvailable.collectAsState()
val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState()
val providerRows = providerRows(providers = providers, models = models)
val readyProviderCount = providerRows.count { it.ready }
@@ -1748,7 +1750,7 @@ private fun SettingsShellScreen(
}
val settingsRows =
listOf(
listOfNotNull(
SettingsRow(
nativeText("Gateway"),
verbatimText(gatewaySummary(gatewayConnectionDisplay)),
@@ -1801,6 +1803,11 @@ private fun SettingsShellScreen(
),
SettingsRow(nativeText("Dreaming"), verbatimText(dreamingSummaryText(dreamingSummary)), Icons.Default.Storage, status = dreamingStatus(dreamingSummary), route = SettingsRoute.Dreaming),
SettingsRow(nativeText("Terminal"), nativeText("Shell in the agent workspace"), Icons.Outlined.Terminal, status = isConnected, route = SettingsRoute.Terminal),
if (desktopObserveAvailable) {
SettingsRow(nativeText("Desktop"), nativeText("View a machine screen"), Icons.Outlined.DesktopWindows, status = isConnected, route = SettingsRoute.Desktop)
} else {
null
},
SettingsRow(nativeText("Voice"), if (speakerEnabled) nativeText("Speaker on") else nativeText("Speaker muted"), Icons.Default.Mic, route = SettingsRoute.Voice),
SettingsRow(nativeText("Canvas"), nativeText("Screen surface"), Icons.AutoMirrored.Filled.ScreenShare, status = isConnected, route = SettingsRoute.Canvas),
SettingsRow(nativeText("Notifications"), if (notificationForwardingEnabled) nativeText("Smart delivery") else nativeText("Off"), Icons.Default.Notifications, route = SettingsRoute.Notifications),
@@ -2053,6 +2060,7 @@ internal fun settingsSectionTitleForRoute(route: SettingsRoute): NativeText =
SettingsRoute.SkillWorkshop,
SettingsRoute.Dreaming,
SettingsRoute.Terminal,
SettingsRoute.Desktop,
-> nativeText("Agents & automation")
SettingsRoute.Voice,
@@ -47,6 +47,7 @@ class AndroidScreenshotModeTest {
assertEquals(HomeDestination.Chat, AndroidScreenshotScene.Chat.homeDestination)
assertEquals(HomeDestination.Chat, AndroidScreenshotScene.Swarm.homeDestination)
assertEquals(HomeDestination.Settings, AndroidScreenshotScene.Settings.homeDestination)
assertEquals(HomeDestination.Settings, AndroidScreenshotScene.Desktop.homeDestination)
assertEquals(HomeDestination.Settings, AndroidScreenshotScene.VoiceWake.homeDestination)
}
@@ -81,4 +82,12 @@ class AndroidScreenshotModeTest {
assertEquals(AndroidScreenshotScene.VoiceWake, scene)
assertEquals(SettingsRoute.Voice, scene.settingsRoute)
}
@Test
fun desktopSceneTargetsDesktopSettings() {
val scene = AndroidScreenshotScene.fromRawValue("desktop")
assertEquals(AndroidScreenshotScene.Desktop, scene)
assertEquals(SettingsRoute.Desktop, scene.settingsRoute)
}
}
@@ -0,0 +1,37 @@
package ai.openclaw.app.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
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 DesktopScreenTest {
@Test
fun desktopUrlUsesDocumentModeWithoutSource() {
val url = desktopUrl(baseUrl = "https://gateway.example.com:8443/openclaw/")
assertEquals("https://gateway.example.com:8443/openclaw/?view=desktop", url)
assertFalse(url.contains("token="))
assertFalse(url.contains("password="))
}
@Test
fun desktopUrlEncodesProvidedSource() {
val url =
desktopUrl(
baseUrl = "https://gateway.example.com:8443",
source = "environment:Mac Studio/QA & demo",
)
assertEquals(
"https://gateway.example.com:8443/?view=desktop&source=environment%3AMac%20Studio%2FQA%20%26%20demo",
url,
)
assertFalse(url.contains("token="))
assertFalse(url.contains("password="))
}
}
@@ -5,6 +5,7 @@ import SwiftUI
struct SessionDashboardScreen: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.dismiss) private var dismiss
@State private var showsDesktop = false
let sessionKey: String
var body: some View {
@@ -31,6 +32,18 @@ struct SessionDashboardScreen: View {
.navigationTitle("Dashboard")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if self.appModel.isDesktopObserveAvailable {
ToolbarItem(placement: .topBarTrailing) {
Button {
self.showsDesktop = true
} label: {
Image(systemName: "display")
.font(OpenClawType.subheadSemiBold)
}
.accessibilityLabel("Open Desktop")
.accessibilityIdentifier("SessionDashboard.Desktop")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
self.dismiss()
@@ -40,6 +53,11 @@ struct SessionDashboardScreen: View {
}
}
}
.navigationDestination(isPresented: self.$showsDesktop) {
// Session dashboard presentation currently carries only the session key,
// so the desktop document mode owns source selection.
DesktopHubScreen(source: nil, usesNativeNavigationChrome: true)
}
}
private var unavailableCard: some View {
@@ -0,0 +1,139 @@
import OpenClawKit
import SwiftUI
/// Control-hub Desktop destination: embeds the gateway-served desktop page in
/// the same authenticated, origin-locked WKWebView used by other Control UI pages.
struct DesktopHubScreen: View {
@Environment(NodeAppModel.self) private var appModel
let source: String?
let headerSidebarAction: OpenClawSidebarHeaderAction?
let usesNativeNavigationChrome: Bool
let gatewayAction: (() -> Void)?
init(
source: String? = nil,
headerSidebarAction: OpenClawSidebarHeaderAction? = nil,
usesNativeNavigationChrome: Bool = false,
gatewayAction: (() -> Void)? = nil)
{
self.source = source
self.headerSidebarAction = headerSidebarAction
self.usesNativeNavigationChrome = usesNativeNavigationChrome
self.gatewayAction = gatewayAction
}
var body: some View {
let config = self.appModel.activeGatewayConnectConfig
let storedOperatorToken = AuthenticatedControlUI.storedOperatorToken(config: config)
ZStack {
OpenClawProBackground()
if let url = Self.desktopURL(config: config, source: self.source) {
AuthenticatedControlUIWebView(
url: url,
authScript: Self.desktopAuthUserScript(
config: config,
source: self.source,
storedOperatorToken: storedOperatorToken),
tls: config?.tls)
.id(Self.webContentIdentity(
config: config,
source: self.source,
storedOperatorToken: storedOperatorToken))
.ignoresSafeArea(.container, edges: .bottom)
} else {
self.unavailableCard
}
}
.navigationTitle("Desktop")
.navigationBarTitleDisplayMode(.inline)
.toolbar(
self.usesNativeNavigationChrome || self.headerSidebarAction != nil ? .visible : .hidden,
for: .navigationBar)
.toolbar {
if self.usesNativeNavigationChrome, let gatewayAction {
ToolbarItem(placement: .topBarTrailing) {
Button(action: gatewayAction) {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(OpenClawType.subheadSemiBold)
}
.accessibilityLabel("Gateway settings")
}
}
if let headerSidebarAction {
OpenClawSidebarToolbarItem(
action: headerSidebarAction,
placement: .topBarLeading)
}
}
}
private var unavailableCard: some View {
VStack(spacing: 12) {
ProIconBadge(systemName: "display", color: OpenClawBrand.accent)
Text("Desktop needs a connected gateway")
.font(OpenClawType.subheadSemiBold)
Text("Connect to your gateway to view an observable machine.")
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if let gatewayAction {
Button(action: gatewayAction) {
Text("Open Gateway Settings")
.font(OpenClawType.subheadSemiBold)
}
.buttonStyle(.borderedProminent)
.tint(OpenClawBrand.accent)
}
}
.padding(24)
}
/// Credentials never enter this URL; the document-start user script carries
/// them through the Control UI's native-auth contract.
static func desktopURL(config: GatewayConnectConfig?, source: String?) -> URL? {
var queryItems = [URLQueryItem(name: "view", value: "desktop")]
if let source = self.normalizedSource(source) {
queryItems.append(URLQueryItem(name: "source", value: source))
}
return AuthenticatedControlUI.pageURL(
config: config,
path: "/",
queryItems: queryItems)
}
static func desktopAuthUserScript(config: GatewayConnectConfig?, source: String?) -> String? {
self.desktopAuthUserScript(
config: config,
source: source,
storedOperatorToken: AuthenticatedControlUI.storedOperatorToken(config: config))
}
static func desktopAuthUserScript(
config: GatewayConnectConfig?,
source: String?,
storedOperatorToken: String?) -> String?
{
AuthenticatedControlUI.authUserScript(
config: config,
pageURL: self.desktopURL(config: config, source: source),
storedOperatorToken: storedOperatorToken)
}
static func webContentIdentity(
config: GatewayConnectConfig?,
source: String?,
storedOperatorToken: String?) -> Int
{
var hasher = Hasher()
hasher.combine(AuthenticatedControlUI.webContentIdentity(
config: config,
storedOperatorToken: storedOperatorToken))
hasher.combine(self.normalizedSource(source))
return hasher.finalize()
}
private static func normalizedSource(_ source: String?) -> String? {
let trimmed = source?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}
+27
View File
@@ -376,6 +376,8 @@ final class NodeAppModel {
self.operatorConnected
}
private(set) var isDesktopObserveAvailable: Bool = false
private(set) var hasOperatorAdminScope: Bool = false
var gatewayServerName: String?
@@ -4491,6 +4493,10 @@ extension NodeAppModel {
}
}
self.setOperatorConnected(true)
await self.refreshDesktopObserveAvailability(
stableID: stableID,
routeGeneration: routeGeneration)
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
self.clearOperatorGatewayConnectionProblemIfCurrent()
GatewayDiagnostics.log(
"operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
@@ -5245,6 +5251,9 @@ extension NodeAppModel {
func setOperatorConnected(_ connected: Bool) {
let changed = self.operatorConnected != connected
self.operatorConnected = connected
if !connected {
self.isDesktopObserveAvailable = false
}
self.operatorStatusText = connected ? "Connected" : "Offline"
self.refreshOperatorAdminScopeFromStore()
guard connected else {
@@ -5276,6 +5285,24 @@ extension NodeAppModel {
}
}
private func refreshDesktopObserveAvailability(stableID: String, routeGeneration: UInt64) async {
// Advertised methods belong to one admitted operator route. Never let a
// reconnect publish support learned from the socket it replaced.
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID),
let route = await self.operatorGateway.currentRoute(ifGatewayID: stableID)
else {
self.isDesktopObserveAvailable = false
return
}
let supported = await self.operatorGateway.supportsServerMethod(
"desktop.observe",
ifCurrentRoute: route) == true
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID),
await self.operatorGateway.currentRoute(ifGatewayID: stableID) == route
else { return }
self.isDesktopObserveAvailable = supported
}
func refreshOperatorAdminScopeFromStore() {
guard let config = activeGatewayConnectConfig else {
self.hasOperatorAdminScope = false
+14 -4
View File
@@ -43,7 +43,8 @@ struct RootSidebar: View {
.background(OpenClawSidebarPalette.background)
.sheet(isPresented: self.$showsPagesEditor) {
RootSidebarPagesEditor(
pinnedPages: self.pinnedPages,
destinations: RootTabs.pinnableSidebarPages.filter(self.isDestinationAvailable),
pinnedPages: self.storedPinnedPages,
onSelect: { destination in
self.showsPagesEditor = false
self.selectSidebarDestination(destination)
@@ -52,12 +53,20 @@ struct RootSidebar: View {
}
}
private var pinnedPages: [RootTabs.SidebarDestination] {
private var storedPinnedPages: [RootTabs.SidebarDestination] {
RootTabs.pinnedSidebarPages(from: self.pinnedPagesStorage)
}
private var pinnedPages: [RootTabs.SidebarDestination] {
self.storedPinnedPages.filter(self.isDestinationAvailable)
}
private func isDestinationAvailable(_ destination: RootTabs.SidebarDestination) -> Bool {
destination != .desktop || self.appModel.isDesktopObserveAvailable
}
private func togglePinnedPage(_ destination: RootTabs.SidebarDestination) {
var pages = self.pinnedPages
var pages = self.storedPinnedPages
if let index = pages.firstIndex(of: destination) {
pages.remove(at: index)
} else {
@@ -864,6 +873,7 @@ struct RootSidebar: View {
/// Web-parity Pages editor (the pen menu): navigate to any page, pin/unpin
/// which ones stay in the sidebar. Home is fixed and not listed.
struct RootSidebarPagesEditor: View {
let destinations: [RootTabs.SidebarDestination]
let pinnedPages: [RootTabs.SidebarDestination]
let onSelect: (RootTabs.SidebarDestination) -> Void
let onTogglePin: (RootTabs.SidebarDestination) -> Void
@@ -874,7 +884,7 @@ struct RootSidebarPagesEditor: View {
NavigationStack {
List {
Section {
ForEach(RootTabs.pinnableSidebarPages) { destination in
ForEach(self.destinations) { destination in
self.pageRow(destination)
}
} footer: {
+4
View File
@@ -394,6 +394,10 @@ struct RootTabs: View {
headerTitle: "Automations",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .desktop:
DesktopHubScreen(
headerSidebarAction: self.sidebarHeaderAction,
gatewayAction: { self.selectSidebarDestination(.gateway) })
case .terminal:
TerminalHubScreen(
headerSidebarAction: self.sidebarHeaderAction,
+5 -1
View File
@@ -27,6 +27,7 @@ extension RootTabs {
case dreaming
case usage
case cron
case desktop
case terminal
case docs
case settings
@@ -50,6 +51,7 @@ extension RootTabs {
case .dreaming: String(localized: "Dreaming")
case .usage: String(localized: "Usage")
case .cron: String(localized: "Automations")
case .desktop: String(localized: "Desktop")
case .terminal: String(localized: "Terminal")
case .docs: String(localized: "Docs")
case .settings: String(localized: "Settings")
@@ -78,6 +80,7 @@ extension RootTabs {
case .dreaming: "moon.stars"
case .usage: "chart.bar.xaxis"
case .cron: "timer"
case .desktop: "display"
case .terminal: "terminal"
case .docs: "book"
case .settings: "gearshape"
@@ -92,7 +95,7 @@ extension RootTabs {
case .chat, .overview, .activity, .agents, .workboard, .skillWorkshop, .instances, .sessions,
.files,
.dreaming,
.usage, .cron, .terminal, .settings, .docs:
.usage, .cron, .desktop, .terminal, .settings, .docs:
nil
}
}
@@ -238,6 +241,7 @@ extension RootTabs {
.instances,
.files,
.dreaming,
.desktop,
.terminal,
.docs,
]
@@ -0,0 +1,79 @@
import Foundation
import Testing
@testable import OpenClaw
@testable import OpenClawKit
@MainActor
struct DesktopHubScreenTests {
private static func makeConfig(
url: URL,
token: String? = nil,
password: String? = nil) -> GatewayConnectConfig
{
GatewayConnectConfig(
url: url,
stableID: "manual|gateway.example.com|443",
tls: nil,
token: token,
bootstrapToken: nil,
password: password,
nodeOptions: GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "ios",
clientMode: "node",
clientDisplayName: "Phone"))
}
@Test func `standalone desktop URL uses document mode without credentials`() throws {
let config = try Self.makeConfig(
url: #require(URL(string: "wss://gateway.example.com:8443/openclaw")),
token: "secret-token",
password: "secret-password")
let url = DesktopHubScreen.desktopURL(config: config, source: nil)
#expect(url?.absoluteString == "https://gateway.example.com:8443/openclaw/?view=desktop")
#expect(url?.absoluteString.contains("secret-token") == false)
#expect(url?.absoluteString.contains("secret-password") == false)
}
@Test func `session desktop URL includes the selected source`() throws {
let config = try Self.makeConfig(
url: #require(URL(string: "ws://192.168.1.10:18789")),
token: "secret-token")
let url = DesktopHubScreen.desktopURL(config: config, source: "node:worker-1")
#expect(url?.absoluteString == "http://192.168.1.10:18789/?view=desktop&source=node%3Aworker-1")
#expect(url?.absoluteString.contains("secret-token") == false)
}
@Test func `empty desktop source is omitted`() throws {
let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com")))
let url = DesktopHubScreen.desktopURL(config: config, source: " ")
#expect(url?.absoluteString == "https://gateway.example.com/?view=desktop")
}
@Test func `desktop auth script carries credentials outside the URL`() throws {
let config = try Self.makeConfig(
url: #require(URL(string: "wss://gateway.example.com")),
token: " secret-token ",
password: "secret-password")
let url = DesktopHubScreen.desktopURL(config: config, source: "gateway")
let script = DesktopHubScreen.desktopAuthUserScript(config: config, source: "gateway")
#expect(url?.absoluteString == "https://gateway.example.com/?view=desktop&source=gateway")
#expect(url?.absoluteString.contains("secret-token") == false)
#expect(url?.absoluteString.contains("secret-password") == false)
#expect(script?.contains("__OPENCLAW_NATIVE_CONTROL_AUTH__") == true)
#expect(script?.contains("\"token\":\"secret-token\"") == true)
#expect(script?.contains("\"password\":\"secret-password\"") == true)
}
}
@@ -177,6 +177,7 @@ struct RootTabsPresentationTests {
.instances,
.files,
.dreaming,
.desktop,
.terminal,
.docs,
])
@@ -193,6 +194,7 @@ struct RootTabsPresentationTests {
"dreaming",
"usage",
"cron",
"desktop",
"terminal",
"docs",
"settings",
@@ -161,7 +161,8 @@ struct RootTabsSourceGuardTests {
#expect(rootSource.contains("RootSidebar("))
#expect(source.contains("ForEach(self.pinnedPages)"))
#expect(source.contains("ForEach(RootTabs.pinnableSidebarPages)"))
#expect(source.contains("destinations: RootTabs.pinnableSidebarPages.filter(self.isDestinationAvailable)"))
#expect(source.contains("ForEach(self.destinations)"))
#expect(source.contains("private var brandHeader: some View"))
#expect(source.contains("private var agentsSection: some View"))
#expect(source.contains("static func shownAgentCount(configured: Int, total: Int) -> Int"))
@@ -770,7 +771,7 @@ extension RootTabsSourceGuardTests {
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
#expect(!rootSource.contains("openVoiceSettings:"))
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 2)
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 3)
#expect(!rootSource.contains("showGatewayActions"))
#expect(!rootSource.contains("gatewayActionsDialog"))
#expect(overviewSource.contains("Button(action: self.openSettings)"))
+42
View File
@@ -12,11 +12,14 @@ import { t } from "../i18n/index.ts";
import { isTerminalAvailable } from "../lib/terminal-availability.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
import { isDesktopPanelAvailable } from "./app-shell-chrome.ts";
import { bootstrapApplication, type ApplicationRuntime } from "./bootstrap.ts";
import { resolveControlUiBasePath } from "./browser.ts";
import { applicationContext, type ApplicationContext } from "./context.ts";
import { desktopDocumentOptions, isDesktopOnlyView } from "./desktop-document-mode.ts";
import {
APPROVAL_PAGE_ELEMENT,
DESKTOP_PANEL_ELEMENT,
isOptionalElementDefined,
preloadOptionalElement,
TERMINAL_PANEL_ELEMENT,
@@ -72,6 +75,11 @@ export class OpenClawApp extends OpenClawLightDomElement {
globalThis.location,
resolveControlUiBasePath(globalThis.location?.pathname ?? "/"),
);
private readonly desktopOnly = isDesktopOnlyView(
globalThis.location,
resolveControlUiBasePath(globalThis.location?.pathname ?? "/"),
);
private readonly desktopOptions = desktopDocumentOptions(globalThis.location);
private runtime: ApplicationRuntime | undefined;
private readonly contextProvider = new ContextProvider(this, {
context: applicationContext,
@@ -106,6 +114,9 @@ export class OpenClawApp extends OpenClawLightDomElement {
if (this.terminalOnly) {
preloadOptionalElement(this, TERMINAL_PANEL_ELEMENT);
}
if (this.desktopOnly) {
preloadOptionalElement(this, DESKTOP_PANEL_ELEMENT);
}
if (this.runtime.documentMode?.kind === "approval") {
preloadOptionalElement(this, APPROVAL_PAGE_ELEMENT);
}
@@ -219,6 +230,37 @@ export class OpenClawApp extends OpenClawLightDomElement {
: nothing}
`;
}
// Desktop documents share the panel's connection owner but none of its
// dock or shell chrome. Native clients can therefore load this route as a
// standalone, mobile-shaped surface without changing the observe contract.
if (this.desktopOnly) {
const desktopAvailable = isDesktopPanelAvailable(gatewaySnapshot);
return html`
<openclaw-desktop-panel
.client=${gatewayConnected ? gatewaySnapshot.client : null}
.available=${desktopAvailable}
.documentMode=${true}
.documentSource=${this.desktopOptions.source}
.documentControl=${this.desktopOptions.control}
.onDocumentClose=${() => {
if (globalThis.history.length > 1) {
globalThis.history.back();
} else {
globalThis.location.assign(context.basePath || "/");
}
}}
></openclaw-desktop-panel>
${!gatewayConnected && gatewaySnapshot.lastError === null
? renderConnectingSplash()
: nothing}
${!isOptionalElementDefined(DESKTOP_PANEL_ELEMENT) && desktopAvailable
? renderConnectingSplash()
: nothing}
${!desktopAvailable && (gatewayConnected || gatewaySnapshot.lastError)
? html`<div class="desktop-view-unavailable">${t("desktop.unavailable")}</div>`
: nothing}
`;
}
// In the normal Control UI document, the Gateway lifecycle owns unresolved
// first-connect state across every auth mode. Failures publish lastError
// before the gate returns; reconnects keep the shell mounted, and
+17 -7
View File
@@ -38,6 +38,7 @@ import type {
ApplicationThemeServerSelection,
} from "./context.ts";
import { syncCustomThemeStyleTag } from "./custom-theme.ts";
import { isDesktopDocumentPath, isDesktopOnlyView } from "./desktop-document-mode.ts";
import { createApplicationGateway } from "./gateway-store.ts";
import { createInitialUserMessageHandoff } from "./initial-user-message-handoff.ts";
import { createNativeChatDrafts } from "./native-bridge.ts";
@@ -55,7 +56,7 @@ import {
import { createSkillWorkshopRevisionHandoff } from "./skill-workshop-revision-handoff.ts";
import { createStartupLifecycle, type StartupStep } from "./startup-lifecycle.ts";
import { resolveApplicationStartupSettings } from "./startup-settings.ts";
import { isTerminalDocumentPath } from "./terminal-document-mode.ts";
import { isTerminalDocumentPath, isTerminalOnlyView } from "./terminal-document-mode.ts";
import { startThemeTransition } from "./theme-transition.ts";
import { resolveTheme, type ThemeMode } from "./theme.ts";
import { createWebPushCapability } from "./web-push.ts";
@@ -270,9 +271,18 @@ export function bootstrapApplication(
const basePath = resolveControlUiBasePath(
startup.location.pathname || globalThis.location?.pathname || "/",
);
const terminalDocument = isTerminalDocumentPath(startup.location.pathname, basePath);
const standaloneDocument =
isTerminalDocumentPath(startup.location.pathname, basePath) ||
isDesktopDocumentPath(startup.location.pathname, basePath);
const firstRunDefaultLanding =
documentMode === null && isDefaultChatLanding(startup.location, basePath, routeIdFromPath);
// A `?view=` document mode still lands on the chat path, so it counts as the default landing
// for routing, but it is an explicit destination that renders its own surface. Redirecting it
// into model setup strands native app webviews on a blank page, so only gate the redirect.
const firstRunRedirectEnabled =
firstRunDefaultLanding &&
!isTerminalOnlyView(startup.location, basePath) &&
!isDesktopOnlyView(startup.location, basePath);
const sessionPathBuilderReady =
dependencies.sessionPathBuilderReady ??
(documentMode
@@ -366,9 +376,9 @@ export function bootstrapApplication(
const chatAttachmentHandoff = createChatAttachmentHandoff();
applyThemePresentation(settings);
const router = createApplicationRouter();
// /terminal is served by the Gateway's SPA fallback but renders before the
// shell; starting the page router would rewrite this special document to /chat.
const startsApplicationRouter = documentMode === null && !terminalDocument;
// Standalone terminal and desktop paths render before the shell; starting
// the page router would rewrite these special documents to /chat.
const startsApplicationRouter = documentMode === null && !standaloneDocument;
let routerStarted = false;
// Pre-start navigations are invisible to history; retain the latest request so
// router.start() cannot resolve the stale browser URL over the user's route.
@@ -526,7 +536,7 @@ export function bootstrapApplication(
steps.push(() =>
startModelSetupFirstRunRedirectAfterLocation({
context,
enabled: firstRunDefaultLanding,
enabled: firstRunRedirectEnabled,
history,
initialLocationReady,
}),
@@ -554,7 +564,7 @@ export function bootstrapApplication(
startupLifecycle.trackDisposer(
startModelSetupFirstRunRedirectAfterLocation({
context,
enabled: firstRunDefaultLanding,
enabled: firstRunRedirectEnabled,
history,
initialLocationReady,
installLocation: async (location) => {
+38
View File
@@ -0,0 +1,38 @@
import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter";
const DESKTOP_DOCUMENT_PATH = "/desktop";
type DesktopDocumentLocation = Pick<Location, "pathname" | "search">;
type DesktopDocumentOptions = {
source: string | null;
control: boolean;
};
function desktopDocumentPath(basePath = ""): string {
return `${normalizeRouteBasePath(basePath)}${DESKTOP_DOCUMENT_PATH}`;
}
export function isDesktopDocumentPath(pathname: string, basePath: string): boolean {
return normalizeRoutePath(pathname) === desktopDocumentPath(basePath);
}
export function isDesktopOnlyView(
location: DesktopDocumentLocation | undefined = globalThis.location,
basePath = "",
): boolean {
return (
new URLSearchParams(location?.search ?? "").get("view") === "desktop" ||
isDesktopDocumentPath(location?.pathname ?? "/", basePath)
);
}
export function desktopDocumentOptions(
location: Pick<DesktopDocumentLocation, "search"> | undefined = globalThis.location,
): DesktopDocumentOptions {
const search = new URLSearchParams(location?.search ?? "");
return {
source: search.get("source"),
control: search.get("control") === "1",
};
}
@@ -66,6 +66,11 @@ describe("DesktopClient", () => {
const { Rfb, instances } = createFakeRfb();
const socket = new FakeSocket("ws://control.example.test/desktop/observe");
const client = new DesktopClient(Rfb, () => socket as unknown as WebSocket);
const target = document.createElement("div");
const canvas = document.createElement("canvas");
const onKeyDown = vi.fn();
canvas.addEventListener("keydown", onKeyDown);
target.append(canvas);
const handle = await client.connect({
gatewayUrl: "ws://control.example.test",
@@ -73,16 +78,28 @@ describe("DesktopClient", () => {
credentials: { username: "operator", password: "secret" },
background: "rgb(8, 8, 8)",
viewOnly: false,
target: document.createElement("div"),
scaleViewport: false,
target,
});
expect(instances[0]?.background).toBe("rgb(8, 8, 8)");
expect(instances[0]?.viewOnly).toBe(false);
expect(instances[0]?.scaleViewport).toBe(true);
expect(instances[0]?.scaleViewport).toBe(false);
expect(instances[0]?.options).toEqual({
credentials: { username: "operator", password: "secret" },
});
handle.setScaleViewport?.(true);
expect(instances[0]?.scaleViewport).toBe(true);
handle.sendKeyboardEvent?.(new KeyboardEvent("keydown", { key: "k", code: "KeyK" }));
expect(onKeyDown).toHaveBeenCalledOnce();
expect((onKeyDown.mock.calls[0]?.[0] as KeyboardEvent | undefined)?.key).toBe("k");
handle.sendText?.("m");
handle.sendBackspace?.();
expect(onKeyDown.mock.calls.map((call) => (call[0] as KeyboardEvent | undefined)?.key)).toEqual(
["k", "m", "Backspace"],
);
handle.disconnect();
expect(instances[0]?.disconnect).toHaveBeenCalledOnce();
});
+57 -1
View File
@@ -15,6 +15,7 @@ type DesktopConnectOptions = {
onConnect?: () => void;
onDisconnect?: (detail: DesktopDisconnectDetail) => void;
onSecurityFailure?: (detail: DesktopSecurityFailureDetail) => void;
scaleViewport?: boolean;
target: HTMLElement;
viewOnly: boolean;
wsUrl: string;
@@ -22,6 +23,10 @@ type DesktopConnectOptions = {
export type DesktopConnectionHandle = {
disconnect(): void;
sendBackspace?(): void;
sendKeyboardEvent?(event: KeyboardEvent): void;
sendText?(text: string): void;
setScaleViewport?(enabled: boolean): void;
};
type RfbClient = EventTarget & {
@@ -89,15 +94,66 @@ export class DesktopClient {
);
rfb.background = options.background ?? getComputedStyle(options.target).backgroundColor;
rfb.viewOnly = options.viewOnly;
rfb.scaleViewport = true;
rfb.scaleViewport = options.scaleViewport ?? true;
rfb.addEventListener("connect", () => options.onConnect?.());
rfb.addEventListener("disconnect", () => options.onDisconnect?.(closeDetail));
rfb.addEventListener("securityfailure", (event) => {
const detail = (event as CustomEvent<DesktopSecurityFailureDetail>).detail ?? {};
options.onSecurityFailure?.(detail);
});
const dispatchKeyboardEvent = (event: KeyboardEvent) => {
// noVNC owns keyboard translation and attaches its listeners to the
// canvas. Forward the offscreen mobile input's event to that same
// boundary so virtual-keyboard input follows the canonical RFB path.
options.target.querySelector("canvas")?.dispatchEvent(event);
};
const cloneKeyboardEvent = (event: KeyboardEvent) =>
new KeyboardEvent(event.type, {
key: event.key,
code: event.code,
location: event.location,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
repeat: event.repeat,
isComposing: event.isComposing,
bubbles: true,
cancelable: true,
});
return {
disconnect: () => rfb.disconnect(),
setScaleViewport: (enabled) => {
rfb.scaleViewport = enabled;
},
sendKeyboardEvent: (event) => dispatchKeyboardEvent(cloneKeyboardEvent(event)),
sendText: (text) => {
// Mobile IMEs can omit keydown/keyup. "Unidentified" asks noVNC's
// keyboard owner to translate each inserted character and emit a
// balanced press/release, matching its built-in mobile UI fallback.
for (let index = 0; index < text.length; index += 1) {
dispatchKeyboardEvent(
new KeyboardEvent("keydown", {
key: text.charAt(index),
code: "Unidentified",
bubbles: true,
cancelable: true,
}),
);
}
},
sendBackspace: () => {
for (const type of ["keydown", "keyup"]) {
dispatchKeyboardEvent(
new KeyboardEvent(type, {
key: "Backspace",
code: "Backspace",
bubbles: true,
cancelable: true,
}),
);
}
},
};
}
}
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { desktopDocumentStyles } from "./desktop-document-styles.ts";
describe("desktop document styles", () => {
it("uses fixed inset sizing without viewport height units", () => {
expect(desktopDocumentStyles.cssText).toContain("position: fixed");
expect(desktopDocumentStyles.cssText).toContain("inset: 0");
expect(desktopDocumentStyles.cssText).not.toMatch(/\d(?:dvh|svh|lvh|vh)\b/);
});
});
@@ -0,0 +1,94 @@
import { css } from "lit";
export const desktopDocumentStyles = css`
/* The inset sizes this to the viewport on its own. Do not reintroduce viewport
height units: Android WebView hosts the Control UI in a container that
resolves dvh/vh/svh/lvh to 0, which collapses the viewer to a blank page. */
.desktop-document {
position: fixed;
inset: 0;
display: flex;
overflow: hidden;
box-sizing: border-box;
background: var(--bg);
}
.desktop-document .desktop-content {
width: 100%;
}
.desktop-document .desktop-stage {
width: 100%;
}
.desktop-touch-toolbar {
position: absolute;
z-index: 3;
right: 12px;
bottom: max(12px, env(safe-area-inset-bottom));
left: 12px;
display: flex;
width: max-content;
max-width: calc(100% - 24px);
align-items: center;
justify-content: center;
gap: 4px;
margin: 0 auto;
padding: 5px;
border: 1px solid color-mix(in srgb, var(--text) 16%, transparent);
border-radius: 14px;
background: color-mix(in srgb, var(--bg) 84%, transparent);
box-shadow: 0 8px 28px rgb(0 0 0 / 35%);
backdrop-filter: blur(16px);
}
.desktop-touch-action {
display: inline-flex;
min-width: 48px;
height: 44px;
align-items: center;
justify-content: center;
gap: 5px;
border: 0;
border-radius: 10px;
padding: 0 9px;
background: transparent;
color: var(--text);
font: inherit;
font-size: 11px;
}
.desktop-touch-action[aria-pressed="true"] {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 16%, transparent);
}
.desktop-touch-action:focus-visible {
outline: 2px solid var(--focus, var(--accent));
outline-offset: 1px;
}
.desktop-touch-action__icon {
display: inline-flex;
width: 18px;
height: 18px;
}
.desktop-touch-action__icon svg {
width: 100%;
height: 100%;
stroke-width: 1.8;
}
.desktop-keyboard-input {
position: fixed;
bottom: 0;
left: 50%;
width: 1px;
height: 1px;
border: 0;
padding: 0;
opacity: 0;
pointer-events: none;
}
@media (max-width: 430px) {
.desktop-touch-action {
min-width: 44px;
padding: 0 7px;
}
.desktop-touch-action__label {
display: none;
}
}
`;
@@ -0,0 +1,123 @@
import { html, nothing, svg } from "lit";
import { t } from "../../i18n/index.ts";
import { strokeIcon } from "../icons-tools.ts";
import { icons } from "../icons.ts";
import type { DesktopPanelState } from "./desktop-panel-state.ts";
const KEYBOARD_GLYPH = strokeIcon(svg`
<rect width="20" height="14" x="2" y="5" rx="2" />
<path d="M6 9h.01" />
<path d="M10 9h.01" />
<path d="M14 9h.01" />
<path d="M18 9h.01" />
<path d="M6 13h.01" />
<path d="M10 13h.01" />
<path d="M14 13h.01" />
<path d="M18 13h.01" />
<path d="M8 17h8" />
`);
type DesktopDocumentViewOptions = {
state: DesktopPanelState;
controlling: boolean;
scaleViewport: boolean;
notice: unknown;
picker: unknown;
credentials: unknown;
recovery: unknown;
keyboardInputValue: string;
onControlToggle: () => void;
onKeyboardFocus: () => void;
onKeyboardEvent: (event: KeyboardEvent) => void;
onKeyboardInput: (event: InputEvent) => void;
onScaleToggle: () => void;
onClose: () => void;
};
export function renderDesktopDocumentView(options: DesktopDocumentViewOptions) {
const connection = html`
<div class="desktop-stage">
<div class="desktop-surface"></div>
${options.state === "connecting"
? html`<div class="desktop-connecting" role="status" aria-live="polite">
<span class="desktop-connecting__monitor" aria-hidden="true">${icons.monitor}</span>
<span>${t("desktop.connecting")}</span>
</div>`
: nothing}
<textarea
class="desktop-keyboard-input"
inputmode="text"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
tabindex="-1"
aria-label=${t("desktop.keyboardInput")}
.value=${options.keyboardInputValue}
@keydown=${options.onKeyboardEvent}
@keyup=${options.onKeyboardEvent}
@input=${options.onKeyboardInput}
></textarea>
<nav class="desktop-touch-toolbar" aria-label=${t("desktop.touchControls")}>
<button
class="desktop-touch-action"
type="button"
aria-label=${t(options.controlling ? "desktop.switchToViewOnly" : "desktop.takeControl")}
aria-pressed=${options.controlling ? "true" : "false"}
@click=${options.onControlToggle}
>
<span class="desktop-touch-action__icon" aria-hidden="true">
${options.controlling ? icons.hand : icons.eye}
</span>
<span class="desktop-touch-action__label">
${t(options.controlling ? "desktop.control" : "desktop.viewOnly")}
</span>
</button>
<button
class="desktop-touch-action"
type="button"
aria-label=${t("desktop.keyboard")}
@click=${options.onKeyboardFocus}
>
<span class="desktop-touch-action__icon" aria-hidden="true">${KEYBOARD_GLYPH}</span>
<span class="desktop-touch-action__label">${t("desktop.keyboard")}</span>
</button>
<button
class="desktop-touch-action"
type="button"
aria-label=${t(options.scaleViewport ? "desktop.actualSize" : "desktop.fitScreen")}
aria-pressed=${options.scaleViewport ? "true" : "false"}
@click=${options.onScaleToggle}
>
<span class="desktop-touch-action__icon" aria-hidden="true">
${options.scaleViewport ? icons.minimize : icons.maximize}
</span>
<span class="desktop-touch-action__label">${t("desktop.fit")}</span>
</button>
<button
class="desktop-touch-action"
type="button"
aria-label=${t("desktop.back")}
@click=${options.onClose}
>
<span class="desktop-touch-action__icon" aria-hidden="true">${icons.arrowLeft}</span>
<span class="desktop-touch-action__label">${t("desktop.back")}</span>
</button>
</nav>
</div>
`;
return html`
<section class="desktop-document" aria-label=${t("desktop.title")}>
<div class="desktop-content">
${options.notice}
${options.state === "picker"
? options.picker
: options.state === "inventory-error" || options.state === "disconnected"
? options.recovery
: options.state === "credentials"
? options.credentials
: connection}
</div>
</section>
`;
}
@@ -0,0 +1,244 @@
import type { EnvironmentSummary, WorkerDesktopAppId } from "@openclaw/gateway-protocol";
import { html, nothing, svg } from "lit";
import { t } from "../../i18n/index.ts";
import { icons } from "../icons.ts";
import { desktopAppIcon, desktopAppLabel } from "./desktop-app-presentation.ts";
import type { DesktopPanelState } from "./desktop-panel-state.ts";
import { desktopSourceForEnvironment } from "./desktop-source.ts";
const CLOSE_GLYPH = svg`<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>`;
const DOCK_BOTTOM_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M2 10h12" /></svg>`;
const DOCK_RIGHT_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M10 2.5v11" /></svg>`;
export function renderDesktopPanelHeader(options: {
dock: "bottom" | "right";
onClose: () => void;
onDock: (dock: "bottom" | "right") => void;
}) {
return html`
<header class="bp-header">
<div class="bp-title">${t("desktop.title")}</div>
<div class="bp-actions">
<button
class="bp-icon ${options.dock === "bottom" ? "is-active" : ""}"
type="button"
title=${t("desktop.dockBottom")}
aria-label=${t("desktop.dockBottom")}
@click=${() => options.onDock("bottom")}
>
${DOCK_BOTTOM_GLYPH}
</button>
<button
class="bp-icon ${options.dock === "right" ? "is-active" : ""}"
type="button"
title=${t("desktop.dockRight")}
aria-label=${t("desktop.dockRight")}
@click=${() => options.onDock("right")}
>
${DOCK_RIGHT_GLYPH}
</button>
<button
class="bp-icon"
type="button"
title=${t("desktop.hide")}
aria-label=${t("desktop.hide")}
@click=${options.onClose}
>
${CLOSE_GLYPH}
</button>
</div>
</header>
`;
}
export function renderDesktopPicker(options: {
environments: EnvironmentSummary[];
loading: boolean;
onConnect: (environmentId: string) => void;
onRefresh: () => void;
}) {
return html`
<div class="desktop-toolbar">
<span>${t("desktop.pickerTitle")}</span>
<span class="desktop-toolbar__spacer"></span>
<button
class="desktop-button"
type="button"
?disabled=${options.loading}
@click=${options.onRefresh}
>
${options.loading ? t("desktop.refreshing") : t("desktop.refresh")}
</button>
</div>
<div class="desktop-picker">
${options.loading && options.environments.length === 0
? html`<div class="desktop-status">${t("desktop.loading")}</div>`
: options.environments.length === 0
? html`<div class="desktop-status">${t("desktop.empty")}</div>`
: options.environments.map((environment) =>
renderDesktopEnvironment(environment, options.onConnect),
)}
</div>
`;
}
function renderDesktopEnvironment(
environment: EnvironmentSummary,
onConnect: (environmentId: string) => void,
) {
const worker = environment.worker;
const source = desktopSourceForEnvironment(environment);
return html`
<div class="desktop-environment">
<div class="desktop-environment__details">
<div class="desktop-environment__id">
${source.kind === "host" ? t("desktop.thisMachine") : environment.id}
</div>
<div class="desktop-environment__meta">
<span>${worker?.state ?? environment.status}</span>
</div>
${worker && worker.attachedSessionIds.length > 0
? html`<div class="desktop-environment__sessions">
${worker.attachedSessionIds.map(
(sessionId) => html`<span class="desktop-session">${sessionId}</span>`,
)}
</div>`
: nothing}
</div>
<button
class="desktop-button desktop-button--primary"
type="button"
@click=${() => onConnect(environment.id)}
>
${t("desktop.connect")}
</button>
</div>
`;
}
export function renderDesktopCredentials(options: {
ardAccount: boolean;
username: string;
onSubmit: (event: SubmitEvent) => void;
}) {
return html`
<div class="desktop-status">
<form class="desktop-credentials" @submit=${options.onSubmit}>
<div>${t(options.ardAccount ? "desktop.accountPrompt" : "desktop.passwordPrompt")}</div>
${options.ardAccount
? html`<label class="desktop-credentials__label">
${t("desktop.usernameLabel")}
<input
class="desktop-credentials__input"
name="username"
type="text"
autocomplete="off"
.value=${options.username}
required
/>
</label>`
: nothing}
<label class="desktop-credentials__label">
${t(options.ardAccount ? "desktop.accountPasswordLabel" : "desktop.passwordLabel")}
<input
class="desktop-credentials__input"
name="password"
type="password"
autocomplete="off"
required
/>
</label>
<button class="desktop-button desktop-button--primary" type="submit">
${t("desktop.connect")}
</button>
</form>
</div>
`;
}
export function renderDesktopConnection(options: {
state: DesktopPanelState;
controlling: boolean;
desktopApps: WorkerDesktopAppId[];
environmentSelected: boolean;
launchingApp: WorkerDesktopAppId | null;
showApps: boolean;
onDisconnect: () => void;
onLaunch: (app: WorkerDesktopAppId) => void;
onTakeControl: () => void;
}) {
return html`
<div class="desktop-toolbar desktop-toolbar--connection">
${options.showApps && options.desktopApps.length > 0
? html`<div class="desktop-apps">
${options.desktopApps.map((app) => {
const launching = options.launchingApp === app;
const label = desktopAppLabel(app);
return html`<button
class="desktop-app-button"
type="button"
title=${label}
aria-label=${label}
aria-busy=${launching ? "true" : "false"}
?disabled=${!options.environmentSelected || launching}
@click=${() => options.onLaunch(app)}
>
<span
class="desktop-app-button__icon ${launching
? "desktop-app-button__icon--launching"
: ""}"
aria-hidden="true"
>
${desktopAppIcon(app)}
</span>
<span>${label}</span>
</button>`;
})}
</div>`
: nothing}
<span class="desktop-toolbar__spacer"></span>
<button
class="desktop-toolbar-action"
type="button"
title=${t("desktop.disconnect")}
aria-label=${t("desktop.disconnect")}
@click=${options.onDisconnect}
>
${t("desktop.disconnect")}
</button>
</div>
<div class="desktop-stage">
<div class="desktop-surface"></div>
${!options.controlling
? html`<button
class="desktop-stage__take-control"
type="button"
title=${t("desktop.takeControl")}
aria-label=${t("desktop.takeControl")}
@click=${options.onTakeControl}
></button>`
: nothing}
${options.state === "connecting"
? html`<div class="desktop-connecting" role="status" aria-live="polite">
<span class="desktop-connecting__monitor" aria-hidden="true">${icons.monitor}</span>
<span class="desktop-connecting__copy">
${t("desktop.connecting")}
<span class="desktop-connecting__dots" aria-hidden="true">
<span class="desktop-connecting__dot"></span>
<span class="desktop-connecting__dot"></span>
<span class="desktop-connecting__dot"></span>
</span>
</span>
</div>`
: nothing}
</div>
`;
}
export function renderDesktopNotice(errorText: string | null, noticeText: string | null) {
return errorText
? html`<div class="desktop-note desktop-note--error" role="alert">${errorText}</div>`
: noticeText
? html`<div class="desktop-note" role="status">${noticeText}</div>`
: nothing;
}
+210 -227
View File
@@ -6,7 +6,7 @@ import type {
WorkerDesktopAppId,
WorkerDesktopLaunchResult,
} from "@openclaw/gateway-protocol";
import { html, nothing, svg } from "lit";
import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { t } from "../../i18n/index.ts";
@@ -14,23 +14,26 @@ import { formatUiError } from "../../lib/format-error.ts";
import { OpenClawLitElement } from "../../lit/openclaw-element.ts";
import { DockLayoutController, dockPanelStyles } from "../dock-layout-controller.ts";
import { createDockPanelLayout } from "../dock-panel-layout.ts";
import { icons } from "../icons.ts";
import {
DESKTOP_PANEL_TOGGLE_EVENT,
type DesktopPanelToggleDetail,
} from "../panel-toggle-contract.ts";
import { desktopAppIcon, desktopAppLabel } from "./desktop-app-presentation.ts";
import { DesktopClient, type DesktopConnectionHandle } from "./desktop-client.ts";
import { desktopDocumentStyles } from "./desktop-document-styles.ts";
import { renderDesktopDocumentView } from "./desktop-document-view.ts";
import { desktopCredentialRequirement } from "./desktop-panel-credentials.ts";
import { desktopPanelLauncherStyles } from "./desktop-panel-launcher-styles.ts";
import { type DesktopPanelState, renderDesktopPanelRecovery } from "./desktop-panel-state.ts";
import { desktopPanelStyles } from "./desktop-panel-styles.ts";
import {
renderDesktopConnection,
renderDesktopCredentials,
renderDesktopNotice,
renderDesktopPanelHeader,
renderDesktopPicker,
} from "./desktop-panel-view.ts";
import { desktopSourceForEnvironment } from "./desktop-source.ts";
const CLOSE_GLYPH = svg`<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>`;
const DOCK_BOTTOM_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M2 10h12" /></svg>`;
const DOCK_RIGHT_GLYPH = svg`<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M10 2.5v11" /></svg>`;
const panelLayout = createDockPanelLayout({
storageKey: "openclaw.desktopPanel",
minHeight: 240,
@@ -49,12 +52,17 @@ type PendingDesktopConnection = {
operationId: number;
};
type ObservedDesktopConnection = PendingDesktopConnection & { observed: DesktopObserveResult };
const MOBILE_KEYBOARD_SENTINEL = "________________";
/** `<openclaw-desktop-panel>` — dockable RFB access to Gateway desktop sources. */
class OpenClawDesktopPanel extends OpenClawLitElement {
@property({ attribute: false }) client: GatewayBrowserClient | null = null;
@property({ type: Boolean }) available = false;
@property({ type: Boolean }) suppressed = false;
@property({ type: Boolean }) documentMode = false;
@property({ attribute: false }) documentSource: string | null = null;
@property({ type: Boolean }) documentControl = false;
@property({ attribute: false }) onDocumentClose: (() => void) | null = null;
/** Browser tests replace the transport without opening a real RFB socket. */
desktopClientFactory: () => Pick<DesktopClient, "connect"> = () => new DesktopClient();
@@ -71,6 +79,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
@state() private launchingApp: DesktopAppId | null = null;
@state() private launchErrorText: string | null = null;
@state() private desktopApps: DesktopAppId[] = [];
@state() private scaleViewport = true;
private connection: DesktopConnectionHandle | null = null;
private credentials: DesktopCredentials | undefined;
@@ -79,6 +88,8 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
private operationId = 0;
private launchOperationId = 0;
private controlTakeoverRecoveryUsed = false;
private documentSourceResolved = false;
private keyboardInputValue = MOBILE_KEYBOARD_SENTINEL;
private readonly dockLayout = new DockLayoutController(this, {
layout: panelLayout,
reservationPrefix: "desktop",
@@ -86,13 +97,20 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
});
private readonly onToggleRequest = (event: Event) => this.handleToggleRequest(event);
static override styles = [dockPanelStyles, desktopPanelLauncherStyles, desktopPanelStyles];
static override styles = [
dockPanelStyles,
desktopPanelLauncherStyles,
desktopPanelStyles,
desktopDocumentStyles,
];
override connectedCallback(): void {
super.connectedCallback();
window.addEventListener(DESKTOP_PANEL_TOGGLE_EVENT, this.onToggleRequest);
this.dockLayout.setSuppressed(this.suppressed);
if (this.dockLayout.open) {
if (this.documentMode && this.available) {
void this.refreshEnvironments();
} else if (this.dockLayout.open) {
void this.refreshEnvironments();
}
}
@@ -113,7 +131,22 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
void this.refreshEnvironments();
}
}
if (changed.has("client") || changed.has("available")) {
if (changed.has("documentSource")) {
this.documentSourceResolved = false;
}
const gatewayAvailabilityChanged = changed.has("client") || changed.has("available");
const documentPresentationChanged =
changed.has("documentMode") ||
changed.has("documentSource") ||
changed.has("documentControl");
if (this.documentMode && (gatewayAvailabilityChanged || documentPresentationChanged)) {
if (!this.available) {
this.documentSourceResolved = false;
this.returnToPicker();
} else {
void this.refreshEnvironments();
}
} else if (gatewayAvailabilityChanged) {
if (!this.available && this.dockLayout.open) {
this.dockLayout.hideWithoutPersisting();
this.returnToPicker();
@@ -125,6 +158,9 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
}
handleToggleRequest(event: Event): void {
if (this.documentMode) {
return;
}
const detail =
event instanceof CustomEvent && typeof event.detail === "object" && event.detail !== null
? (event.detail as DesktopPanelToggleDetail)
@@ -174,6 +210,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
const connection = this.connection;
this.connection = null;
connection?.disconnect();
this.resetDocumentKeyboardInput();
}
private clearLaunchState(): void {
@@ -190,23 +227,54 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
const operationId = expectedOperationId ?? ++this.operationId;
this.loading = true;
this.errorText = null;
let refreshed = false;
try {
const result = await client.request<EnvironmentsListResult>("environments.list", {});
if (operationId !== this.operationId) {
return false;
}
this.environments = result.environments.filter((environment) => environment.desktop === true);
return true;
refreshed = true;
} catch (error) {
if (operationId === this.operationId) {
this.errorText = t("desktop.errors.listFailed", { error: formatUiError(error) });
if (this.documentMode && this.documentSource !== null) {
this.environmentId = this.documentSource;
this.state = "inventory-error";
}
}
return false;
} finally {
if (operationId === this.operationId) {
this.loading = false;
}
}
if (refreshed) {
await this.resolveDocumentSource(operationId);
}
return refreshed;
}
private async resolveDocumentSource(operationId: number): Promise<void> {
if (!this.documentMode || this.documentSourceResolved || operationId !== this.operationId) {
return;
}
this.documentSourceResolved = true;
const requestedSource = this.documentSource;
if (requestedSource === null) {
return;
}
if (!this.environments.some((environment) => environment.id === requestedSource)) {
this.state = "picker";
this.noticeText = t("desktop.sourceUnavailable");
return;
}
await this.connectEnvironment(requestedSource, this.documentControl);
}
private retryDocumentInventory(): void {
this.documentSourceResolved = false;
this.state = "connecting";
void this.refreshEnvironments();
}
private async connectRequestedEnvironment(environmentId: string): Promise<void> {
@@ -275,6 +343,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
if (operationId !== this.operationId) {
return;
}
this.controlling = observed.control;
const credentials = observed.preauthenticated
? undefined
: observed.vncPassword
@@ -334,6 +403,7 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
gatewayUrl: client.gatewayUrl,
credentials,
viewOnly: !pending.observed.control,
scaleViewport: this.scaleViewport,
target,
onConnect: () => {
if (pending.operationId === this.operationId) {
@@ -472,243 +542,156 @@ class OpenClawDesktopPanel extends OpenClawLitElement {
}
}
private renderHeader() {
const dock = this.dockLayout.dock;
return html`
<header class="bp-header">
<div class="bp-title">${t("desktop.title")}</div>
<div class="bp-actions">
<button
class="bp-icon ${dock === "bottom" ? "is-active" : ""}"
type="button"
title=${t("desktop.dockBottom")}
aria-label=${t("desktop.dockBottom")}
@click=${() => this.dockLayout.setDock("bottom")}
>
${DOCK_BOTTOM_GLYPH}
</button>
<button
class="bp-icon ${dock === "right" ? "is-active" : ""}"
type="button"
title=${t("desktop.dockRight")}
aria-label=${t("desktop.dockRight")}
@click=${() => this.dockLayout.setDock("right")}
>
${DOCK_RIGHT_GLYPH}
</button>
<button
class="bp-icon"
type="button"
title=${t("desktop.hide")}
aria-label=${t("desktop.hide")}
@click=${() => this.closePanel()}
>
${CLOSE_GLYPH}
</button>
</div>
</header>
`;
private handleDocumentKeyboardEvent(event: KeyboardEvent): void {
if (!this.controlling || !this.connection?.sendKeyboardEvent) {
return;
}
this.connection.sendKeyboardEvent(event);
event.preventDefault();
}
private renderPicker() {
return html`
<div class="desktop-toolbar">
<span>${t("desktop.pickerTitle")}</span>
<span class="desktop-toolbar__spacer"></span>
<button
class="desktop-button"
type="button"
?disabled=${this.loading}
@click=${() => void this.refreshEnvironments()}
>
${this.loading ? t("desktop.refreshing") : t("desktop.refresh")}
</button>
</div>
<div class="desktop-picker">
${this.loading && this.environments.length === 0
? html`<div class="desktop-status">${t("desktop.loading")}</div>`
: this.environments.length === 0
? html`<div class="desktop-status">${t("desktop.empty")}</div>`
: this.environments.map((environment) => this.renderEnvironment(environment))}
</div>
`;
private handleDocumentKeyboardInput(event: InputEvent): void {
const input = event.currentTarget as HTMLTextAreaElement;
if (!this.controlling) {
this.resetDocumentKeyboardInput(input);
return;
}
const previousValue = this.keyboardInputValue;
const nextValue = input.value;
let prefixLength = 0;
const comparableLength = Math.min(previousValue.length, nextValue.length);
while (
prefixLength < comparableLength &&
previousValue.charAt(prefixLength) === nextValue.charAt(prefixLength)
) {
prefixLength += 1;
}
const removedCount = previousValue.length - prefixLength;
for (let index = 0; index < removedCount; index += 1) {
this.connection?.sendBackspace?.();
}
this.connection?.sendText?.(nextValue.slice(prefixLength));
if (nextValue.length < 1 || nextValue.length > MOBILE_KEYBOARD_SENTINEL.length * 2) {
this.resetDocumentKeyboardInput(input);
return;
}
this.keyboardInputValue = nextValue;
}
private renderEnvironment(environment: EnvironmentSummary) {
const worker = environment.worker;
const source = desktopSourceForEnvironment(environment);
return html`
<div class="desktop-environment">
<div class="desktop-environment__details">
<div class="desktop-environment__id">
${source.kind === "host" ? t("desktop.thisMachine") : environment.id}
</div>
<div class="desktop-environment__meta">
<span>${worker?.state ?? environment.status}</span>
</div>
${worker && worker.attachedSessionIds.length > 0
? html`<div class="desktop-environment__sessions">
${worker.attachedSessionIds.map(
(sessionId) => html`<span class="desktop-session">${sessionId}</span>`,
)}
</div>`
: nothing}
</div>
<button
class="desktop-button desktop-button--primary"
type="button"
@click=${() => void this.connectEnvironment(environment.id, false)}
>
${t("desktop.connect")}
</button>
</div>
`;
private resetDocumentKeyboardInput(input?: HTMLTextAreaElement): void {
this.keyboardInputValue = MOBILE_KEYBOARD_SENTINEL;
const target =
input ?? this.shadowRoot?.querySelector<HTMLTextAreaElement>(".desktop-keyboard-input");
if (target) {
target.value = MOBILE_KEYBOARD_SENTINEL;
}
}
private renderConnection() {
return html`
<div class="desktop-toolbar desktop-toolbar--connection">
${this.source?.kind === "environment" && this.desktopApps.length > 0
? html`<div class="desktop-apps">
${this.desktopApps.map((app) => {
const launching = this.launchingApp === app;
const label = desktopAppLabel(app);
return html`<button
class="desktop-app-button"
type="button"
title=${label}
aria-label=${label}
aria-busy=${launching ? "true" : "false"}
?disabled=${!this.environmentId || launching}
@click=${() => void this.launchApp(app)}
>
<span
class="desktop-app-button__icon ${launching
? "desktop-app-button__icon--launching"
: ""}"
aria-hidden="true"
>
${desktopAppIcon(app)}
</span>
<span>${label}</span>
</button>`;
})}
</div>`
: nothing}
<span class="desktop-toolbar__spacer"></span>
<button
class="desktop-toolbar-action"
type="button"
title=${t("desktop.disconnect")}
aria-label=${t("desktop.disconnect")}
@click=${() => this.returnToPicker()}
>
${t("desktop.disconnect")}
</button>
</div>
<div class="desktop-stage">
<div class="desktop-surface"></div>
${!this.controlling
? html`<button
class="desktop-stage__take-control"
type="button"
title=${t("desktop.takeControl")}
aria-label=${t("desktop.takeControl")}
@click=${() =>
this.environmentId && void this.connectEnvironment(this.environmentId, true)}
></button>`
: nothing}
${this.state === "connecting"
? html`<div class="desktop-connecting" role="status" aria-live="polite">
<span class="desktop-connecting__monitor" aria-hidden="true">${icons.monitor}</span>
<span class="desktop-connecting__copy">
${t("desktop.connecting")}
<span class="desktop-connecting__dots" aria-hidden="true">
<span class="desktop-connecting__dot"></span>
<span class="desktop-connecting__dot"></span>
<span class="desktop-connecting__dot"></span>
</span>
</span>
</div>`
: nothing}
</div>
`;
private focusDocumentKeyboard(): void {
const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>(".desktop-keyboard-input");
input?.focus({ preventScroll: true });
input?.setSelectionRange(input.value.length, input.value.length);
}
private renderCredentials() {
const ardAccount = this.credentialAuth === "ard-account";
return html`
<div class="desktop-status">
<form
class="desktop-credentials"
@submit=${(event: SubmitEvent) => this.handleCredentialsSubmit(event)}
>
<div>${t(ardAccount ? "desktop.accountPrompt" : "desktop.passwordPrompt")}</div>
${ardAccount
? html`<label class="desktop-credentials__label">
${t("desktop.usernameLabel")}
<input
class="desktop-credentials__input"
name="username"
type="text"
autocomplete="off"
.value=${this.credentials?.username ?? ""}
required
/>
</label>`
: nothing}
<label class="desktop-credentials__label">
${t(ardAccount ? "desktop.accountPasswordLabel" : "desktop.passwordLabel")}
<input
class="desktop-credentials__input"
name="password"
type="password"
autocomplete="off"
required
/>
</label>
<button class="desktop-button desktop-button--primary" type="submit">
${t("desktop.connect")}
</button>
</form>
</div>
`;
private toggleDocumentScale(): void {
this.scaleViewport = !this.scaleViewport;
this.connection?.setScaleViewport?.(this.scaleViewport);
}
override render() {
if (!this.available || !this.dockLayout.open) {
if (!this.available) {
return nothing;
}
const notice = renderDesktopNotice(this.launchErrorText ?? this.errorText, this.noticeText);
const picker = renderDesktopPicker({
environments: this.environments,
loading: this.loading,
onRefresh: () => void this.refreshEnvironments(),
onConnect: (environmentId) => void this.connectEnvironment(environmentId, false),
});
const credentials = renderDesktopCredentials({
ardAccount: this.credentialAuth === "ard-account",
username: this.credentials?.username ?? "",
onSubmit: (event) => this.handleCredentialsSubmit(event),
});
const recovery = renderDesktopPanelRecovery({
inventoryError: this.state === "inventory-error",
reason: this.disconnectedReason,
onRetry: () => {
if (!this.environmentId) {
return;
}
if (this.state === "inventory-error") {
if (this.documentMode) {
this.retryDocumentInventory();
} else {
void this.connectRequestedEnvironment(this.environmentId);
}
return;
}
void this.connectEnvironment(this.environmentId, this.controlling);
},
});
const connection = renderDesktopConnection({
state: this.state,
controlling: this.controlling,
desktopApps: this.desktopApps,
environmentSelected: this.environmentId !== null,
launchingApp: this.launchingApp,
showApps: this.source?.kind === "environment",
onLaunch: (app) => void this.launchApp(app),
onTakeControl: () => {
if (this.environmentId) {
void this.connectEnvironment(this.environmentId, true);
}
},
onDisconnect: () => this.returnToPicker(),
});
if (this.documentMode) {
return renderDesktopDocumentView({
state: this.state,
controlling: this.controlling,
scaleViewport: this.scaleViewport,
keyboardInputValue: this.keyboardInputValue,
notice,
picker,
credentials,
recovery,
onControlToggle: () => {
if (this.environmentId) {
void this.connectEnvironment(this.environmentId, !this.controlling);
}
},
onKeyboardFocus: () => this.focusDocumentKeyboard(),
onKeyboardEvent: (event) => this.handleDocumentKeyboardEvent(event),
onKeyboardInput: (event) => this.handleDocumentKeyboardInput(event),
onScaleToggle: () => this.toggleDocumentScale(),
onClose: () => this.onDocumentClose?.(),
});
}
if (!this.dockLayout.open) {
return nothing;
}
const dock = this.dockLayout.dock;
const style =
dock === "bottom" ? `height:${this.dockLayout.height}px` : `width:${this.dockLayout.width}px`;
const visibleErrorText = this.launchErrorText ?? this.errorText;
return html`
<section class="bp bp--${dock}" style=${style} aria-label=${t("desktop.title")}>
${this.dockLayout.renderResizer("bp", t("desktop.resize"))} ${this.renderHeader()}
${this.dockLayout.renderResizer("bp", t("desktop.resize"))}
${renderDesktopPanelHeader({
dock,
onDock: (nextDock) => this.dockLayout.setDock(nextDock),
onClose: () => this.closePanel(),
})}
<div class="desktop-content">
${visibleErrorText
? html`<div class="desktop-note desktop-note--error" role="alert">
${visibleErrorText}
</div>`
: this.noticeText
? html`<div class="desktop-note" role="status">${this.noticeText}</div>`
: nothing}
${notice}
${this.state === "picker"
? this.renderPicker()
? picker
: this.state === "inventory-error" || this.state === "disconnected"
? renderDesktopPanelRecovery({
inventoryError: this.state === "inventory-error",
reason: this.disconnectedReason,
onRetry: () =>
this.environmentId &&
void (this.state === "inventory-error"
? this.connectRequestedEnvironment(this.environmentId)
: this.connectEnvironment(this.environmentId, this.controlling)),
})
? recovery
: this.state === "credentials"
? this.renderCredentials()
: this.renderConnection()}
? credentials
: connection}
</div>
</section>
`;
@@ -0,0 +1,279 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({
name: "desktop document mode",
startServerBeforeBrowser: true,
unavailableMessage: (executablePath) =>
`Playwright Chromium is not installed or cannot start at ${executablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`.`,
});
const artifactDirectory = path.resolve(".artifacts/mobile-desktop");
const gatewayEnvironment = {
id: "gateway",
type: "local",
status: "available",
desktop: true,
};
type FakeDesktopConnectOptions = {
onConnect?: () => void;
scaleViewport?: boolean;
target: HTMLElement;
viewOnly: boolean;
};
async function installDesktopClientFake(panel: import("playwright").Locator) {
await panel.evaluate((element) => {
(
element as HTMLElement & {
desktopClientFactory: () => {
connect(options: FakeDesktopConnectOptions): Promise<{
disconnect(): void;
sendBackspace(): void;
sendKeyboardEvent(event: KeyboardEvent): void;
sendText(text: string): void;
setScaleViewport(enabled: boolean): void;
}>;
};
}
).desktopClientFactory = () => ({
async connect(options) {
element.dataset.viewOnly = String(options.viewOnly);
element.dataset.scaleViewport = String(options.scaleViewport ?? true);
const remote = document.createElement("div");
remote.dataset.testRemoteDesktop = "true";
remote.textContent = "Remote desktop";
remote.style.cssText =
"display:grid;place-items:center;width:100%;height:100%;color:#e8edf5;background:linear-gradient(145deg,#26364d,#111823);font:600 18px system-ui";
options.target.replaceChildren(remote);
options.onConnect?.();
return {
disconnect() {
remote.remove();
},
sendKeyboardEvent(event) {
element.dataset.lastKeyboardEvent = `${event.type}:${event.key}`;
},
sendText(text) {
element.dataset.lastKeyboardText = text;
},
sendBackspace() {
element.dataset.lastKeyboardText = "Backspace";
},
setScaleViewport(enabled) {
element.dataset.scaleViewport = String(enabled);
},
};
},
});
});
}
async function startDesktopDocument(
page: import("playwright").Page,
route: string,
desktopObserve: unknown = {
transport: "rfb",
wsPath: "/desktop/observe?token=document",
expiresAtMs: 60_000,
control: false,
},
) {
await page.setViewportSize({ width: 390, height: 844 });
const gateway = await installMockGateway(page, {
deferredMethods: ["environments.list"],
featureMethods: ["desktop.observe", "environments.list", "openclaw.setup.detect"],
methodResponses: {
"desktop.observe": desktopObserve,
"openclaw.setup.detect": {
candidates: [],
manualProviders: [],
workspace: "/tmp/openclaw-desktop-document",
setupComplete: false,
},
},
});
await page.goto(`${suite.server.baseUrl}${route}`);
const panel = page.locator("openclaw-desktop-panel");
await panel.waitFor({ state: "attached" });
await gateway.waitForRequest("environments.list");
await installDesktopClientFake(panel);
return { gateway, panel };
}
async function openDesktopDocument(
page: import("playwright").Page,
route: string,
environments: unknown[],
desktopObserve?: unknown,
) {
const document = await startDesktopDocument(page, route, desktopObserve);
await document.gateway.resolveDeferred("environments.list", { environments });
return document;
}
suite.define(() => {
it.each([
["the query route", "?view=desktop"],
["the path route", "desktop"],
])("renders a full-bleed shell-free picker from %s", async (_label, route) => {
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
const { panel } = await openDesktopDocument(page, route, [gatewayEnvironment]);
const viewer = panel.locator("section.desktop-document");
await viewer.waitFor();
await panel.getByText("Desktop sources", { exact: true }).waitFor();
expect(await page.locator("openclaw-app-shell").count()).toBe(0);
expect(page.url()).not.toContain("model-setup");
const bounds = await viewer.boundingBox();
expect(bounds?.width).toBeGreaterThanOrEqual(389);
expect(bounds?.height).toBeGreaterThanOrEqual(843);
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
if (route === "?view=desktop") {
await mkdir(artifactDirectory, { recursive: true });
await page.screenshot({
path: path.join(artifactDirectory, "picker-390x844.png"),
fullPage: false,
});
}
});
});
it("falls back to the picker with a notice for an unobservable source", async () => {
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
const { gateway, panel } = await openDesktopDocument(
page,
"?view=desktop&source=missing-machine",
[gatewayEnvironment],
);
await panel
.getByText("The requested desktop source is unavailable. Choose another source.", {
exact: true,
})
.waitFor();
await panel.getByText("Desktop sources", { exact: true }).waitFor();
expect(await gateway.getRequests("desktop.observe")).toHaveLength(0);
});
});
it("renders inventory failure recovery and retries the preselected source", async () => {
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
const { gateway, panel } = await startDesktopDocument(page, "?view=desktop&source=gateway");
await gateway.rejectDeferred("environments.list", {
code: "UNAVAILABLE",
message: "desktop inventory is temporarily unavailable",
});
await panel.getByRole("alert").filter({ hasText: "inventory" }).waitFor();
const retry = panel.getByRole("button", { name: "Retry", exact: true });
await retry.waitFor();
expect(await gateway.getRequests("desktop.observe")).toHaveLength(0);
await gateway.setMethodResponse("environments.list", {
environments: [gatewayEnvironment],
});
await retry.click();
const observeRequest = await gateway.waitForRequest("desktop.observe");
expect(observeRequest.params).toEqual({ source: { kind: "host" }, control: false });
await panel.locator("[data-test-remote-desktop='true']").waitFor();
});
});
it("auto-connects view-only and provides four working touch actions", async () => {
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
const { gateway, panel } = await openDesktopDocument(
page,
"?view=desktop&source=gateway",
[gatewayEnvironment],
{
sequence: [
{
transport: "rfb",
wsPath: "/desktop/observe?token=view",
expiresAtMs: 60_000,
control: false,
},
{
transport: "rfb",
wsPath: "/desktop/observe?token=control",
expiresAtMs: 60_000,
control: true,
},
],
},
);
const viewRequest = await gateway.waitForRequest("desktop.observe");
expect(viewRequest.params).toEqual({ source: { kind: "host" }, control: false });
await expect.poll(() => panel.getAttribute("data-view-only")).toBe("true");
const touchActions = panel.locator(".desktop-touch-action");
await expect.poll(() => touchActions.count()).toBe(4);
await panel.getByRole("button", { name: "Back", exact: true }).waitFor();
await panel.getByRole("button", { name: "Take control", exact: true }).click();
await expect.poll(async () => (await gateway.getRequests("desktop.observe")).length).toBe(2);
expect((await gateway.getRequests("desktop.observe"))[1]?.params).toEqual({
source: { kind: "host" },
control: true,
});
await expect.poll(() => panel.getAttribute("data-view-only")).toBe("false");
await panel.getByRole("button", { name: "Use actual size", exact: true }).click();
await expect.poll(() => panel.getAttribute("data-scale-viewport")).toBe("false");
await panel.getByRole("button", { name: "Keyboard", exact: true }).click();
expect(
await panel.evaluate((element) =>
element.shadowRoot?.activeElement?.classList.contains("desktop-keyboard-input"),
),
).toBe(true);
await page.keyboard.type("k");
await expect.poll(() => panel.getAttribute("data-last-keyboard-event")).toBe("keyup:k");
await panel.locator(".desktop-keyboard-input").evaluate((element) => {
const input = element as HTMLTextAreaElement;
input.value += "m";
input.dispatchEvent(
new InputEvent("input", { data: "m", inputType: "insertText", bubbles: true }),
);
});
await expect.poll(() => panel.getAttribute("data-last-keyboard-text")).toBe("m");
await mkdir(artifactDirectory, { recursive: true });
await page.screenshot({
path: path.join(artifactDirectory, "connected-toolbar-390x844.png"),
fullPage: false,
});
});
});
it("applies only control=1 as the initial control request", async () => {
for (const [value, expected] of [
["1", true],
["true", false],
] as const) {
await suite.withPage({ serviceWorkers: "block" }, async ({ page }) => {
const { gateway } = await openDesktopDocument(
page,
`?view=desktop&source=gateway&control=${value}`,
[gatewayEnvironment],
{
transport: "rfb",
wsPath: `/desktop/observe?token=control-${value}`,
expiresAtMs: 60_000,
control: expected,
},
);
const request = await gateway.waitForRequest("desktop.observe");
expect(request.params).toEqual({ source: { kind: "host" }, control: expected });
});
}
});
});
+12
View File
@@ -2006,6 +2006,7 @@ export const en: TranslationMap = {
},
desktop: {
title: "Desktop",
unavailable: "Desktop viewing is unavailable for this connection.",
toggle: "Toggle desktop panel",
hide: "Hide desktop panel",
resize: "Resize desktop panel",
@@ -2017,9 +2018,20 @@ export const en: TranslationMap = {
refreshing: "Refreshing…",
loading: "Loading desktop sources…",
empty: "No desktop-capable sources are available.",
sourceUnavailable: "The requested desktop source is unavailable. Choose another source.",
connect: "Connect",
connecting: "Connecting to desktop…",
takeControl: "Take control",
switchToViewOnly: "Switch to view only",
viewOnly: "View only",
control: "Control",
keyboard: "Keyboard",
keyboardInput: "Remote desktop keyboard input",
touchControls: "Remote desktop controls",
fit: "Fit",
fitScreen: "Fit screen",
actualSize: "Use actual size",
back: "Back",
disconnect: "Disconnect",
reconnect: "Reconnect",
passwordPrompt: "Enter the VNC password for this machine.",
@@ -126,6 +126,12 @@ function renderInto(template: unknown): HTMLDivElement {
return container;
}
// These render into the shared document, so a missing teardown leaks pairing
// dialogs into whichever suite the worker runs next.
afterEach(() => {
document.body.replaceChildren();
});
describe("channel DM access request views", () => {
it("renders pending senders without exposing the pairing code", () => {
const onApprove = vi.fn();
@@ -15,6 +15,12 @@ const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/chat-
const baseTime = Date.now();
const chatSessionKey = "agent:main:main";
// Running tasks render a live elapsed label, so comparing raw transcript text makes the
// assertion fail whenever a second ticks over mid-check. Only the durations may move here.
function withoutElapsedLabels(text: string | null): string {
return (text ?? "").replaceAll(/\d+(?:\.\d+)?\s*(?:ms|[smhd])\b/g, "<elapsed>");
}
function requestSessionKey(request: MockGatewayRequest): string | undefined {
const { params } = request;
if (
@@ -180,7 +186,7 @@ suite.define(() => {
const chatUrl = page.url();
const mainTranscript = page.locator(".chat-main .chat-thread");
const mainTranscriptBefore = await mainTranscript.textContent();
const mainTranscriptBefore = withoutElapsedLabels(await mainTranscript.textContent());
const openRow = rail.locator('[data-task-id="task-subagent"]');
await openRow.click();
const detailPanel = page.locator("[data-task-detail-panel]");
@@ -209,7 +215,7 @@ suite.define(() => {
limit: 100,
});
expect(page.url()).toBe(chatUrl);
expect(await mainTranscript.textContent()).toBe(mainTranscriptBefore);
expect(withoutElapsedLabels(await mainTranscript.textContent())).toBe(mainTranscriptBefore);
await page.screenshot({
path: path.join(railFlowDir, "02-task-detail-sidebar.png"),
fullPage: true,
+4 -3
View File
@@ -3912,9 +3912,10 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
display: none;
}
/* Terminal-only document (`?view=terminal`, mobile WebViews): centered notice
when the gateway refuses or lacks the terminal surface. */
.terminal-view-unavailable {
/* Standalone terminal/desktop documents: centered notice when the gateway
refuses or lacks the requested surface. */
.terminal-view-unavailable,
.desktop-view-unavailable {
position: fixed;
inset: 0;
display: flex;