Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
import SwiftUI
struct DeepLinkAgentPromptAlert: ViewModifier {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
private var promptBinding: Binding<NodeAppModel.AgentDeepLinkPrompt?> {
Binding(
get: { self.appModel.pendingAgentDeepLinkPrompt },
set: { _ in
// Keep prompt state until explicit user action.
})
}
func body(content: Content) -> some View {
content.alert(item: self.promptBinding) { prompt in
Alert(
title: Text("Run OpenClaw agent?")
.font(OpenClawType.headline),
message: Text(
"""
Message:
\(prompt.messagePreview)
URL:
\(prompt.urlPreview)
""")
.font(OpenClawType.subhead),
primaryButton: .cancel(
Text("Cancel")
.font(OpenClawType.subheadSemiBold))
{
self.appModel.declinePendingAgentDeepLinkPrompt()
},
secondaryButton: .default(
Text("Run")
.font(OpenClawType.subheadSemiBold))
{
Task { await self.appModel.approvePendingAgentDeepLinkPrompt() }
})
}
}
}
extension View {
func deepLinkAgentPromptAlert() -> some View {
self.modifier(DeepLinkAgentPromptAlert())
}
}

View File

@@ -0,0 +1,203 @@
import SwiftUI
private struct ExecApprovalPromptDialogModifier: ViewModifier {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
let suppressedApprovalID: String?
func body(content: Content) -> some View {
content
.overlay {
if let prompt = self.appModel.pendingExecApprovalPrompt,
prompt.id != self.suppressedApprovalID
{
ZStack {
Color.black.opacity(0.38)
.ignoresSafeArea()
ExecApprovalPromptCard(
prompt: prompt,
isResolving: self.appModel.pendingExecApprovalPromptResolving,
errorText: self.appModel.pendingExecApprovalPromptErrorText,
onAllowOnce: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once")
}
},
onAllowAlways: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
}
},
onDeny: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny")
}
},
onCancel: {
self.appModel.dismissPendingExecApprovalPrompt()
})
.padding(.horizontal, 20)
.frame(maxWidth: 460)
.transition(.scale(scale: 0.98).combined(with: .opacity))
}
.zIndex(1)
}
}
.animation(.easeInOut(duration: 0.18), value: self.appModel.pendingExecApprovalPrompt?.id)
}
}
private struct ExecApprovalPromptCard: View {
let prompt: NodeAppModel.ExecApprovalPrompt
let isResolving: Bool
let errorText: String?
let onAllowOnce: () -> Void
let onAllowAlways: () -> Void
let onDeny: () -> Void
let onCancel: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("Exec approval required")
.font(OpenClawType.headline)
Text("Review this exec request before continuing. Your decision will be sent back to the gateway.")
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
}
Text(self.prompt.commandText)
.font(OpenClawType.mono)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(
.black.opacity(0.14),
in: RoundedRectangle(cornerRadius: OpenClawRadius.md, style: .continuous))
VStack(alignment: .leading, spacing: 8) {
if let host = self.normalized(self.prompt.host) {
ExecApprovalPromptMetadataRow(label: "Host", value: host)
}
if let nodeId = self.normalized(self.prompt.nodeId) {
ExecApprovalPromptMetadataRow(label: "Node", value: nodeId)
}
if let agentId = self.normalized(self.prompt.agentId) {
ExecApprovalPromptMetadataRow(label: "Agent", value: agentId)
}
if let expiresText = self.expiresText(self.prompt.expiresAtMs) {
ExecApprovalPromptMetadataRow(label: "Expires", value: expiresText)
}
}
if let errorText = self.normalized(self.errorText) {
Text(errorText)
.font(OpenClawType.footnote)
.foregroundStyle(OpenClawBrand.danger)
}
if self.isResolving {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Resolving…")
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
}
VStack(spacing: 10) {
Button {
self.onAllowOnce()
} label: {
Text("Allow Once")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(self.isResolving)
if self.prompt.allowsAllowAlways {
Button {
self.onAllowAlways()
} label: {
Text("Allow Always")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(self.isResolving)
}
HStack(spacing: 10) {
Button(role: .destructive) {
self.onDeny()
} label: {
Text("Deny")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(self.isResolving)
Button(role: .cancel) {
self.onCancel()
} label: {
Text("Cancel")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(self.isResolving)
}
}
.controlSize(.large)
.frame(maxWidth: .infinity)
}
.padding(18)
.proPanelSurface(tint: OpenClawBrand.accentHot, radius: 20, isProminent: true)
}
private func normalized(_ value: String?) -> String? {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func expiresText(_ expiresAtMs: Int?) -> String? {
guard let expiresAtMs else { return nil }
let remainingSeconds = Int((Double(expiresAtMs) / 1000.0) - Date().timeIntervalSince1970)
if remainingSeconds <= 0 {
return "expired"
}
if remainingSeconds < 60 {
return "under a minute"
}
if remainingSeconds < 3600 {
let minutes = Int(ceil(Double(remainingSeconds) / 60.0))
return minutes == 1 ? "about 1 minute" : "about \(minutes) minutes"
}
let hours = Int(ceil(Double(remainingSeconds) / 3600.0))
return hours == 1 ? "about 1 hour" : "about \(hours) hours"
}
}
private struct ExecApprovalPromptMetadataRow: View {
let label: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(self.label)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
Text(self.value)
.font(OpenClawType.footnote)
.textSelection(.enabled)
}
}
}
extension View {
func execApprovalPromptDialog(suppressedApprovalID: String? = nil) -> some View {
modifier(ExecApprovalPromptDialogModifier(suppressedApprovalID: suppressedApprovalID))
}
}

View File

