import CoreLocation import OpenClawKit import SwiftUI import UIKit import UserNotifications extension SettingsProTab { func detailStatusCard( icon: String, title: String, detail: String, value: String, color: Color) -> some View { Section { HStack(spacing: 12) { SettingsIcon(systemName: icon, color: color) VStack(alignment: .leading, spacing: 2) { Text(title) .font(OpenClawType.headline) Text(detail) .font(OpenClawType.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 8) Text(value) .font(OpenClawType.subheadMedium) .foregroundStyle(color) } } } var diagnosticChecksCard: some View { Section("Checks") { self.diagnosticCheckRow( icon: "stethoscope", title: "Last Run", detail: self.diagnosticsLastRunText, value: self.diagnosticsRunValue, color: self.diagnosticsRunColor) self.diagnosticCheckRow( icon: "antenna.radiowaves.left.and.right", title: "Gateway Link", detail: self.gatewayStatusDetail, value: self.gatewayStatusValue, color: self.gatewayStatusColor) self.diagnosticCheckRow( icon: "dot.radiowaves.left.and.right", title: "Discovery", detail: self.gatewayController.discoveryStatusText, value: "\(self.gatewayController.gateways.count)", color: self.gatewayController.gateways.isEmpty ? .secondary : OpenClawBrand.accent) self.diagnosticCheckRow( icon: "waveform", title: "Talk Config", detail: self.gatewayTalkConfigDetail, value: self.gatewayTalkConfigValue, color: self.gatewayTalkConfigColor) self.diagnosticCheckRow( icon: "bell", title: "Notifications", detail: "Approval and event alert channel", value: self.notificationStatusText, color: self.notificationStatus.color) self.diagnosticCheckRow( icon: "rectangle.on.rectangle", title: "Screen Capture", detail: "Live foreground capture state", value: self.appModel.screenRecordActive ? "live" : "idle", color: self.appModel.screenRecordActive ? OpenClawBrand.ok : .secondary) self.diagnosticCheckRow( icon: "mic", title: "Voice Wake", detail: self.appModel.voiceWake.statusText, value: self.voiceWakeEnabled ? "on" : "off", color: self.voiceWakeEnabled ? OpenClawBrand.ok : .secondary) } } func diagnosticCheckRow( icon: String, title: String, detail: String, value: String, color: Color) -> some View { HStack(spacing: 12) { SettingsIcon(systemName: icon, color: color) VStack(alignment: .leading, spacing: 2) { Text(title) .font(OpenClawType.subheadSemiBold) Text(detail) .font(OpenClawType.caption) .foregroundStyle(.secondary) .lineLimit(1) } Spacer(minLength: 8) Text(value) .font(OpenClawType.subhead) .foregroundStyle(.secondary) } } func detailListCard(@ViewBuilder content: () -> some View) -> some View { Section { content() } } func detailRow(_ label: String, value: String) -> some View { LabeledContent(label) { Text(value) .font(OpenClawType.subhead) .lineLimit(1) .truncationMode(.middle) } } func reconnectGateway() async { guard !self.appModel.isAppleReviewDemoModeEnabled else { return } guard !self.isReconnectingGateway else { return } self.isReconnectingGateway = true defer { self.isReconnectingGateway = false } await self.gatewayController.connectLastKnown() } @MainActor func runDiagnostics() async { guard !self.isRefreshingGateway else { return } self.isRefreshingGateway = true defer { self.isRefreshingGateway = false } if !self.appModel.isAppleReviewDemoModeEnabled { self.gatewayController.refreshActiveGatewayRegistrationFromSettings() self.gatewayController.restartDiscovery() await self.appModel.refreshGatewayOverviewIfConnected() } let notificationSettings = await UNUserNotificationCenter.current().notificationSettings() self.applyNotificationStatus(notificationSettings.authorizationStatus) self.registerForRemoteNotificationsIfEnrollmentReady() let issueCount = SettingsDiagnostics.issueCount( gatewayConnected: self.gatewayDiagnosticConnected, discoveredGatewayCount: self.gatewayController.gateways.count, talkConfigLoaded: self.gatewayDiagnosticTalkConfigLoaded, notificationsAllowed: self.notificationStatus == .allowed) self.diagnosticsIssueCount = issueCount self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date()) } func syncSettingsState() { self.manualGatewayPortText = self.manualGatewayPort > 0 ? String(self.manualGatewayPort) : "" self.selectedAgentPickerId = self.appModel.selectedAgentId ?? "" self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction() self.refreshLocationPermissionSummary() let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedInstanceId.isEmpty else { return } self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? "" self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? "" } func refreshLocationPermissionSummary(desiredMode modeOverride: OpenClawLocationMode? = nil) { let mode = modeOverride ?? OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off let manager = CLLocationManager() self.locationPermissionRefreshID &+= 1 let refreshID = self.locationPermissionRefreshID let currentSummary = self.locationPermissionSummary self.locationPermissionSummary = LocationPermissionSummary( desiredMode: mode, locationServicesEnabled: currentSummary.locationServicesEnabled, authorizationStatus: manager.authorizationStatus, accuracyAuthorization: manager.accuracyAuthorization) Task { let locationServicesEnabled = await Self.locationServicesEnabled() guard refreshID == self.locationPermissionRefreshID else { return } let latestManager = CLLocationManager() let latestMode = modeOverride ?? OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off self.locationPermissionSummary = LocationPermissionSummary( desiredMode: latestMode, locationServicesEnabled: locationServicesEnabled, authorizationStatus: latestManager.authorizationStatus, accuracyAuthorization: latestManager.accuracyAuthorization) } } private static func locationServicesEnabled() async -> Bool { await Task.detached(priority: .utility) { CLLocationManager.locationServicesEnabled() }.value } func syncAfterOnboardingReset() { self.connectingGatewayID = nil self.setupStatusText = nil self.stagedGatewaySetupLink = nil self.pendingManualAuthOverride = nil self.syncSettingsState() } func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { self.connectingGatewayID = gateway.id defer { self.connectingGatewayID = nil } self.manualGatewayEnabled = false GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID) GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID) if let err = await self.gatewayController.connectWithDiagnostics(gateway) { self.setupStatusText = err } } func applySetupCodeAndConnect() async { self.setupStatusText = nil guard self.applySetupCode() else { return } let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard self.resolvedManualPort(host: host) != nil else { self.setupStatusText = "Failed: invalid port" return } guard await self.preflightGateway(host: host) else { return } self.setupStatusText = "Setup code applied. Connecting..." await self.connectManual() } func applyPendingGatewaySetupLinkIfNeeded() { guard let link = self.appModel.consumePendingGatewaySetupLink() else { return } self.setupCode = "" self.setupStatusText = nil self.stagedGatewaySetupLink = link let security = link.tls ? "TLS" : "plain" self.setupStatusText = "Setup link loaded for \(link.host):\(link.port) (\(security)). Tap Connect to apply." } @discardableResult func applySetupCode() -> Bool { let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines) let stagedLink = self.stagedGatewaySetupLink guard !raw.isEmpty || stagedLink != nil else { self.setupStatusText = "Paste a setup code to continue." return false } if AppleReviewDemoMode.isSetupCode(raw) { self.stagedGatewaySetupLink = nil self.setupCode = "" self.setupStatusText = "Apple Review demo mode enabled." self.appModel.enterAppleReviewDemoMode() return false } guard let link = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else { self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL." return false } self.stagedGatewaySetupLink = nil self.applyGatewayLink(link) return true } func applyGatewayLink(_ link: GatewayConnectDeepLink) { self.manualGatewayHost = link.host self.manualGatewayPort = link.port self.manualGatewayPortText = String(link.port) self.manualGatewayTLS = link.tls let instanceId = GatewaySettingsStore.currentInstanceID() let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link) if setupAuth.hasBootstrapToken { GatewayOnboardingReset.prepareForBootstrapPairing(appModel: self.appModel, instanceId: instanceId) } if !instanceId.isEmpty { GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: instanceId) } if setupAuth.shouldApplyTokenField { self.gatewayToken = setupAuth.token if !instanceId.isEmpty { GatewaySettingsStore.saveGatewayToken(setupAuth.token, instanceId: instanceId) } } if setupAuth.shouldApplyPasswordField { self.gatewayPassword = setupAuth.password if !instanceId.isEmpty { GatewaySettingsStore.saveGatewayPassword(setupAuth.password, instanceId: instanceId) } } self.pendingManualAuthOverride = setupAuth.manualAuthOverride } func openGatewayQRScanner() { self.appModel.disconnectGateway() self.connectingGatewayID = nil self.setupStatusText = "Opening QR scanner..." self.showQRScanner = true } func handleScannedGatewayLink(_ link: GatewayConnectDeepLink) { self.showQRScanner = false self.setupCode = "" self.applyGatewayLink(link) self.setupStatusText = "QR loaded. Connecting to \(link.host):\(link.port)..." Task { await self.connectAfterScannedGatewayLink() } } func handleScannedSetupCode(_ code: String) { guard AppleReviewDemoMode.isSetupCode(code) else { return } self.showQRScanner = false self.setupCode = "" self.stagedGatewaySetupLink = nil self.setupStatusText = "Apple Review demo mode enabled." self.appModel.enterAppleReviewDemoMode() } func connectAfterScannedGatewayLink() async { let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard self.resolvedManualPort(host: host) != nil else { self.setupStatusText = "Failed: invalid port" return } guard await self.preflightGateway(host: host) else { return } await self.connectManual() } func connectManual() async { let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty else { self.setupStatusText = "Failed: host required" return } guard self.manualPortIsValid else { self.setupStatusText = "Failed: invalid port" return } self.connectingGatewayID = "manual" self.manualGatewayEnabled = true defer { self.connectingGatewayID = nil } let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput( token: self.gatewayToken, pendingOverride: self.pendingManualAuthOverride, password: self.gatewayPassword) self.pendingManualAuthOverride = nil await self.gatewayController.connectManual( host: host, port: self.manualGatewayPort, useTLS: self.manualGatewayTLS, authOverride: authOverride) } func preflightGateway(host: String) async -> Bool { let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() { self.setupStatusText = "Tailscale is off on this device. Turn it on, then try again." return false } self.gatewayController.requestLocalNetworkAccess(reason: "settings_preflight") return true } func resetOnboarding() { self.connectingGatewayID = nil self.setupStatusText = nil self.setupCode = "" self.gatewayAutoConnect = false self.suppressCredentialPersist = true defer { self.suppressCredentialPersist = false } self.gatewayToken = "" self.gatewayPassword = "" GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId) self.onboardingComplete = false self.hasConnectedOnce = false self.manualGatewayEnabled = false self.manualGatewayHost = "" self.onboardingRequestID += 1 } func handleLocationModeChange(_ newValue: String) { guard !self.isChangingLocationMode else { return } guard newValue != self.previousLocationModeRaw else { return } guard let mode = OpenClawLocationMode(rawValue: newValue) else { return } let previous = self.previousLocationModeRaw Task { await self.applyLocationMode(mode, rawValue: newValue, previous: previous) } } @MainActor func applyLocationMode( _ mode: OpenClawLocationMode, rawValue: String, previous: String) async { self.isChangingLocationMode = true self.locationStatusText = nil self.refreshLocationPermissionSummary(desiredMode: mode) defer { self.isChangingLocationMode = false } if mode == .off { _ = await self.appModel.requestLocationPermissions(mode: mode) self.previousLocationModeRaw = rawValue self.refreshLocationPermissionSummary(desiredMode: mode) self.gatewayController.refreshActiveGatewayRegistrationFromSettings() return } let granted = await self.appModel.requestLocationPermissions(mode: mode) self.refreshLocationPermissionSummary(desiredMode: mode) if granted { self.previousLocationModeRaw = rawValue self.gatewayController.refreshActiveGatewayRegistrationFromSettings() } else { self.locationModeRaw = previous self.previousLocationModeRaw = previous self.locationStatusText = "Location permission was not granted." self.refreshLocationPermissionSummary( desiredMode: OpenClawLocationMode(rawValue: previous) ?? .off) } } func refreshNotificationSettings() { UNUserNotificationCenter.current().getNotificationSettings { settings in let status = settings.authorizationStatus Task { @MainActor in self.applyNotificationStatus(status) self.registerForRemoteNotificationsIfEnrollmentReady() } } } func handleNotificationAction() { if self.notificationStatus.shouldOpenNotificationSettings { self.openNotificationSettings() return } guard self.notificationStatus == .notSet else { return } if PushBuildConfig.current.usesOpenClawHostedRelay { self.showNotificationRelayDisclosure = true return } self.requestNotificationAuthorizationFromSettings() } func requestNotificationAuthorizationFromSettings() { guard !self.isRequestingNotificationAuthorization else { return } PushEnrollmentConsent.markDisclosureAccepted() self.isRequestingNotificationAuthorization = true Task { let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [ .alert, .badge, .sound, ])) ?? false let settings = await UNUserNotificationCenter.current().notificationSettings() await MainActor.run { self.isRequestingNotificationAuthorization = false self.notificationStatus = SettingsNotificationStatus(settings.authorizationStatus) guard granted else { return } self.registerForRemoteNotificationsIfEnrollmentReady() } } } @MainActor func registerForRemoteNotificationsIfEnrollmentReady() { guard PushEnrollmentConsent.disclosureAccepted else { return } guard self.notificationStatus.allowsNotifications else { return } UIApplication.shared.registerForRemoteNotifications() } @MainActor func applyNotificationStatus(_ status: UNAuthorizationStatus) { self.notificationStatus = SettingsNotificationStatus(status) } func persistGatewayToken(_ value: String) { guard !self.suppressCredentialPersist else { return } let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !instanceId.isEmpty else { return } GatewaySettingsStore.saveGatewayToken( value.trimmingCharacters(in: .whitespacesAndNewlines), instanceId: instanceId) } func persistGatewayPassword(_ value: String) { guard !self.suppressCredentialPersist else { return } let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !instanceId.isEmpty else { return } GatewaySettingsStore.saveGatewayPassword( value.trimmingCharacters(in: .whitespacesAndNewlines), instanceId: instanceId) } func openNotificationSettings() { guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else { return } UIApplication.shared.open(url) } func title(for route: SettingsRoute) -> String { switch route { case .gateway: "Gateway" case .approvals: "Approvals" case .permissions: "Permissions" case .channels: "Channels" case .voice: "Voice & Talk" case .diagnostics: "Diagnostics" case .privacy: "Privacy" case .notifications: "Notifications" case .licenses: "Licenses" case .about: "About" } } var manualPortBinding: Binding { Binding( get: { self.manualGatewayPortText }, set: { newValue in let filtered = newValue.filter(\.isNumber) self.manualGatewayPortText = filtered self.manualGatewayPort = Int(filtered) ?? 0 }) } var manualPortIsValid: Bool { if self.manualGatewayPortText.isEmpty { return true } return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535 } func resolvedManualPort(host: String) -> Int? { if self.manualGatewayPort > 0 { return self.manualGatewayPort <= 65535 ? self.manualGatewayPort : nil } let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } if self.manualGatewayTLS, trimmed.lowercased().hasSuffix(".ts.net") { return 443 } return 18789 } var setupStatusLine: String? { if let problem = self.appModel.lastGatewayProblem { return problem.message } let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly } if let friendly = self.friendlyGatewayMessage(from: trimmedSetup) { return friendly } if self.isTransientSetupStatus(trimmedSetup), !gatewayStatus.isEmpty, gatewayStatus != "Offline" { return gatewayStatus } if !trimmedSetup.isEmpty { return trimmedSetup } if gatewayStatus.isEmpty || gatewayStatus == "Offline" { return nil } return gatewayStatus } var canApplyGatewaySetup: Bool { !self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || self.stagedGatewaySetupLink != nil } var tailnetWarningText: String? { let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty, Self.isTailnetHostOrIP(host), !Self.hasTailnetIPv4() else { return nil } return "This gateway is on your tailnet. Turn on Tailscale on this device, then tap Connect." } func friendlyGatewayMessage(from raw: String) -> String? { let lower = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if lower.contains("pairing required") { return "Pairing required. Run /pair approve in your OpenClaw chat, then connect again." } if lower.contains("device nonce required") || lower.contains("device nonce mismatch") { return "Secure handshake failed. Check Tailscale, then connect again." } if lower.contains("tls fingerprint verification timed out") || lower.contains("no tls endpoint detected") { return raw.trimmingCharacters(in: .whitespacesAndNewlines) } if lower.contains("timed out") { return "Connection timed out. Make sure Tailscale is connected, then try again." } if lower.contains("unauthorized role") { return "Connected, but some controls are restricted for nodes. This is expected." } return nil } func isTransientSetupStatus(_ raw: String) -> Bool { let lower = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return lower == "setup code applied. connecting..." || lower.hasPrefix("qr loaded. connecting to ") || lower == "checking gateway reachability..." } var shouldShowRealtimeVoicePicker: Bool { let providerSelection = TalkModeProviderSelection.resolved(self.talkProviderSelectionRaw) return providerSelection == .openAIRealtime || self.appModel.talkMode.gatewayTalkUsesRealtime } var talkProviderSelectionBinding: Binding { Binding( get: { self.talkProviderSelectionRaw }, set: { newValue in let selection = TalkModeProviderSelection.resolved(newValue) self.talkProviderSelectionRaw = selection.rawValue self.appModel.setTalkProviderSelection(selection.rawValue) }) } var talkRealtimeVoiceSelectionBinding: Binding { Binding( get: { self.talkRealtimeVoiceSelectionRaw }, set: { newValue in let voice = TalkModeRealtimeVoiceSelection.resolvedOverride(newValue) ?? "" self.talkRealtimeVoiceSelectionRaw = voice self.appModel.setTalkRealtimeVoiceSelection(voice) }) } var talkSpeakerphoneBinding: Binding { Binding( get: { self.talkSpeakerphoneEnabled }, set: { newValue in self.talkSpeakerphoneEnabled = newValue self.appModel.setTalkSpeakerphoneEnabled(newValue) }) } var talkApiKeyStatus: String { guard self.appModel.talkMode.gatewayTalkConfigLoaded else { return "Not loaded" } return self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured" } var gatewayTalkActiveVoiceDetail: String { let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines) let subtitle = (self.appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) if title.isEmpty { return "Not active" } if subtitle.isEmpty { return title } return "\(title) • \(subtitle)" } var gatewayTalkLastIssueDetail: String? { let detail = (self.appModel.talkMode.gatewayTalkLastIssueText ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) return detail.isEmpty ? nil : detail } func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] { var lines: [String] = [] if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") } if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") } let gw = gateway.gatewayPort.map(String.init) let canvas = gateway.canvasPort.map(String.init) if gw != nil || canvas != nil { lines.append("Ports: gateway \(gw ?? "-") / canvas \(canvas ?? "-")") } return lines.isEmpty ? [gateway.debugID] : lines } var gatewayConnected: Bool { !self.appModel.isAppleReviewDemoModeEnabled && GatewayStatusBuilder.build(appModel: self.appModel) == .connected } var gatewayStatusDetail: String { if self.appModel.isAppleReviewDemoModeEnabled { return "Apple Review demo mode" } return self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText } var gatewayStatusValue: String { if self.appModel.isAppleReviewDemoModeEnabled { return "demo" } return self.gatewayConnected ? "online" : "offline" } var gatewayStatusColor: Color { if self.appModel.isAppleReviewDemoModeEnabled { return OpenClawBrand.accent } return self.gatewayConnected ? OpenClawBrand.ok : .secondary } var gatewayDiagnosticConnected: Bool { self.appModel.isAppleReviewDemoModeEnabled || self.gatewayConnected } var gatewayDiagnosticTalkConfigLoaded: Bool { self.appModel.isAppleReviewDemoModeEnabled || self.appModel.talkMode.gatewayTalkConfigLoaded } var approvalEmptyDetail: String { if self.appModel.isAppleReviewDemoModeEnabled { return "Live gateway requests are disabled in demo mode." } if self.notificationsNeedAttention { return "Foreground approvals still appear while OpenClaw is connected." } return self.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway." } var gatewayTalkConfigDetail: String { if self.appModel.isAppleReviewDemoModeEnabled { return "Demo mode only" } return self.appModel.talkMode.gatewayTalkTransportLabel } var gatewayTalkConfigValue: String { if self.appModel.isAppleReviewDemoModeEnabled { return "demo" } return self.appModel.talkMode.gatewayTalkConfigLoaded ? "loaded" : "missing" } var gatewayTalkConfigColor: Color { if self.appModel.isAppleReviewDemoModeEnabled { return .secondary } return self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary } var gatewayAddress: String { self.appModel.gatewayRemoteAddress ?? "Waiting for gateway" } var gatewayServer: String { self.appModel.gatewayServerName ?? "OpenClaw Gateway" } var pendingApproval: NodeAppModel.ExecApprovalPrompt? { self.appModel.pendingExecApprovalPrompt } var notificationsNeedAttention: Bool { switch self.notificationStatus { case .allowed, .checking: false case .notAllowed, .notSet, .unknown: true } } var approvalItems: [SettingsApprovalItem] { guard let pendingApproval else { return [] } return [ SettingsApprovalItem( id: "pending-real", icon: "terminal.fill", title: pendingApproval.commandPreview ?? "Review gateway action", detail: "Agent: \(self.appModel.activeAgentName)", priority: self.appModel.pendingExecApprovalPromptResolving ? "Resolving" : "High", color: OpenClawBrand.danger), SettingsApprovalItem( id: "pending-context", icon: "doc.text.fill", title: pendingApproval.allowsAllowAlways ? "Permission can be saved" : "One-time approval", detail: "Gateway request", priority: pendingApproval.allowsAllowAlways ? "Medium" : "Review", color: OpenClawBrand.warn), ] } var voiceDetail: String { if self.talkEnabled, self.voiceWakeEnabled { return "Talk + Wake" } if self.talkEnabled { return "Talk on" } if self.voiceWakeEnabled { return "Wake on" } return "Off" } var diagnosticsHealthValue: String { if self.appModel.isAppleReviewDemoModeEnabled { return "demo" } if self.gatewayConnected { return "ready" } if self.gatewayController.gateways.isEmpty { return "check" } return "partial" } var diagnosticsRunValue: String { guard let diagnosticsIssueCount else { return "pending" } return diagnosticsIssueCount == 0 ? "pass" : "\(diagnosticsIssueCount)" } var diagnosticsRunColor: Color { guard let diagnosticsIssueCount else { return .secondary } return diagnosticsIssueCount == 0 ? OpenClawBrand.ok : OpenClawBrand.warn } var privacyDetail: String { let location = OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off return switch (location, self.locationPermissionSummary.effectiveMode) { case (.off, _): "Location off" case (.whileUsing, .whileUsing): "Location While Using" case (.whileUsing, .off): "Location While Using, effective Off" case (.whileUsing, .always): "Location While Using, effective Always" case (.always, .always): "Location Always" case (.always, .whileUsing): "Location Always, effective While Using" case (.always, .off): "Location Always, effective Off" } } var locationPermissionDetailText: String { if self.isChangingLocationMode { return "Requesting iOS location permission…" } return self.locationPermissionSummary.detailText } var locationPermissionWarningText: String? { guard let locationStatusText else { return nil } guard locationStatusText != self.locationPermissionSummary.detailText else { return nil } return locationStatusText } var notificationStatusText: String { self.notificationStatus.text } var notificationActionText: String { self.notificationStatus.actionTitle } var notificationStatusDetail: String { switch self.notificationStatus { case .checking: "Checking iOS notification permission." case .allowed: "OpenClaw can show approval prompts and event alerts when the app is not active." case .notAllowed: "Notifications have been denied. Enable them in iOS Settings." case .notSet: "Enable notifications to receive approval prompts and event alerts outside the app." case .unknown: "OpenClaw cannot determine the current notification permission state." } } var notificationRelayDetail: String { if PushBuildConfig.current.usesOpenClawHostedRelay { let host = PushBuildConfig.current.relayBaseURL.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false)?.host } ?? "ios-push-relay.openclaw.ai" return """ This build uses OpenClaw's hosted push relay at \(host) for notification \ delivery data. """ } return "This build is not configured to use OpenClaw's hosted push relay." } var notificationRelayDisclosureMessage: String { "Enabling this sends delivery data through OpenClaw's hosted push relay." } }