Files
openclaw/apps/ios/Sources/Desktop/DesktopHubScreen.swift
T
Peter Steinberger e5fca42952 fix(apps): per-session desktop button opens that session's machine (#123412)
* fix(apps): open session desktop on its machine

* fix(ui): scope the desktop session lookup to the key's own agent

`sessions.list` has no exact-key filter, so the viewer resolves a `session=`
parameter by searching for the key and matching it exactly in the response. A
key that prefixes longer ones — `agent:main:main` alongside user-named sessions
that start the same way — could push the exact row outside a five-row page and
silently fall back to the picker.

Session keys encode their agent and the list API accepts `agentId`, so scope the
search to that agent and widen the page.

* refactor(ui): stop parking a session key in the desktop environment id

Document-mode inventory failures stashed the requested session key in
`environmentId` purely so the Retry button's non-null guard would pass, even
though document-mode retry refreshes the inventory and never reads the value.
A session key only names a machine once the inventory loads, so it now stays
out of `environmentId`, and the retry branch that ignores it runs before the
guard. Adds E2E coverage for recovering a session-preselected desktop.

* fix(ui): resolve the desktop session with an exact-key lookup

The session-preselect path searched `sessions.list` and scoped the search to
the key's own agent, but a bounded search cannot rule a key out: 25 newer
same-agent sessions sharing the requested key's prefix would push the exact row
off the page, and the viewer would report the source as unavailable for a
session that exists. `sessions.describe` is the exact-key operation and already
projects placement, so the panel calls it directly and the app-root resolver
plumbing goes away with it.

* refactor(ui): move the desktop document keyboard bridge into its own controller

`desktop-panel.ts` crossed the 700-line cap. The mobile keyboard bridge — the
padded sentinel, the value diffing that turns composed input into backspaces
and text, and the field focus/reset helpers — is a self-contained concern, so
it moves into a `DesktopMobileKeyboard` controller alongside the existing
fullscreen controller instead of taking a `max-lines` suppression.

* test(ios): drain text fields instead of assuming a delete burst lands

`testReleaseChatScreenshot` typed a 5-character probe, sent 5 deletes in one
`typeText`, then asserted the field was empty. CI dropped one synthetic
keystroke under simulator load and the assertion failed with a leftover "f".
XCUITest makes no lossless-burst guarantee, so clearing now re-sends against
whatever the field actually still holds, bounded. The two gateway-setup fields
that overtyped through the same burst use the helper for the same reason.

* fix(ui): keep the chat placement owner out of the startup chunk

`desktop-document-mode.ts` is imported by bootstrap, so importing the chat
placement owner from it pulled the chat page's dependency tree into the startup
bundle and pushed startup JS past its gzip budget (331075 B against a 330507 B
allowance). The route module now only parses the URL; resolving a session to its
machine moves next to the lazily loaded desktop panel, which is the only caller.
Startup JS is back to 329710 B.
2026-08-13 23:33:19 -07:00

160 lines
5.9 KiB
Swift

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 session: String?
let headerSidebarAction: OpenClawSidebarHeaderAction?
let usesNativeNavigationChrome: Bool
let gatewayAction: (() -> Void)?
init(
source: String? = nil,
session: String? = nil,
headerSidebarAction: OpenClawSidebarHeaderAction? = nil,
usesNativeNavigationChrome: Bool = false,
gatewayAction: (() -> Void)? = nil)
{
self.source = source
self.session = session
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, session: self.session) {
AuthenticatedControlUIWebView(
url: url,
authScript: Self.desktopAuthUserScript(
config: config,
source: self.source,
session: self.session,
storedOperatorToken: storedOperatorToken),
tls: config?.tls)
.id(Self.webContentIdentity(
config: config,
source: self.source,
session: self.session,
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?,
session: String? = nil) -> URL?
{
var queryItems = [URLQueryItem(name: "view", value: "desktop")]
if let source = self.normalizedSource(source) {
queryItems.append(URLQueryItem(name: "source", value: source))
}
if let session = self.normalizedSource(session) {
queryItems.append(URLQueryItem(name: "session", value: session))
}
return AuthenticatedControlUI.pageURL(
config: config,
path: "/",
queryItems: queryItems)
}
static func desktopAuthUserScript(
config: GatewayConnectConfig?,
source: String?,
session: String? = nil) -> String?
{
self.desktopAuthUserScript(
config: config,
source: source,
session: session,
storedOperatorToken: AuthenticatedControlUI.storedOperatorToken(config: config))
}
static func desktopAuthUserScript(
config: GatewayConnectConfig?,
source: String?,
session: String? = nil,
storedOperatorToken: String?) -> String?
{
AuthenticatedControlUI.authUserScript(
config: config,
pageURL: self.desktopURL(config: config, source: source, session: session),
storedOperatorToken: storedOperatorToken)
}
static func webContentIdentity(
config: GatewayConnectConfig?,
source: String?,
session: String? = nil,
storedOperatorToken: String?) -> Int
{
var hasher = Hasher()
hasher.combine(AuthenticatedControlUI.webContentIdentity(
config: config,
storedOperatorToken: storedOperatorToken))
hasher.combine(self.normalizedSource(source))
hasher.combine(self.normalizedSource(session))
return hasher.finalize()
}
private static func normalizedSource(_ source: String?) -> String? {
let trimmed = source?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}