@@ -0,0 +1,78 @@
import Foundation
import OpenClawKit
/// Single source of truth for "how we connect" to the current gateway.
///
/// The iOS app maintains two WebSocket sessions to the same gateway:
/// - a `role=node` session for device capabilities (`node.invoke.*`)
/// - a `role=operator` session for chat/talk/config (`chat.*`, `talk.*`, etc.)
///
/// Both sessions should derive all connection inputs from this config so we
/// don't accidentally persist gateway-scoped state under different keys.
struct GatewayConnectConfig {
let url: URL
let stableID: String
let tls: GatewayTLSParams?
let token: String?
let bootstrapToken: String?
let password: String?
let nodeOptions: GatewayConnectOptions
/// Stable, non-empty identifier used for gateway-scoped persistence keys.
/// If the caller doesn't provide a stableID, fall back to URL identity.
var effectiveStableID: String {
let trimmed = self.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return self.url.absoluteString }
return trimmed
}
func hasSameConnectionInputs(as other: GatewayConnectConfig) -> Bool {
self.url == other.url &&
self.stableID == other.stableID &&
Self.sameTLS(self.tls, other.tls) &&
self.token == other.token &&
self.bootstrapToken == other.bootstrapToken &&
self.password == other.password &&
Self.sameOptions(self.nodeOptions, other.nodeOptions)
}
private static func sameTLS(_ lhs: GatewayTLSParams?, _ rhs: GatewayTLSParams?) -> Bool {
switch (lhs, rhs) {
case (nil, nil):
true
case let (lhs?, rhs?):
lhs.required == rhs.required &&
lhs.expectedFingerprint == rhs.expectedFingerprint &&
lhs.allowTOFU == rhs.allowTOFU &&
lhs.storeKey == rhs.storeKey
default:
false
}
}
private static func sameOptions(_ lhs: GatewayConnectOptions, _ rhs: GatewayConnectOptions) -> Bool {
let lhsScopes = Self.normalizedValues(lhs.scopes)
let rhsScopes = Self.normalizedValues(rhs.scopes)
let lhsCaps = Self.normalizedValues(lhs.caps)
let rhsCaps = Self.normalizedValues(rhs.caps)
let lhsCommands = Self.normalizedValues(lhs.commands)
let rhsCommands = Self.normalizedValues(rhs.commands)
return lhs.role == rhs.role &&
lhs.scopesAreExplicit == rhs.scopesAreExplicit &&
lhs.clientId == rhs.clientId &&
lhs.clientMode == rhs.clientMode &&
lhs.clientDisplayName == rhs.clientDisplayName &&
lhs.deviceIdentityProfile == rhs.deviceIdentityProfile &&
lhs.includeDeviceIdentity == rhs.includeDeviceIdentity &&
lhsScopes == rhsScopes &&
lhsCaps == rhsCaps &&
lhsCommands == rhsCommands &&
lhs.permissions == rhs.permissions
}
private static func normalizedValues(_ values: [String]) -> [String] {
values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.sorted()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
import Foundation
import OpenClawKit
enum GatewayConnectionIssue: Equatable {
case none
case tokenMissing
case unauthorized
case pairingRequired(requestId: String?)
case network
case unknown(String)
var requestId: String? {
if case let .pairingRequired(requestId) = self {
return requestId
}
return nil
}
var needsAuthToken: Bool {
switch self {
case .tokenMissing, .unauthorized:
true
default:
false
}
}
var needsPairing: Bool {
if case .pairingRequired = self { return true }
return false
}
static func detect(problem: GatewayConnectionProblem?) -> Self {
guard let problem else { return .none }
if problem.needsPairingApproval {
return .pairingRequired(requestId: problem.requestId)
}
if problem.needsCredentialUpdate {
return problem.kind == .gatewayAuthTokenMissing ? .tokenMissing : .unauthorized
}
switch problem.kind {
case .deviceIdentityRequired,
.deviceSignatureExpired,
.deviceNonceRequired,
.deviceNonceMismatch,
.deviceSignatureInvalid,
.devicePublicKeyInvalid,
.deviceIdMismatch,
.tailscaleIdentityMissing,
.tailscaleProxyMissing,
.tailscaleWhoisFailed,
.tailscaleIdentityMismatch,
.authRateLimited:
return .unauthorized
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return .network
case .unknown:
return .unknown(problem.message)
default:
return .none
}
}
static func detect(from statusText: String) -> Self {
let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .none }
let lower = trimmed.lowercased()
if lower.contains("pairing required") || lower.contains("not_paired") || lower.contains("not paired") {
return .pairingRequired(requestId: self.extractRequestId(from: trimmed))
}
if lower.contains("gateway token missing") {
return .tokenMissing
}
if lower.contains("unauthorized") {
return .unauthorized
}
if lower.contains("connection refused") ||
lower.contains("timed out") ||
lower.contains("network is unreachable") ||
lower.contains("cannot find host") ||
lower.contains("could not connect")
{
return .network
}
if lower.hasPrefix("gateway error:") {
return .unknown(trimmed)
}
return .none
}
private static func extractRequestId(from statusText: String) -> String? {
let marker = "requestId:"
guard let range = statusText.range(of: marker) else { return nil }
let suffix = statusText[range.upperBound...]
let trimmed = suffix.trimmingCharacters(in: .whitespacesAndNewlines)
let end = trimmed.firstIndex(where: { ch in
ch == ")" || ch.isWhitespace || ch == "," || ch == ";"
}) ?? trimmed.endIndex
let id = String(trimmed[..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
return id.isEmpty ? nil : id
}
}

View File

@@ -0,0 +1,73 @@
import SwiftUI
import UIKit
struct GatewayDiscoveryDebugLogView: View {
@Environment(GatewayConnectionController.self) private var gatewayController
@AppStorage("gateway.discovery.debugLogs") private var debugLogsEnabled: Bool = false
var body: some View {
List {
if !self.debugLogsEnabled {
Text("Enable “Discovery Debug Logs” to start collecting events.")
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
}
if self.gatewayController.discoveryDebugLog.isEmpty {
Text("No log entries yet.")
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
} else {
ForEach(self.gatewayController.discoveryDebugLog) { entry in
VStack(alignment: .leading, spacing: 2) {
Text(Self.formatTime(entry.ts))
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
Text(entry.message)
.font(OpenClawType.callout)
.textSelection(.enabled)
}
.padding(.vertical, 4)
}
}
}
.navigationTitle("Discovery Logs")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
UIPasteboard.general.string = self.formattedLog()
} label: {
Text("Copy")
.font(OpenClawType.subheadSemiBold)
}
.disabled(self.gatewayController.discoveryDebugLog.isEmpty)
}
}
}
private func formattedLog() -> String {
self.gatewayController.discoveryDebugLog
.map { "\(Self.formatISO($0.ts)) \($0.message)" }
.joined(separator: "\n")
}
private static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
private static let isoFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static func formatTime(_ date: Date) -> String {
self.timeFormatter.string(from: date)
}
private static func formatISO(_ date: Date) -> String {
self.isoFormatter.string(from: date)
}
}

View File

