Files
openclaw/apps/ios/Sources/Terminal/TerminalHubScreen.swift
Peter Steinberger 0fff74c800 feat(ios): replace the tab bar with a black overlay sidebar (web parity + dashboard) (#111339)
* feat(ios): unify navigation on a black overlay sidebar and drop the phone tab bar

* test(ios): rework navigation tests and snapshot UITests for the sidebar shell

* chore(i18n): sync derived native catalogs for sidebar strings

* feat(ios): reveal the sidebar beneath a push-away rounded content card

* feat(ios): web-parity sidebar — agent switcher, pinned pages, sessions vocabulary, uniform content card

* chore(i18n): translate sidebar and session strings across native catalogs

* fix(ios): sidebar footer shows connection dot only when degraded, like the web

* feat(ios): sidebar toggle top-left with edge-swipe open; card surface owns the drawer chrome

* fix(ios): single sidebar refresh owner, alias-aware Home badges, RM tap-close, user pin order, drop vestigial idiom shims

* feat(ios): align sidebar with the mobile prototype — agent roster, theme-following palette, web session subtitles, row badges

* chore(i18n): translate agent roster and Recent strings across native catalogs

* feat(ios): frosted-glass agent picker with uniform drawer insets

* fix(ios): address sidebar review findings — settings toggle edge, per-agent scoping, alias-aware selection, cron paging, refresh cadence

* fix(ios): clear landing CI blockers
2026-07-19 14:50:39 -07:00

249 lines
10 KiB
Swift

import OpenClawKit
import SwiftUI
import WebKit
/// Control-hub Terminal destination: embeds the gateway-served terminal page
/// (`/?view=terminal`, the ghostty-web surface shared with the Control UI) in a
/// WKWebView, authenticated with the stored gateway credentials.
struct TerminalHubScreen: View {
@Environment(NodeAppModel.self) private var appModel
let headerSidebarAction: OpenClawSidebarHeaderAction?
let usesNativeNavigationChrome: Bool
let gatewayAction: (() -> Void)?
init(
headerSidebarAction: OpenClawSidebarHeaderAction? = nil,
usesNativeNavigationChrome: Bool = false,
gatewayAction: (() -> Void)? = nil)
{
self.headerSidebarAction = headerSidebarAction
self.usesNativeNavigationChrome = usesNativeNavigationChrome
self.gatewayAction = gatewayAction
}
var body: some View {
let config = self.appModel.activeGatewayConnectConfig
let storedOperatorToken = Self.storedOperatorToken(config: config)
ZStack {
OpenClawProBackground()
if let url = Self.terminalURL(config: config) {
TerminalWebView(
url: url,
authScript: Self.terminalAuthUserScript(
config: config,
storedOperatorToken: storedOperatorToken))
// Recreate the web view only when the connection inputs
// change; SwiftUI update passes must not restart live shells.
.id(Self.webContentIdentity(
config: config,
storedOperatorToken: storedOperatorToken))
.ignoresSafeArea(.container, edges: .bottom)
} else {
self.unavailableCard
}
}
.navigationTitle("Terminal")
.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 {
ToolbarItem(placement: .topBarLeading) {
OpenClawSidebarRevealButton(action: headerSidebarAction)
}
}
}
}
private var unavailableCard: some View {
VStack(spacing: 12) {
ProIconBadge(systemName: "terminal", color: OpenClawBrand.accent)
Text("Terminal needs a connected gateway")
.font(OpenClawType.subheadSemiBold)
Text("Connect to your gateway to open a shell in the agent workspace.")
.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)
}
/// Derives the terminal page URL from the active gateway connection: the
/// WS endpoint flips to HTTP(S) and only `view=terminal` rides in the URL.
/// Credentials never enter the URL they are injected as a document-start
/// user script (see `terminalAuthUserScript`), matching the macOS Dashboard.
static func terminalURL(config: GatewayConnectConfig?) -> URL? {
guard let config,
var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false)
else {
return nil
}
switch components.scheme?.lowercased() {
case "wss", "https":
components.scheme = "https"
default:
components.scheme = "http"
}
components.path = "/"
components.queryItems = [URLQueryItem(name: "view", value: "terminal")]
return components.url
}
/// Origin-gated document-start script that hands the gateway credentials to
/// the Control UI via its `__OPENCLAW_NATIVE_CONTROL_AUTH__` startup contract
/// (the same mechanism the macOS Dashboard window uses), so the token never
/// appears in the page URL, WebKit history, or gateway request logs.
static func terminalAuthUserScript(config: GatewayConnectConfig?) -> String? {
self.terminalAuthUserScript(
config: config,
storedOperatorToken: self.storedOperatorToken(config: config))
}
static func terminalAuthUserScript(
config: GatewayConnectConfig?,
storedOperatorToken: String?) -> String?
{
guard let config, let pageURL = terminalURL(config: config) else {
return nil
}
var payload: [String: String] = ["gatewayUrl": config.url.absoluteString]
let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let storedToken = storedOperatorToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !token.isEmpty {
payload["token"] = token
} else if !storedToken.isEmpty {
payload["token"] = storedToken
}
if !password.isEmpty {
payload["password"] = password
}
guard payload["token"] != nil || payload["password"] != nil else {
return nil
}
guard let data = try? JSONSerialization.data(withJSONObject: payload),
let json = String(data: data, encoding: .utf8)
else {
return nil
}
let allowedOrigin = Self.jsStringLiteral(Self.originString(for: pageURL))
return """
(() => {
try {
if (location.origin !== \(allowedOrigin)) return;
Object.defineProperty(window, "__OPENCLAW_NATIVE_CONTROL_AUTH__", {
value: \(json),
configurable: true,
});
} catch {}
})();
"""
}
/// Identity for the embedded web view: recreate it only when the gateway
/// endpoint or credentials actually change.
static func webContentIdentity(config: GatewayConnectConfig?, storedOperatorToken: String?) -> Int {
var hasher = Hasher()
hasher.combine(config?.url)
hasher.combine(config?.token)
hasher.combine(config?.password)
hasher.combine(storedOperatorToken?.trimmingCharacters(in: .whitespacesAndNewlines))
return hasher.finalize()
}
private static func storedOperatorToken(config: GatewayConnectConfig?) -> String? {
guard let config else { return nil }
// Endpoint handoffs may explicitly suppress device-token reuse; every auth surface
// must honor that boundary or a stale token can override the supplied password.
guard config.nodeOptions.allowStoredDeviceAuth else { return nil }
let gatewayID = config.nodeOptions.deviceAuthGatewayID ?? config.effectiveStableID
guard let identity = DeviceIdentityStore.loadOrCreatePersisted() else { return nil }
return DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "operator",
gatewayID: gatewayID)?
.token
}
static func originString(for url: URL) -> String {
guard let scheme = url.scheme, let host = url.host else {
return ""
}
let hostPart = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host
var origin = "\(scheme)://\(hostPart)"
if let port = url.port {
origin += ":\(port)"
}
return origin
}
private static func jsStringLiteral(_ value: String) -> String {
guard let data = try? JSONSerialization.data(withJSONObject: [value]),
let raw = String(data: data, encoding: .utf8),
raw.hasPrefix("["),
raw.hasSuffix("]")
else {
return "\"\""
}
return String(raw.dropFirst().dropLast())
}
}
/// Minimal WKWebView host for the terminal page. Unlike the canvas WebView it
/// needs no script bridges or deep-link routing the page is self-contained.
private struct TerminalWebView: UIViewRepresentable {
let url: URL
let authScript: String?
func makeUIView(context _: Context) -> WKWebView {
let config = WKWebViewConfiguration()
// Ephemeral store: credentials arrive per load via the auth user
// script; nothing needs to persist across loads.
config.websiteDataStore = .nonPersistent()
if let authScript {
config.userContentController.addUserScript(WKUserScript(
source: authScript,
injectionTime: .atDocumentStart,
forMainFrameOnly: true))
}
let webView = WKWebView(frame: .zero, configuration: config)
webView.isOpaque = true
webView.backgroundColor = .black
let scrollView = webView.scrollView
scrollView.backgroundColor = .black
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.contentInset = .zero
scrollView.verticalScrollIndicatorInsets = .zero
scrollView.horizontalScrollIndicatorInsets = .zero
scrollView.automaticallyAdjustsScrollIndicatorInsets = false
webView.load(URLRequest(url: self.url))
return webView
}
func updateUIView(_: WKWebView, context _: Context) {
// Connection changes recreate the view via `.id(webContentIdentity)`;
// reloading here would restart live shell sessions on unrelated passes.
}
}