mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-20 09:31:54 -06:00
e5fca42952
* 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.
160 lines
5.9 KiB
Swift
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
|
|
}
|
|
}
|