@@ -0,0 +1,184 @@
import Foundation
import Network
import Observation
import OpenClawKit
@MainActor
@Observable
final class GatewayDiscoveryModel {
struct DebugLogEntry: Identifiable, Equatable {
var id = UUID()
var ts: Date
var message: String
}
struct DiscoveredGateway: Identifiable, Equatable {
var id: String {
self.stableID
}
var name: String
var endpoint: NWEndpoint
var stableID: String
var debugID: String
var lanHost: String?
var tailnetDns: String?
var gatewayPort: Int?
var canvasPort: Int?
var tlsEnabled: Bool
var tlsFingerprintSha256: String?
var cliPath: String?
}
var gateways: [DiscoveredGateway] = []
var statusText: String = "Idle"
private(set) var debugLog: [DebugLogEntry] = []
private var browsers: [String: NWBrowser] = [:]
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
private var statesByDomain: [String: NWBrowser.State] = [:]
private var debugLoggingEnabled = false
private var lastStableIDs = Set<String>()
func setDebugLoggingEnabled(_ enabled: Bool) {
let wasEnabled = self.debugLoggingEnabled
self.debugLoggingEnabled = enabled
if !enabled {
self.debugLog = []
} else if !wasEnabled {
self.appendDebugLog("debug logging enabled")
self.appendDebugLog("snapshot: status=\(self.statusText) gateways=\(self.gateways.count)")
}
}
func start() {
if !self.browsers.isEmpty { return }
self.appendDebugLog("start()")
for domain in OpenClawBonjour.gatewayServiceDomains {
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
serviceType: OpenClawBonjour.gatewayServiceType,
domain: domain,
queueLabelPrefix: "ai.openclawfoundation.app.gateway-discovery",
onState: { [weak self] state in
guard let self else { return }
self.statesByDomain[domain] = state
self.updateStatusText()
self.appendDebugLog("state[\(domain)]: \(Self.prettyState(state))")
},
onResults: { [weak self] results in
guard let self else { return }
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
switch result.endpoint {
case let .service(name, _, _, _):
let decodedName = BonjourEscapes.decode(name)
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
let advertisedName = txt["displayName"]
let prettyAdvertised = advertisedName
.map(Self.prettifyInstanceName)
.flatMap { $0.isEmpty ? nil : $0 }
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
return DiscoveredGateway(
name: prettyName,
endpoint: result.endpoint,
stableID: GatewayEndpointID.stableID(result.endpoint),
debugID: GatewayEndpointID.prettyDescription(result.endpoint),
lanHost: Self.txtValue(txt, key: "lanHost"),
tailnetDns: Self.txtValue(txt, key: "tailnetDns"),
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
tlsEnabled: Self.txtBoolValue(txt, key: "gatewayTls"),
tlsFingerprintSha256: Self.txtValue(txt, key: "gatewayTlsSha256"),
cliPath: Self.txtValue(txt, key: "cliPath"))
default:
return nil
}
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
self.recomputeGateways()
})
self.browsers[domain] = browser
}
}
func stop() {
self.appendDebugLog("stop()")
for browser in self.browsers.values {
browser.cancel()
}
self.browsers = [:]
self.gatewaysByDomain = [:]
self.statesByDomain = [:]
self.gateways = []
self.statusText = "Stopped"
}
private func recomputeGateways() {
let next = self.gatewaysByDomain.values
.flatMap(\.self)
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
let nextIDs = Set(next.map(\.stableID))
let added = nextIDs.subtracting(self.lastStableIDs)
let removed = self.lastStableIDs.subtracting(nextIDs)
if !added.isEmpty || !removed.isEmpty {
self.appendDebugLog("results: total=\(next.count) added=\(added.count) removed=\(removed.count)")
}
self.lastStableIDs = nextIDs
self.gateways = next
}
private func updateStatusText() {
self.statusText = GatewayDiscoveryStatusText.make(
states: Array(self.statesByDomain.values),
hasBrowsers: !self.browsers.isEmpty)
}
private static func prettyState(_ state: NWBrowser.State) -> String {
switch state {
case .setup:
"setup"
case .ready:
"ready"
case let .failed(err):
"failed (\(err))"
case .cancelled:
"cancelled"
case let .waiting(err):
"waiting (\(err))"
@unknown default:
"unknown"
}
}
private func appendDebugLog(_ message: String) {
guard self.debugLoggingEnabled else { return }
self.debugLog.append(DebugLogEntry(ts: Date(), message: message))
if self.debugLog.count > 200 {
self.debugLog.removeFirst(self.debugLog.count - 200)
}
}
private static func prettifyInstanceName(_ decodedName: String) -> String {
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
let stripped = normalized.replacingOccurrences(of: " (OpenClaw)", with: "")
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func txtValue(_ dict: [String: String], key: String) -> String? {
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return raw.isEmpty ? nil : raw
}
private static func txtIntValue(_ dict: [String: String], key: String) -> Int? {
guard let raw = self.txtValue(dict, key: key) else { return nil }
return Int(raw)
}
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
return raw == "1" || raw == "true" || raw == "yes"
}
}

View File

@@ -0,0 +1,85 @@
import Foundation
import OpenClawKit
@MainActor
final class GatewayHealthMonitor {
struct Config {
var intervalSeconds: Double
var timeoutSeconds: Double
var maxFailures: Int
}
private let config: Config
private let sleep: @Sendable (UInt64) async -> Void
private var task: Task<Void, Never>?
init(
config: Config = Config(intervalSeconds: 15, timeoutSeconds: 5, maxFailures: 3),
sleep: @escaping @Sendable (UInt64) async -> Void = { nanoseconds in
try? await Task.sleep(nanoseconds: nanoseconds)
})
{
self.config = config
self.sleep = sleep
}
func start(
check: @escaping @Sendable () async throws -> Bool,
onFailure: @escaping @Sendable (_ failureCount: Int) async -> Void)
{
self.stop()
let config = self.config
let sleep = self.sleep
self.task = Task { @MainActor in
var failures = 0
while !Task.isCancelled {
let ok = await Self.runCheck(check: check, timeoutSeconds: config.timeoutSeconds)
if ok {
failures = 0
} else {
failures += 1
if failures >= max(1, config.maxFailures) {
await onFailure(failures)
failures = 0
}
}
if Task.isCancelled { break }
let interval = max(0.0, config.intervalSeconds)
let nanos = UInt64(interval * 1_000_000_000)
if nanos > 0 {
await sleep(nanos)
} else {
await Task.yield()
}
}
}
}
func stop() {
self.task?.cancel()
self.task = nil
}
private static func runCheck(
check: @escaping @Sendable () async throws -> Bool,
timeoutSeconds: Double) async -> Bool
{
let timeout = max(0.0, timeoutSeconds)
if timeout == 0 {
return await (try? check()) ?? false
}
do {
let timeoutError = NSError(
domain: "GatewayHealthMonitor",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "health check timed out"])
return try await AsyncTimeout.withTimeout(
seconds: timeout,
onTimeout: { timeoutError },
operation: check)
} catch {
return false
}
}
}

