Files
Peter Steinberger 4af09d4961 feat(ui): unify focused presentation routes (#126143)
* feat(ui): unify focused presentation routes

/focus/<target> replaces unshipped standalone query links across dashboard, terminal, desktop, and native apps.

Gateway-served index assets are anchored so nested documents resolve their bundles from the Control UI base path.

* test(gateway): narrow emitted asset URLs

Fixes check:test-types TS18048/TS2322 by dropping unmatched optional captures before comparing emitted asset URLs.

* test(docs): follow centralized cloud secret guidance

Fixes the stale current-main docs test after #126132 centralized GCP and Hetzner setup in docker-vm-runtime.

* test(ui): retry missing locator reads

The 500ms locator text read can time out while the menu label is still rendering, causing expect.poll to reject instead of using its owning 10s retry window. Treat only Playwright TimeoutError as a missing value so the outer poll retries while page-closure and arbitrary failures still surface.

* test(android): capture TLS probe coroutine

The TLS probe test inferred its coroutine from mutable scope children, racing unrelated child startup and teardown in CI. Capture the exact Job from inside the probe coroutine and join that owner before asserting the stale-attempt guard.

* fix(gateway): preserve plugin focus routes

Keep approval handling ahead of plugin dispatch, but treat focus documents as an unclaimed Control UI fallback after plugin authentication and routing. Exact and prefix plugin routes therefore retain ownership, while unclaimed reads serve the focus document and other methods return 404.

* fix(ui): migrate released terminal links

Preserve stable v2026.7.1 terminal query compatibility by rewriting the root/base ?view=terminal URL once to the canonical /focus/terminal path with history.replace. Keep URL parsing path-only, and leave the removed desktop and dashboard query forms as a hard cut.

* test(codex): assign run-attempt tools shard

Cached filtered configs caused duplicate ownership, and the test lacked a canonical full-suite owner.

* test(ui): keep cloud recovery proof state-owned

The recovery test should assert owner state and reload identity, while dedicated tests own transient alert visibility.

* test(qa): wait for outbound bus state

* fix(qa): reserve gateway ports through staging

* refactor(qa): keep socket creation in gateway owner
2026-08-19 03:41:29 -07:00

166 lines
6.2 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?
{
guard let path = self.desktopPath(source: source, session: session) else { return nil }
return AuthenticatedControlUI.pageURL(
config: config,
path: path,
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.desktopPath(source: source, session: session))
return hasher.finalize()
}
private static func desktopPath(source: String?, session: String?) -> String? {
let basePath = "/focus/desktop"
if let source = self.normalizedValue(source) {
guard let encoded = AuthenticatedControlUI.percentEncodedPathSegment(source) else { return nil }
return "\(basePath)/source/\(encoded)"
}
if let session = self.normalizedValue(session) {
guard let encoded = AuthenticatedControlUI.percentEncodedPathSegment(session) else { return nil }
return "\(basePath)/session/\(encoded)"
}
return basePath
}
private static func normalizedValue(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}