View File

@@ -0,0 +1,34 @@
import OpenClawKit
import UIKit
enum GatewayProblemPrimaryAction {
static func title(
for problem: GatewayConnectionProblem,
retryTitle: String,
resetTitle: String? = nil,
nonRetryableTitle: String? = nil) -> String?
{
if problem.suggestsOnboardingReset, let resetTitle {
return resetTitle
}
if problem.canTrustRotatedCertificate {
return "Trust certificate"
}
if problem.kind == .protocolMismatch {
return problem.actionLabel
}
if problem.retryable {
return problem.actionLabel ?? retryTitle
}
return nonRetryableTitle
}
@MainActor
static func openProtocolMismatchHelpIfNeeded(_ problem: GatewayConnectionProblem) -> Bool {
guard problem.kind == .protocolMismatch else { return false }
if let url = problem.docsURL {
UIApplication.shared.open(url)
}
return true
}
}

View File

@@ -0,0 +1,225 @@
import OpenClawKit
import SwiftUI
import UIKit
struct GatewayProblemBanner: View {
let problem: GatewayConnectionProblem
var primaryActionTitle: String?
var onPrimaryAction: (() -> Void)?
var onShowDetails: (() -> Void)?
var body: some View {
OpenClawNoticeBanner(
icon: self.iconName,
title: self.problem.title,
message: self.problem.message,
ownerLabel: self.ownerLabel,
tint: self.tint,
detail: self.problem.requestId.map(OpenClawNoticeDetail.requestID),
primaryActionTitle: self.primaryActionTitle,
onPrimaryAction: self.onPrimaryAction,
secondaryActionTitle: "Details",
onSecondaryAction: self.onShowDetails)
}
private var iconName: String {
switch self.problem.kind {
case .pairingRequired,
.pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
"person.crop.circle.badge.clock"
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
"wifi.exclamationmark"
case .deviceIdentityRequired,
.deviceSignatureExpired,
.deviceNonceRequired,
.deviceNonceMismatch,
.deviceSignatureInvalid,
.devicePublicKeyInvalid,
.deviceIdMismatch:
"lock.shield"
default:
"exclamationmark.triangle.fill"
}
}
private var tint: Color {
switch self.problem.kind {
case .pairingRequired,
.pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
OpenClawBrand.warn
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
OpenClawBrand.warn
default:
OpenClawBrand.danger
}
}
private var ownerLabel: String {
switch self.problem.owner {
case .gateway:
"Fix on gateway"
case .iphone:
"Fix on this device"
case .both:
"Check both"
case .network:
"Check network"
case .unknown:
"Needs attention"
}
}
}
struct GatewayProblemDetailsSheet: View {
@Environment(\.dismiss) private var dismiss
let problem: GatewayConnectionProblem
var primaryActionTitle: String?
var onPrimaryAction: (() -> Void)?
@State private var copyFeedback: String?
var body: some View {
NavigationStack {
List {
Section {
VStack(alignment: .leading, spacing: 10) {
Text(self.problem.title)
.font(OpenClawType.title3)
Text(self.problem.message)
.font(OpenClawType.body)
.foregroundStyle(.secondary)
Text(self.ownerSummary)
.font(OpenClawType.footnoteSemiBold)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
}
if let requestId = self.problem.requestId {
Section {
Text(verbatim: requestId)
.font(OpenClawType.mono)
.textSelection(.enabled)
Button {
UIPasteboard.general.string = requestId
self.copyFeedback = "Copied request ID"
} label: {
Text("Copy request ID")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
} header: {
Text("Request")
.font(OpenClawType.captionSemiBold)
}
}
if let actionCommand = self.problem.actionCommand {
Section {
Text(verbatim: actionCommand)
.font(OpenClawType.mono)
.textSelection(.enabled)
Button {
UIPasteboard.general.string = actionCommand
self.copyFeedback = "Copied command"
} label: {
Text("Copy command")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
} header: {
Text("Gateway command")
.font(OpenClawType.captionSemiBold)
}
}
if let docsURL = self.problem.docsURL {
Section {
Link(destination: docsURL) {
Label("Open docs", systemImage: "book")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
Text(verbatim: docsURL.absoluteString)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
} header: {
Text("Help")
.font(OpenClawType.captionSemiBold)
}
}
if let technicalDetails = self.problem.technicalDetails {
Section {
Text(verbatim: technicalDetails)
.font(OpenClawType.monoFootnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
} header: {
Text("Technical details")
.font(OpenClawType.captionSemiBold)
}
}
if let copyFeedback {
Section {
Text(copyFeedback)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Connection problem")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Text("Connection problem")
.font(OpenClawType.headline)
}
ToolbarItem(placement: .topBarLeading) {
if let primaryActionTitle, let onPrimaryAction {
Button {
self.dismiss()
onPrimaryAction()
} label: {
Text(primaryActionTitle)
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
self.dismiss()
} label: {
Text("Done")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
}
}
}
}
private var ownerSummary: String {
switch self.problem.owner {
case .gateway:
"Primary fix: gateway"
case .iphone:
"Primary fix: this device"
case .both:
"Primary fix: check both this device and the gateway"
case .network:
"Primary fix: network or remote access"
case .unknown:
"Primary fix: review details and retry"
}
}
}

View File

@@ -0,0 +1,220 @@
import OpenClawKit
import SwiftUI
struct GatewayQuickSetupSheet: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(GatewayConnectionController.self) private var gatewayController
@Environment(\.dismiss) private var dismiss
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
@State private var connecting: Bool = false
@State private var connectError: String?
@State private var showGatewayProblemDetails: Bool = false
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 16) {
Text("Connect to a Gateway?")
.font(OpenClawType.title2)
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
}
if let candidate = self.bestCandidate {
GatewayQuickSetupCandidatePanel(
name: candidate.name,
debugID: candidate.debugID,
discoveryStatusText: self.gatewayController.discoveryStatusText,
gatewayDisplayStatusText: self.appModel.gatewayDisplayStatusText,
nodeStatusText: self.appModel.nodeStatusText,
operatorStatusText: self.appModel.operatorStatusText)
Button {
self.connectError = nil
self.connecting = true
Task {
let err = await self.gatewayController.connectWithDiagnostics(candidate)
await MainActor.run {
self.connecting = false
self.connectError = err
}
}
} label: {
Group {
if self.connecting {
HStack(spacing: 8) {
ProgressView().progressViewStyle(.circular)
Text("Connecting…")
.font(OpenClawType.headline)
}
} else {
Text("Connect")
.font(OpenClawType.headline)
}
}
}
.font(OpenClawType.headline)
.openClawPrimaryButton()
.disabled(self.connecting)
if let connectError {
Text(connectError)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
Button {
self.dismiss()
} label: {
Text("Not now")
.font(OpenClawType.headline)
}
.font(OpenClawType.headline)
.openClawSecondaryButton()
.disabled(self.connecting)
self.fullRowToggle("Dont show this again", isOn: self.$quickSetupDismissed)
.padding(.top, 4)
} else {
Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.")
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
}
Spacer()
}
.font(OpenClawType.body)
.padding()
.navigationTitle("Quick Setup")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Text("Quick Setup")
.font(OpenClawType.headline)
}
ToolbarItem(placement: .topBarTrailing) {
Button {
self.quickSetupDismissed = true
self.dismiss()
} label: {
Text("Close")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
}
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
})
}
}
}
private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {
// Prefer whatever discovery says is first; the list is already name-sorted.
self.gatewayController.gateways.first
}
private func fullRowToggle(_ title: LocalizedStringKey, isOn: Binding<Bool>) -> some View {
Toggle(isOn: isOn) {
Text(title)
.font(OpenClawType.subhead)
}
.font(OpenClawType.subhead)
.contentShape(Rectangle())
.overlay {
// Keep Toggle semantics for accessibility while making the full visual row tappable.
Button {
isOn.wrappedValue.toggle()
} label: {
Rectangle()
.fill(.clear)
.contentShape(Rectangle())
}
.font(OpenClawType.subhead)
.buttonStyle(.plain)
.accessibilityHidden(true)
}
}
private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String? {
GatewayProblemPrimaryAction.title(for: problem, retryTitle: "Connect")
}
private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async {
if problem.canTrustRotatedCertificate {
_ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem)
return
}
if GatewayProblemPrimaryAction.openProtocolMismatchHelpIfNeeded(problem) {
return
}
guard problem.retryable else { return }
guard let candidate = self.bestCandidate else { return }
self.connectError = nil
self.connecting = true
let err = await self.gatewayController.connectWithDiagnostics(candidate)
self.connecting = false
self.connectError = err
}
}
private struct GatewayQuickSetupCandidatePanel: View {
private static let readableMonospaceWidth: CGFloat = 72 * 8
let name: String
let debugID: String
let discoveryStatusText: String
let gatewayDisplayStatusText: String
let nodeStatusText: String
let operatorStatusText: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(verbatim: self.name)
.font(OpenClawType.monoHeadline)
.foregroundStyle(.primary)
Text(verbatim: self.debugID)
.font(OpenClawType.monoFootnote)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
// Use verbatim strings so Bonjour-provided values can't be interpreted as
// localized format strings (which can crash with Objective-C exceptions).
Text(verbatim: "Discovery: \(self.discoveryStatusText)")
.font(OpenClawType.monoFootnote)
Text(verbatim: "Status: \(self.gatewayDisplayStatusText)")
.font(OpenClawType.monoFootnote)
Text(verbatim: "Node: \(self.nodeStatusText)")
.font(OpenClawType.monoFootnote)
Text(verbatim: "Operator: \(self.operatorStatusText)")
.font(OpenClawType.monoFootnote)
}
.font(OpenClawType.monoFootnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: Self.readableMonospaceWidth, alignment: .leading)
.padding(.vertical, 14)
.padding(.horizontal, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
.background(OpenClawBrand.obsidian)
.clipShape(RoundedRectangle(cornerRadius: OpenClawRadius.lg))
}
}

View File

@@ -0,0 +1,52 @@
import Foundation
import OpenClawKit
/// NetService-based resolver for Bonjour services.
/// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
private let service: NetService
private let completion: ((host: String, port: Int)?) -> Void
private var didFinish = false
init(
name: String,
type: String,
domain: String,
completion: @escaping ((host: String, port: Int)?) -> Void)
{
self.service = NetService(domain: domain, type: type, name: name)
self.completion = completion
super.init()
self.service.delegate = self
}
func start(timeout: TimeInterval = 2.0) {
BonjourServiceResolverSupport.start(self.service, timeout: timeout)
}
func netServiceDidResolveAddress(_ sender: NetService) {
let host = Self.normalizeHost(sender.hostName)
let port = sender.port
guard let host, !host.isEmpty, port > 0 else {
self.finish(result: nil)
return
}
self.finish(result: (host: host, port: port))
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
self.finish(result: nil)
}
private func finish(result: (host: String, port: Int)?) {
guard !self.didFinish else { return }
self.didFinish = true
self.service.stop()
self.service.remove(from: .main, forMode: .common)
self.completion(result)
}
private static func normalizeHost(_ raw: String?) -> String? {
BonjourServiceResolverSupport.normalizeHost(raw)
}
}

View File

@@ -0,0 +1,591 @@
import Foundation
import os
enum GatewaySettingsStore {
private static let gatewayService = "ai.openclawfoundation.app.gateway"
private static let nodeService = "ai.openclawfoundation.app.node"
private static let talkService = "ai.openclawfoundation.app.talk"
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID"
private static let lastGatewayKindDefaultsKey = "gateway.last.kind"
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
private static let lastGatewayTlsDefaultsKey = "gateway.last.tls"
private static let lastGatewayStableIDDefaultsKey = "gateway.last.stableID"
private static let clientIdOverrideDefaultsPrefix = "gateway.clientIdOverride."
private static let selectedAgentDefaultsPrefix = "gateway.selectedAgentId."
private static let instanceIdAccount = "instanceId"
private static let preferredGatewayStableIDAccount = "preferredStableID"
private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID"
private static let lastGatewayConnectionAccount = "lastConnection"
private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." // pragma: allowlist secret
static func bootstrapPersistence() {
self.ensureStableInstanceID()
self.ensurePreferredGatewayStableID()
self.ensureLastDiscoveredGatewayStableID()
}
static func currentInstanceID(defaults: UserDefaults = .standard) -> String {
self.bootstrapPersistence()
if let value = defaults.string(forKey: self.instanceIdDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty
{
return value
}
return self.loadStableInstanceID() ?? ""
}
static func loadStableInstanceID() -> String? {
if let value = KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty
{
return value
}
return nil
}
static func saveStableInstanceID(_ instanceId: String) {
_ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount)
}
static func loadPreferredGatewayStableID() -> String? {
if let value = KeychainStore.loadString(
service: self.gatewayService,
account: self.preferredGatewayStableIDAccount)?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty
{
return value
}
return nil
}
static func savePreferredGatewayStableID(_ stableID: String) {
_ = KeychainStore.saveString(
stableID,
service: self.gatewayService,
account: self.preferredGatewayStableIDAccount)
}
static func clearPreferredGatewayStableID(defaults: UserDefaults = .standard) {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.preferredGatewayStableIDAccount)
defaults.removeObject(forKey: self.preferredGatewayStableIDDefaultsKey)
}
static func loadLastDiscoveredGatewayStableID() -> String? {
if let value = KeychainStore.loadString(
service: self.gatewayService,
account: self.lastDiscoveredGatewayStableIDAccount)?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty
{
return value
}
return nil
}
static func saveLastDiscoveredGatewayStableID(_ stableID: String) {
_ = KeychainStore.saveString(
stableID,
service: self.gatewayService,
account: self.lastDiscoveredGatewayStableIDAccount)
}
static func clearLastDiscoveredGatewayStableID(defaults: UserDefaults = .standard) {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.lastDiscoveredGatewayStableIDAccount)
defaults.removeObject(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
}
static func loadGatewayToken(instanceId: String) -> String? {
let account = self.gatewayTokenAccount(instanceId: instanceId)
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if token?.isEmpty == false { return token }
return nil
}
static func saveGatewayToken(_ token: String, instanceId: String) {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayTokenAccount(instanceId: instanceId))
return
}
_ = KeychainStore.saveString(
trimmed,
service: self.gatewayService,
account: self.gatewayTokenAccount(instanceId: instanceId))
}
static func loadGatewayBootstrapToken(instanceId: String) -> String? {
let account = self.gatewayBootstrapTokenAccount(instanceId: instanceId)
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if token?.isEmpty == false { return token }
return nil
}
static func saveGatewayBootstrapToken(_ token: String, instanceId: String) {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
self.clearGatewayBootstrapToken(instanceId: instanceId)
return
}
_ = KeychainStore.saveString(
trimmed,
service: self.gatewayService,
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
}
static func clearGatewayBootstrapToken(instanceId: String) {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
}
static func loadGatewayPassword(instanceId: String) -> String? {
KeychainStore.loadString(
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: instanceId))?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveGatewayPassword(_ password: String, instanceId: String) {
let trimmed = password.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: instanceId))
return
}
_ = KeychainStore.saveString(
trimmed,
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: instanceId))
}
enum LastGatewayConnection: Equatable {
case manual(host: String, port: Int, useTLS: Bool, stableID: String)
case discovered(stableID: String, useTLS: Bool)
}
private enum LastGatewayKind: String, Codable {
case manual
case discovered
}
/// JSON-serializable envelope stored as a single Keychain entry.
private struct LastGatewayConnectionData: Codable {
var kind: LastGatewayKind
var stableID: String
var useTLS: Bool
var host: String?
var port: Int?
}
static func loadTalkProviderApiKey(provider: String) -> String? {
guard let providerId = self.normalizedTalkProviderID(provider) else { return nil }
let account = self.talkProviderApiKeyAccount(providerId: providerId)
let value = KeychainStore.loadString(
service: self.talkService,
account: account)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
return nil
}
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
let payload = LastGatewayConnectionData(
kind: .manual, stableID: stableID, useTLS: useTLS, host: host, port: port)
self.saveLastGatewayConnectionData(payload)
}
static func saveLastGatewayConnectionDiscovered(stableID: String, useTLS: Bool) {
let payload = LastGatewayConnectionData(
kind: .discovered, stableID: stableID, useTLS: useTLS)
self.saveLastGatewayConnectionData(payload)
}
static func loadLastGatewayConnection() -> LastGatewayConnection? {
// Migrate legacy UserDefaults entries on first access.
self.migrateLastGatewayFromUserDefaultsIfNeeded()
guard let json = KeychainStore.loadString(
service: self.gatewayService, account: self.lastGatewayConnectionAccount),
let data = json.data(using: .utf8),
let stored = try? JSONDecoder().decode(LastGatewayConnectionData.self, from: data)
else { return nil }
let stableID = stored.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !stableID.isEmpty else { return nil }
if stored.kind == .discovered {
return .discovered(stableID: stableID, useTLS: stored.useTLS)
}
let host = (stored.host ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let port = stored.port ?? 0
guard !host.isEmpty, port > 0, port <= 65535 else { return nil }
return .manual(host: host, port: port, useTLS: stored.useTLS, stableID: stableID)
}
static func clearLastGatewayConnection(defaults: UserDefaults = .standard) {
_ = KeychainStore.delete(
service: self.gatewayService, account: self.lastGatewayConnectionAccount)
// Clean up any legacy UserDefaults entries.
defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey)
}
@discardableResult
private static func saveLastGatewayConnectionData(_ payload: LastGatewayConnectionData) -> Bool {
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else { return false }
return KeychainStore.saveString(
json, service: self.gatewayService, account: self.lastGatewayConnectionAccount)
}
/// Migrate legacy UserDefaults gateway.last.* keys into a single Keychain entry.
private static func migrateLastGatewayFromUserDefaultsIfNeeded() {
let defaults = UserDefaults.standard
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !stableID.isEmpty else { return }
// Already migrated if Keychain entry exists.
if KeychainStore.loadString(
service: self.gatewayService, account: self.lastGatewayConnectionAccount) != nil
{
// Clean up legacy keys.
self.removeLastGatewayDefaults(defaults)
return
}
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let kind = LastGatewayKind(rawValue: kindRaw) ?? .manual
let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let port = defaults.object(forKey: self.lastGatewayPortDefaultsKey) as? Int
let payload = LastGatewayConnectionData(
kind: kind,
stableID: stableID,
useTLS: useTLS,
host: kind == .manual ? host : nil,
port: kind == .manual ? port : nil)
guard self.saveLastGatewayConnectionData(payload) else { return }
self.removeLastGatewayDefaults(defaults)
}
private static func removeLastGatewayDefaults(_ defaults: UserDefaults) {
defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey)
}
static func deleteGatewayCredentials(instanceId: String) {
let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayTokenAccount(instanceId: trimmed))
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayBootstrapTokenAccount(instanceId: trimmed))
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: trimmed))
}
static func loadGatewayClientIdOverride(stableID: String) -> String? {
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedID.isEmpty else { return nil }
let key = self.clientIdOverrideDefaultsPrefix + trimmedID
let value = UserDefaults.standard.string(forKey: key)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
return nil
}
static func saveGatewayClientIdOverride(stableID: String, clientId: String?) {
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedID.isEmpty else { return }
let key = self.clientIdOverrideDefaultsPrefix + trimmedID
let trimmedClientId = clientId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmedClientId.isEmpty {
UserDefaults.standard.removeObject(forKey: key)
} else {
UserDefaults.standard.set(trimmedClientId, forKey: key)
}
}
static func loadGatewaySelectedAgentId(stableID: String) -> String? {
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedID.isEmpty else { return nil }
let key = self.selectedAgentDefaultsPrefix + trimmedID
let value = UserDefaults.standard.string(forKey: key)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
return nil
}
static func saveGatewaySelectedAgentId(stableID: String, agentId: String?) {
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedID.isEmpty else { return }
let key = self.selectedAgentDefaultsPrefix + trimmedID
let trimmedAgentId = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmedAgentId.isEmpty {
UserDefaults.standard.removeObject(forKey: key)
} else {
UserDefaults.standard.set(trimmedAgentId, forKey: key)
}
}
private static func gatewayTokenAccount(instanceId: String) -> String {
"gateway-token.\(instanceId)"
}
private static func gatewayBootstrapTokenAccount(instanceId: String) -> String {
"gateway-bootstrap-token.\(instanceId)"
}
private static func gatewayPasswordAccount(instanceId: String) -> String {
"gateway-password.\(instanceId)"
}
private static func talkProviderApiKeyAccount(providerId: String) -> String {
self.talkProviderApiKeyAccountPrefix + providerId
}
private static func normalizedTalkProviderID(_ provider: String) -> String? {
let trimmed = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
private static func ensureStableInstanceID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadStableInstanceID() == nil {
self.saveStableInstanceID(existing)
}
return
}
if let stored = self.loadStableInstanceID(), !stored.isEmpty {
defaults.set(stored, forKey: self.instanceIdDefaultsKey)
return
}
let fresh = UUID().uuidString
self.saveStableInstanceID(fresh)
defaults.set(fresh, forKey: self.instanceIdDefaultsKey)
}
private static func ensurePreferredGatewayStableID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadPreferredGatewayStableID() == nil {
self.savePreferredGatewayStableID(existing)
}
return
}
if let stored = self.loadPreferredGatewayStableID(), !stored.isEmpty {
defaults.set(stored, forKey: self.preferredGatewayStableIDDefaultsKey)
}
}
private static func ensureLastDiscoveredGatewayStableID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
{
if self.loadLastDiscoveredGatewayStableID() == nil {
self.saveLastDiscoveredGatewayStableID(existing)
}
return
}
if let stored = self.loadLastDiscoveredGatewayStableID(), !stored.isEmpty {
defaults.set(stored, forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
}
}
}
enum GatewayDiagnostics {
struct ScopedLogger {
private let prefix: String
fileprivate init(prefix: String) {
self.prefix = prefix
}
func stage(_ message: String) {
GatewayDiagnostics.log("\(self.prefix): \(GatewayDiagnostics.sanitizeScopedMessage(message))")
}
func skipped(_ reason: String) {
self.stage("registration skipped reason=\(reason)")
}
func failed(_ stage: String, error: Error) {
let nsError = error as NSError
self
.stage(
"\(stage) failed errorType=\(String(reflecting: type(of: error))) domain=\(nsError.domain) code=\(nsError.code)")
}
}
private static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "GatewayDiag")
private static let queue = DispatchQueue(label: "ai.openclawfoundation.app.gateway.diagnostics")
private static let maxLogBytes: Int64 = 512 * 1024
private static let keepLogBytes: Int64 = 256 * 1024
private static let logSizeCheckEveryWrites = 50
private static let logWritesSinceCheck = OSAllocatedUnfairLock(initialState: 0)
private static let maxScopedMessageCharacters = 320
/// Keep relay diagnostics stage-based. Push tokens, relay grants, proofs,
/// receipts, signed payloads, and handles must never enter this cache log.
static let pushRelay = ScopedLogger(prefix: "push relay")
private static func sanitizeScopedMessage(_ value: String) -> String {
let collapsed = value
.replacingOccurrences(of: "\r", with: " ")
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\t", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard collapsed.count > self.maxScopedMessageCharacters else {
return collapsed
}
let end = collapsed.index(collapsed.startIndex, offsetBy: self.maxScopedMessageCharacters)
return String(collapsed[..<end]) + "..."
}
private static func isoTimestamp() -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: Date())
}
private static var fileURL: URL? {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("openclaw-gateway.log")
}
private static func truncateLogIfNeeded(url: URL) {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let sizeNumber = attrs[.size] as? NSNumber
else { return }
let size = sizeNumber.int64Value
guard size > self.maxLogBytes else { return }
do {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
let start = max(Int64(0), size - self.keepLogBytes)
try handle.seek(toOffset: UInt64(start))
var tail = try handle.readToEnd() ?? Data()
// If we truncated mid-line, drop the first partial line so logs remain readable.
if start > 0, let nl = tail.firstIndex(of: 10) {
let next = tail.index(after: nl)
if next < tail.endIndex {
tail = tail.suffix(from: next)
} else {
tail = Data()
}
}
try tail.write(to: url, options: .atomic)
} catch {
// Best-effort only.
}
}
private static func appendToLog(url: URL, data: Data) {
if FileManager.default.fileExists(atPath: url.path) {
if let handle = try? FileHandle(forWritingTo: url) {
defer { try? handle.close() }
_ = try? handle.seekToEnd()
try? handle.write(contentsOf: data)
}
} else {
try? data.write(to: url, options: .atomic)
}
}
private static func applyFileProtection(url: URL) {
try? FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: url.path)
}
static func bootstrap() {
guard let url = fileURL else { return }
self.queue.async {
self.truncateLogIfNeeded(url: url)
let timestamp = self.isoTimestamp()
let line = "[\(timestamp)] gateway diagnostics started\n"
if let data = line.data(using: .utf8) {
self.appendToLog(url: url, data: data)
self.applyFileProtection(url: url)
}
}
}
static func log(_ message: String) {
let timestamp = self.isoTimestamp()
let line = "[\(timestamp)] \(message)"
self.logger.info("\(line, privacy: .public)")
guard let url = fileURL else { return }
self.queue.async {
let shouldTruncate = self.logWritesSinceCheck.withLock { count in
count += 1
if count >= self.logSizeCheckEveryWrites {
count = 0
return true
}
return false
}
if shouldTruncate {
self.truncateLogIfNeeded(url: url)
}
let entry = line + "\n"
if let data = entry.data(using: .utf8) {
self.appendToLog(url: url, data: data)
}
}
}
}

View File

@@ -0,0 +1,45 @@
import SwiftUI
struct GatewayTrustPromptAlert: ViewModifier {
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
func body(content: Content) -> some View {
content.alert(
"Trust this gateway?",
isPresented: Binding(
get: { self.gatewayController.pendingTrustPrompt != nil },
set: { _ in
// Keep pending trust state until explicit user action.
// SwiftUI may set presentation bindings during dismissal; clearing here can
// race with the trust button and make accept no-op.
}),
presenting: self.gatewayController.pendingTrustPrompt)
{ _ in
Button(role: .cancel) {
self.gatewayController.declinePendingTrustPrompt()
} label: {
Text("Cancel")
.font(OpenClawType.subheadSemiBold)
}
Button {
Task { await self.gatewayController.acceptPendingTrustPrompt() }
} label: {
Text("Trust and connect")
.font(OpenClawType.subheadSemiBold)
}
} message: { prompt in
Text(String(
format: NSLocalizedString(
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint out-of-band before trusting:\n%@",
comment: "Gateway certificate trust instructions"),
prompt.fingerprintSha256))
.font(OpenClawType.subhead)
}
}
}
extension View {
func gatewayTrustPromptAlert() -> some View {
self.modifier(GatewayTrustPromptAlert())
}
}

View File

@@ -0,0 +1,16 @@
import Foundation
import OpenClawKit
enum KeychainStore {
static func loadString(service: String, account: String) -> String? {
GenericPasswordKeychainStore.loadString(service: service, account: account)
}
static func saveString(_ value: String, service: String, account: String) -> Bool {
GenericPasswordKeychainStore.saveString(value, service: service, account: account)
}
static func delete(service: String, account: String) -> Bool {
GenericPasswordKeychainStore.delete(service: service, account: account)
}
}

View File

@@ -0,0 +1,105 @@
import SwiftUI
private struct NotificationPermissionGuidanceDialogModifier: ViewModifier {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
let openNotifications: (String) -> Void
func body(content: Content) -> some View {
content
.overlay {
if let prompt = self.appModel.pendingNotificationPermissionGuidancePrompt {
ZStack {
Color.black.opacity(0.38)
.ignoresSafeArea()
NotificationPermissionGuidanceCard(
onOpenNotifications: {
let approvalId = prompt.approvalId
self.appModel.dismissNotificationPermissionGuidancePrompt(
suppressFuture: false)
self.openNotifications(approvalId)
},
onDismiss: {
self.appModel.dismissNotificationPermissionGuidancePrompt(
suppressFuture: false)
},
onSuppressFuture: {
self.appModel.dismissNotificationPermissionGuidancePrompt(
suppressFuture: true)
})
.padding(.horizontal, 20)
.frame(maxWidth: 460)
.transition(.scale(scale: 0.98).combined(with: .opacity))
}
.zIndex(2)
.id(prompt.id)
}
}
.animation(
.easeInOut(duration: 0.18),
value: self.appModel.pendingNotificationPermissionGuidancePrompt?.id)
}
}
private struct NotificationPermissionGuidanceCard: View {
let onOpenNotifications: () -> Void
let onDismiss: () -> Void
let onSuppressFuture: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("Notifications are off")
.font(OpenClawType.headline)
Text(
"""
Exec approvals can only be reviewed while OpenClaw is open and connected.
Enable Notifications to receive approval notifications while OpenClaw is not open.
""")
.font(OpenClawType.subhead)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
VStack(spacing: 10) {
Button {
self.onOpenNotifications()
} label: {
Text("Open Notifications Settings")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button(role: .cancel) {
self.onDismiss()
} label: {
Text("Not Now")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button {
self.onSuppressFuture()
} label: {
Text("Don't show again")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
.controlSize(.large)
.frame(maxWidth: .infinity)
}
.padding(18)
.proPanelSurface(tint: OpenClawBrand.warn, radius: 20, isProminent: true)
}
}
extension View {
func notificationPermissionGuidanceDialog(openNotifications: @escaping (String) -> Void) -> some View {
modifier(NotificationPermissionGuidanceDialogModifier(openNotifications: openNotifications))
}
}

View File

@@ -0,0 +1,42 @@
import Foundation
import Network
import os
enum TCPProbe {
static func probe(host: String, port: Int, timeoutSeconds: Double, queueLabel: String) async -> Bool {
guard port >= 1, port <= 65535 else { return false }
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false }
let endpointHost = NWEndpoint.Host(host)
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
return await withCheckedContinuation { cont in
let queue = DispatchQueue(label: queueLabel)
let finished = OSAllocatedUnfairLock(initialState: false)
let finish: @Sendable (Bool) -> Void = { ok in
let shouldResume = finished.withLock { flag -> Bool in
if flag { return false }
flag = true
return true
}
guard shouldResume else { return }
connection.cancel()
cont.resume(returning: ok)
}
connection.stateUpdateHandler = { state in
switch state {
case .ready:
finish(true)
case .failed, .cancelled:
finish(false)
default:
break
}
}
connection.start(queue: queue)
queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
}
}
}