import CoreLocation import OpenClawKit import SwiftUI import UIKit import UserNotifications extension SettingsProTab { func detailStatusCard( icon: String, title: OpenClawTextValue, detail: OpenClawTextValue, value: OpenClawTextValue, color: Color, actionTitle: LocalizedStringKey? = nil, actionSystemImage: String = "arrow.right", action: (() -> Void)? = nil) -> some View { Section { HStack(spacing: 12) { SettingsIcon(systemName: icon, color: color) VStack(alignment: .leading, spacing: 2) { title.text .font(OpenClawType.headline) detail.text .font(OpenClawType.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 8) value.text .font(OpenClawType.subheadMedium) .foregroundStyle(color) } if let action, let actionTitle { Button(action: action) { Label(actionTitle, systemImage: actionSystemImage) .font(OpenClawType.subheadSemiBold) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .tint(OpenClawBrand.accent) } } } var diagnosticChecksCard: some View { Section("Checks") { self.diagnosticCheckRow( icon: "stethoscope", title: "Last Run", detail: .verbatim(self.diagnosticsLastRunText), value: .verbatim(self.diagnosticsRunValue), color: self.diagnosticsRunColor) self.diagnosticCheckRow( icon: "antenna.radiowaves.left.and.right", title: "Gateway Link", detail: .verbatim(self.gatewayStatusDetail), value: .verbatim(self.gatewayStatusValue), color: self.gatewayStatusColor) self.diagnosticCheckRow( icon: "dot.radiowaves.left.and.right", title: "Discovery", detail: .verbatim(self.gatewayController.discoveryStatusText), value: .verbatim(self.gatewayController.gateways.count.formatted()), color: self.gatewayController.gateways.isEmpty ? .secondary : OpenClawBrand.accent) self.diagnosticCheckRow( icon: "waveform", title: "Talk Config", detail: .verbatim(self.gatewayTalkConfigDetail), value: .verbatim(self.gatewayTalkConfigValue), color: self.gatewayTalkConfigColor) self.diagnosticCheckRow( icon: "bell", title: "Notifications", detail: "Approval and event alert channel", value: .verbatim(self.notificationStatusText), color: self.notificationStatusColor) self.diagnosticCheckRow( icon: "rectangle.on.rectangle", title: "Screen Capture", detail: "Live foreground capture state", value: .verbatim(self.appModel.screenRecordActive ? String(localized: "live") : String(localized: "idle")), color: self.appModel.screenRecordActive ? OpenClawBrand.ok : .secondary) self.diagnosticCheckRow( icon: "mic", title: "Voice Wake", detail: .verbatim(self.appModel.voiceWake.statusText), value: .verbatim(self.voiceWakeEnabled ? String(localized: "on") : String(localized: "off")), color: self.voiceWakeEnabled ? OpenClawBrand.ok : .secondary) } } func diagnosticCheckRow( icon: String, title: OpenClawTextValue, detail: OpenClawTextValue, value: OpenClawTextValue, color: Color) -> some View { HStack(spacing: 12) { SettingsIcon(systemName: icon, color: color) VStack(alignment: .leading, spacing: 2) { title.text .font(OpenClawType.subheadSemiBold) detail.text .font(OpenClawType.caption) .foregroundStyle(.secondary) .lineLimit(1) } Spacer(minLength: 8) value.text .font(OpenClawType.subhead) .foregroundStyle(.secondary) } } func detailListCard(@ViewBuilder content: () -> some View) -> some View { Section { content() } } func reconnectGateway() async { guard !self.appModel.isAppleReviewDemoModeEnabled else { return } guard !self.isReconnectingGateway else { return } self.isReconnectingGateway = true defer { self.isReconnectingGateway = false } await self.gatewayController.connectActiveGateway() } func switchGateway(to entry: GatewaySettingsStore.GatewayRegistryEntry) async { guard self.connectingGateway == nil else { return } self.connectingGateway = .gateway(entry.id) self.setupStatusText = String( format: String(localized: "Switching to %@…"), entry.name) defer { self.connectingGateway = nil self.refreshGatewayRegistry() } if let failure = await self.gatewayController.switchToGateway(stableID: entry.stableID) { self.setupStatusText = failure return } self.selectGatewayCredentialTarget(entry.stableID, allowManualOverride: false) } func forgetPendingGateway() { guard let entry = self.pendingForgetGateway else { return } self.pendingForgetGateway = nil guard self.gatewayController.forgetGateway(stableID: entry.stableID) else { self.setupStatusText = String( format: String(localized: "Could not forget %@."), entry.name) self.refreshGatewayRegistry() return } if GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, entry.stableID) { self.clearManualCredentialFields() } self.setupStatusText = String( format: String(localized: "Forgot %@."), entry.name) self.refreshGatewayRegistry() } func refreshGatewayRegistry() { self.gatewayRegistry = GatewaySettingsStore.loadGatewayRegistry() } func gatewayEndpointSummary(_ entry: GatewaySettingsStore.GatewayRegistryEntry) -> String { switch entry.kind { case .manual: let endpoint = if let host = entry.host, let port = entry.port { "\(host):\(port)" } else { String(localized: "Saved endpoint unavailable") } return entry.useTLS ? "\(endpoint) • TLS" : endpoint case .discovered: return entry.useTLS ? String(localized: "Discovered • TLS") : String(localized: "Discovered") } } @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.notificationServingActive) self.diagnosticsIssueCount = issueCount self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date()) } func syncSettingsState() { self.refreshGatewayRegistry() 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 } guard let stableID = self.currentManualGatewayStableID else { self.gatewayCredentialFieldStableID = nil self.gatewayToken = "" self.gatewayPassword = "" self.pendingManualAuthOverride = nil return } let credentials = GatewaySettingsStore.loadGatewayCredentials( instanceId: trimmedInstanceId, gatewayStableID: stableID) let ownsFields = credentials.hasCredentials || credentials.suppressStoredDeviceAuth self.gatewayCredentialFieldStableID = ownsFields ? stableID : nil self.gatewayToken = credentials.token ?? "" self.gatewayPassword = credentials.password ?? "" self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted( instanceId: trimmedInstanceId, targetStableID: stableID) } 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.invalidateGatewaySetupAttempt() self.setupStatusText = nil self.stagedGatewaySetupLink = nil self.pendingManualAuthOverride = nil self.syncSettingsState() self.pendingTargetSuppression.releaseAutoConnect(controller: self.gatewayController) } func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { let supersededSetupLease = self.takeStagedGatewaySetupSuppression() defer { if let supersededSetupLease { self.gatewayController.resumeAutoConnect(after: supersededSetupLease) } } self.connectingGateway = .gateway(gateway.id) defer { self.connectingGateway = nil self.refreshGatewayRegistry() } self.manualGatewayEnabled = false self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false) GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID) GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID) if let err = await self.gatewayController.connectWithDiagnostics(gateway) { self.setupStatusText = err } } func applySetupCodeAndConnect() async { guard let attemptID = self.beginGatewaySetupAttempt() else { return } defer { self.finishGatewaySetupAttempt(attemptID) self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController) } self.setupStatusText = nil guard await self.applySetupCode(attemptID: attemptID) else { return } let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard self.resolvedManualPort(host: host) != nil else { self.setupStatusText = String(localized: "Failed: invalid port") return } guard await self.preflightGateway(host: host) else { return } self.setupStatusText = String(localized: "Setup code applied. Connecting...") await self.connectManual(setupAttemptID: attemptID) } func applyGatewaySetupLink(_ link: GatewayConnectDeepLink) { // Only the root-selected Gateway destination may destructively claim a // setup link; other Settings views can remain mounted behind onboarding. self.showQRScanner = false self.scannerResultHandoff.cancel() let lease = self.gatewayController.cancelPendingConnectionAttempts() self.pendingTargetSuppression.replace(owner: .setupLink, lease: lease) self.setupCode = "" self.setupStatusText = nil self.stagedGatewaySetupLink = link let security = link.tls ? String(localized: "TLS") : String(localized: "plain") self.setupStatusText = String( format: String( localized: "Setup link loaded for %@:%@ (%@). Tap Connect to apply."), link.host, link.port.formatted(), security) } @discardableResult func applySetupCode(attemptID: UUID) async -> Bool { let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines) let stagedLink = self.stagedGatewaySetupLink guard !raw.isEmpty || stagedLink != nil else { self.setupStatusText = String(localized: "Paste a setup code to continue.") return false } if AppleReviewDemoMode.isSetupCode(raw) { self.stagedGatewaySetupLink = nil self.setupCode = "" self.setupStatusText = String(localized: "Apple Review demo mode enabled.") self.appModel.enterAppleReviewDemoMode() self.pendingTargetSuppression.releaseAutoConnect(.setupLink, controller: self.gatewayController) return false } guard let parsedLink = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else { self.setupStatusText = String( localized: "Setup code not recognized or uses an insecure ws:// gateway URL.") return false } let link = await self.gatewayController.selectReachableSetupLink(parsedLink) guard self.setupAttemptID == attemptID else { return false } self.stagedGatewaySetupLink = nil await self.applyGatewayLink(link) return true } func applyGatewayLink(_ link: GatewayConnectDeepLink) async { 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) self.gatewayCredentialFieldStableID = setupAuth.targetStableID if setupAuth.hasBootstrapToken { await GatewayOnboardingReset.prepareForBootstrapPairing( appModel: self.appModel, instanceId: instanceId, gatewayStableID: setupAuth.targetStableID) } if !instanceId.isEmpty { GatewaySettingsStore.saveGatewayCredentials( token: setupAuth.token, bootstrapToken: setupAuth.bootstrapToken, password: setupAuth.password, gatewayStableID: setupAuth.targetStableID, suppressStoredDeviceAuth: true, instanceId: instanceId) } self.gatewayToken = setupAuth.token self.gatewayPassword = setupAuth.password self.pendingManualAuthOverride = setupAuth.manualAuthOverride } func openGatewayQRScanner() { self.invalidateGatewaySetupAttempt() let lease = self.gatewayController.cancelPendingConnectionAttempts(suspendCurrentGateway: true) self.stagedGatewaySetupLink = nil self.pendingTargetSuppression.replace(owner: .qrScanner, lease: lease) self.scannerScanID = self.scannerResultHandoff.beginScan() self.connectingGateway = nil self.setupStatusText = String(localized: "Opening QR scanner...") self.showQRScanner = true } func queueScannedResult(_ result: QRScannerResult, scanID: UInt64) { guard self.scannerResultHandoff.queue(result, scanID: scanID) else { return } self.setupStatusText = String(localized: "QR loaded. Closing scanner...") self.showQRScanner = false } func processQueuedScannerResult() { let delivery = self.scannerResultHandoff.processAfterDismissal { result in switch result { case let .gatewayLink(link): self.handleScannedGatewayLink(link) case let .setupCode(code): self.handleScannedSetupCode(code) } } if delivery == nil { self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController) } } func handleScannedGatewayLink(_ link: GatewayConnectDeepLink) { self.showQRScanner = false guard let attemptID = self.beginGatewaySetupAttempt() else { return } self.setupCode = "" Task { await self.connectAfterScannedGatewayLink(link, attemptID: attemptID) } } func handleScannedSetupCode(_ code: String) { guard AppleReviewDemoMode.isSetupCode(code) else { return } self.showQRScanner = false self.setupCode = "" self.stagedGatewaySetupLink = nil self.setupStatusText = String(localized: "Apple Review demo mode enabled.") self.appModel.enterAppleReviewDemoMode() self.pendingTargetSuppression.releaseAutoConnect(.qrScanner, controller: self.gatewayController) } func clearStagedGatewaySetupLink() { guard self.stagedGatewaySetupLink != nil else { return } self.stagedGatewaySetupLink = nil self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController) } private func takeStagedGatewaySetupSuppression() -> GatewayConnectionController.AutoConnectSuppressionLease? { self.stagedGatewaySetupLink = nil return self.pendingTargetSuppression.take(ifOwnedBy: .setupLink) } func connectAfterScannedGatewayLink(_ parsedLink: GatewayConnectDeepLink, attemptID: UUID) async { defer { self.finishGatewaySetupAttempt(attemptID) self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController) } let link = await self.gatewayController.selectReachableSetupLink(parsedLink) guard self.setupAttemptID == attemptID else { return } await self.applyGatewayLink(link) self.setupStatusText = String( format: String(localized: "QR loaded. Connecting to %@:%@..."), link.host, link.port.formatted()) let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard self.resolvedManualPort(host: host) != nil else { self.setupStatusText = String(localized: "Failed: invalid port") return } guard await self.preflightGateway(host: host) else { return } await self.connectManual(setupAttemptID: attemptID) } func connectManual(setupAttemptID: UUID? = nil) async { if let setupAttemptID { guard self.setupAttemptID == setupAttemptID else { return } } else { self.invalidateGatewaySetupAttempt() } let supersededSetupLease = self.takeStagedGatewaySetupSuppression() defer { if let supersededSetupLease { self.gatewayController.resumeAutoConnect(after: supersededSetupLease) } } let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty else { self.setupStatusText = String(localized: "Failed: host required") return } guard self.manualPortIsValid else { self.setupStatusText = String(localized: "Failed: invalid port") return } guard let port = self.resolvedManualPort(host: host) else { self.setupStatusText = String(localized: "Failed: invalid port") return } self.connectingGateway = .manual self.manualGatewayEnabled = true defer { self.connectingGateway = nil self.refreshGatewayRegistry() } let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID( host: host, port: port) self.selectGatewayCredentialTarget(stableID, allowManualOverride: true) if GatewayStableIdentifier.matches( self.appModel.activeGatewayConnectConfig?.effectiveStableID, stableID), self.appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true { self.pendingManualAuthOverride = nil } let fieldsMatchTarget = GatewayStableIdentifier.matches( self.gatewayCredentialFieldStableID, stableID) let pendingOverride = GatewayStableIdentifier.matches( self.pendingManualAuthOverride?.targetStableID, stableID) ? self.pendingManualAuthOverride : nil let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput( token: fieldsMatchTarget ? self.gatewayToken : nil, pendingOverride: pendingOverride, password: fieldsMatchTarget ? self.gatewayPassword : nil, targetStableID: stableID) let instanceId = GatewaySettingsStore.currentInstanceID() if !instanceId.isEmpty, fieldsMatchTarget || pendingOverride != nil { GatewaySettingsStore.saveGatewayCredentials( token: authOverride?.token, bootstrapToken: authOverride?.bootstrapToken, password: authOverride?.password, gatewayStableID: stableID, suppressStoredDeviceAuth: authOverride?.suppressStoredDeviceAuth == true, instanceId: instanceId) } await self.gatewayController.connectManual( host: host, port: port, useTLS: self.manualGatewayTLS, authOverride: authOverride) // The controller now owns this attempt's immutable override. A later retry must reload // durable state so a spent bootstrap token cannot be resurrected from the live view. self.pendingManualAuthOverride = nil } 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 = String( localized: "Tailscale is off on this device. Turn it on, then try again.") return false } self.gatewayController.requestLocalNetworkAccess(reason: "settings_preflight") return true } func resetOnboarding() async { self.invalidateGatewaySetupAttempt() self.setupStatusText = nil self.setupCode = "" self.gatewayAutoConnect = false self.suppressCredentialPersist = true defer { self.suppressCredentialPersist = false } self.gatewayToken = "" self.gatewayPassword = "" self.gatewayCredentialFieldStableID = nil self.pendingManualAuthOverride = nil await GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId) self.onboardingComplete = false self.hasConnectedOnce = false self.manualGatewayEnabled = false self.manualGatewayHost = "" self.onboardingRequestID += 1 } func beginGatewaySetupAttempt() -> UUID? { guard self.connectingGateway == nil else { return nil } let attemptID = UUID() self.setupAttemptID = attemptID self.connectingGateway = .setupCode return attemptID } func finishGatewaySetupAttempt(_ attemptID: UUID) { guard self.setupAttemptID == attemptID else { return } self.invalidateGatewaySetupAttempt() } func invalidateGatewaySetupAttempt() { self.setupAttemptID = nil self.connectingGateway = nil } 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.pendingLocationMode = nil self.locationModeRaw = rawValue 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.pendingLocationMode = nil self.locationModeRaw = rawValue self.previousLocationModeRaw = rawValue self.gatewayController.refreshActiveGatewayRegistrationFromSettings() } else { self.locationModeRaw = previous self.previousLocationModeRaw = previous self.refreshLocationPermissionSummary( desiredMode: OpenClawLocationMode(rawValue: previous) ?? .off) let presentation = self.locationSettingsPresentation(selectedMode: mode) self.locationStatusText = presentation.statusText } } var selectedLocationMode: OpenClawLocationMode { OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off } var displayedLocationMode: OpenClawLocationMode { self.pendingLocationMode ?? self.selectedLocationMode } var locationSettingsPresentation: LocationSettingsPresentation { self.locationSettingsPresentation(selectedMode: self.displayedLocationMode) } func locationSettingsPresentation(selectedMode: OpenClawLocationMode) -> LocationSettingsPresentation { var summary = self.locationPermissionSummary summary.desiredMode = selectedMode return LocationSettingsPresentation(selectedMode: selectedMode, summary: summary) } func handleLocationSharingTap() { guard !self.isChangingLocationMode else { return } self.performLocationSettingsAction(self.locationSettingsPresentation.toggleAction()) } func selectLocationAccessLevel(_ mode: OpenClawLocationMode) { guard mode != .off else { return } guard !self.isChangingLocationMode else { return } let presentation = self.locationSettingsPresentation(selectedMode: mode) self.performLocationSettingsAction(presentation.accessLevelAction(mode: mode)) } func performLocationSettingsAction(_ action: LocationSettingsAction) { switch action { case let .setMode(mode): self.setLocationMode(mode) case let .openAppSettings(mode): self.pendingLocationMode = mode self.locationStatusText = self.locationSettingsPresentation(selectedMode: mode).statusText self.openLocationSettings() } } func setLocationMode(_ mode: OpenClawLocationMode) { let rawValue = mode.rawValue let previous = self.previousLocationModeRaw if self.locationModeRaw != rawValue { self.locationModeRaw = rawValue return } Task { await self.applyLocationMode(mode, rawValue: rawValue, previous: previous) } } func applyPendingLocationModeIfAvailable() { guard let mode = self.pendingLocationMode else { return } Task { let locationServicesEnabled = await Self.locationServicesEnabled() let manager = CLLocationManager() let summary = LocationPermissionSummary( desiredMode: mode, locationServicesEnabled: locationServicesEnabled, authorizationStatus: manager.authorizationStatus, accuracyAuthorization: manager.accuracyAuthorization) self.locationPermissionSummary = summary let unavailableStatus = self.locationSettingsPresentation(selectedMode: mode).statusText self.pendingLocationMode = nil guard summary.effectiveMode != .off else { self.locationStatusText = unavailableStatus return } self.setLocationMode(mode) } } func openLocationSettings() { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } func refreshNotificationSettings() { UNUserNotificationCenter.current().getNotificationSettings { settings in let status = settings.authorizationStatus Task { @MainActor in self.applyNotificationStatus(status) self.registerForRemoteNotificationsIfEnrollmentReady() } } } func handleNotificationServingToggleChange(_ isOn: Bool) { guard isOn else { self.notificationServingEnabled = false // UIKit stops APNs delivery here; re-enabling registers again and the // app delegate republishes the current token to the active gateway. UIApplication.shared.unregisterForRemoteNotifications() return } switch self.notificationStatus { case .allowed: self.enableNotificationServing() case .notSet: guard self.prepareNotificationEnrollment() else { return } self.requestNotificationAuthorizationFromSettings() case .notAllowed, .unknown: self.notificationServingEnabled = true self.openNotificationSettings() case .checking: break } } private func prepareNotificationEnrollment() -> Bool { if PushBuildConfig.current.usesOpenClawHostedRelay, !PushEnrollmentConsent.disclosureAccepted { self.showNotificationRelayDisclosure = true return false } return true } private func enableNotificationServing() { guard self.prepareNotificationEnrollment() else { return } self.notificationServingEnabled = true self.registerForRemoteNotificationsIfEnrollmentReady() } func acceptNotificationRelayDisclosure() { PushEnrollmentConsent.markDisclosureAccepted() switch self.notificationStatus { case .allowed: self.enableNotificationServing() case .notSet: self.requestNotificationAuthorizationFromSettings() case .notAllowed, .unknown: self.notificationServingEnabled = true self.openNotificationSettings() case .checking: self.notificationServingEnabled = false } } 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) self.notificationServingEnabled = granted && self.notificationStatus.allowsNotifications guard self.notificationServingEnabled else { return } self.registerForRemoteNotificationsIfEnrollmentReady() } } } @MainActor func registerForRemoteNotificationsIfEnrollmentReady() { guard self.notificationServingEnabled else { return } guard !PushBuildConfig.current.usesOpenClawHostedRelay || PushEnrollmentConsent.disclosureAccepted else { return } guard self.notificationStatus.allowsNotifications else { return } UIApplication.shared.registerForRemoteNotifications() } @MainActor func applyNotificationStatus(_ status: UNAuthorizationStatus) { self.notificationStatus = SettingsNotificationStatus(status) } var currentManualGatewayStableID: String? { let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return nil } return GatewayConnectionController.ManualAuthOverride.manualStableID( host: host, port: port) } var gatewayCredentialTargetStableID: String? { // Auth fields follow the selected route. Otherwise a discovered-gateway retry can save // credentials under the unrelated manual endpoint and immediately reload an empty bundle. self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID } var gatewayCustomHeadersTargetStableID: String? { guard let stableID = self.gatewayCredentialTargetStableID else { return nil } if GatewayStableIdentifier.matches(self.currentManualGatewayStableID, stableID) { return self.manualGatewayTLS ? stableID : nil } if let active = self.appModel.activeGatewayConnectConfig, GatewayStableIdentifier.matches(active.effectiveStableID, stableID) { return active.url.scheme?.lowercased() == "wss" ? stableID : nil } return nil } var manualGatewayEnabledBinding: Binding { Binding( get: { self.manualGatewayEnabled }, set: { enabled in self.manualGatewayEnabled = enabled guard enabled, let stableID = self.currentManualGatewayStableID else { return } self.selectGatewayCredentialTarget(stableID, allowManualOverride: true) }) } var gatewayTokenBinding: Binding { Binding( get: { self.gatewayToken }, set: { self.persistGatewayToken($0) }) } var gatewayPasswordBinding: Binding { Binding( get: { self.gatewayPassword }, set: { self.persistGatewayPassword($0) }) } var manualHostBinding: Binding { Binding( get: { self.manualGatewayHost }, set: { value in let previousStableID = self.currentManualGatewayStableID self.manualGatewayHost = value if GatewayStableIdentifier.key(previousStableID) != GatewayStableIdentifier.key(self.currentManualGatewayStableID) { self.clearManualCredentialFields() } }) } func persistGatewayToken(_ value: String) { self.gatewayToken = value guard !self.suppressCredentialPersist else { return } let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return } self.gatewayCredentialFieldStableID = stableID let saved = GatewaySettingsStore.updateGatewayCredentials( token: value, password: self.gatewayPassword, gatewayStableID: stableID, instanceId: instanceId) self.pendingManualAuthOverride = saved ? GatewayConnectionController.ManualAuthOverride.persisted( instanceId: instanceId, targetStableID: stableID) : nil } func persistGatewayPassword(_ value: String) { self.gatewayPassword = value guard !self.suppressCredentialPersist else { return } let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return } self.gatewayCredentialFieldStableID = stableID let saved = GatewaySettingsStore.updateGatewayCredentials( token: self.gatewayToken, password: value, gatewayStableID: stableID, instanceId: instanceId) self.pendingManualAuthOverride = saved ? GatewayConnectionController.ManualAuthOverride.persisted( instanceId: instanceId, targetStableID: stableID) : nil } 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: String(localized: "Gateway") case .appleWatch: String(localized: "Apple Watch") case .approvals: String(localized: "Approvals") case .permissions: String(localized: "Permissions") case .channels: String(localized: "Channels") case .skills: String(localized: "Skills") case .voice: String(localized: "Voice & Talk") case .diagnostics: String(localized: "Diagnostics") case .privacy: String(localized: "Privacy") case .notifications: String(localized: "Notifications") case .licenses: String(localized: "Licenses") case .about: String(localized: "About") } } func sendDirectWatchSetup() async { guard !self.isSendingWatchDirectSetup else { return } self.isSendingWatchDirectSetup = true self.watchDirectSetupStatusText = String(localized: "Preparing one-time setup…") defer { self.isSendingWatchDirectSetup = false } do { let result = try await self.appModel.sendDirectWatchSetup() self.watchDirectSetupStatusText = result.deliveredImmediately ? String(localized: "Setup sent. Open OpenClaw on the watch to connect.") : String( localized: "Setup queued for the watch. Open OpenClaw before the code expires.") } catch { self.watchDirectSetupStatusText = error.localizedDescription } } var manualPortBinding: Binding { Binding( get: { self.manualGatewayPortText }, set: { newValue in let previousStableID = self.currentManualGatewayStableID let filtered = newValue.filter(\.isNumber) self.manualGatewayPortText = filtered self.manualGatewayPort = Int(filtered) ?? 0 if GatewayStableIdentifier.key(previousStableID) != GatewayStableIdentifier.key(self.currentManualGatewayStableID) { self.clearManualCredentialFields() } }) } private func clearManualCredentialFields() { self.gatewayToken = "" self.gatewayPassword = "" self.gatewayCredentialFieldStableID = nil self.pendingManualAuthOverride = nil } private func selectGatewayCredentialTarget(_ stableID: String, allowManualOverride: Bool) { let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) if !GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, stableID) { let credentials = GatewaySettingsStore.loadGatewayCredentials( instanceId: instanceId, gatewayStableID: stableID) self.gatewayCredentialFieldStableID = stableID self.gatewayToken = credentials.token ?? "" self.gatewayPassword = credentials.password ?? "" } guard allowManualOverride else { self.pendingManualAuthOverride = nil return } // Each attempt consumes the in-memory override. Reload durable bootstrap auth even // when the endpoint fields did not change so retry never erases a one-time token. self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted( instanceId: instanceId, targetStableID: stableID) } var manualPortIsValid: Bool { if self.manualGatewayPortText.isEmpty { return true } return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535 } func resolvedManualPort(host: String) -> Int? { guard self.manualGatewayPortText.isEmpty || self.manualGatewayPort > 0 else { return nil } return GatewayConnectionController.resolvedManualPort( host: host, port: self.manualGatewayPort) } var setupStatusLine: String? { if let problem = self.appModel.lastGatewayProblem { return problem.localizedMessage } 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 String( localized: "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 String( localized: "Pairing required. Run /pair approve in your OpenClaw chat, then connect again.") } if lower.contains("device nonce required") || lower.contains("device nonce mismatch") { return String(localized: "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 String( localized: "Connection timed out. Make sure Tailscale is connected, then try again.") } if lower.contains("unauthorized role") { return String( localized: "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() let setupApplied = String(localized: "Setup code applied. Connecting...").lowercased() let checkingReachability = String(localized: "Checking gateway reachability...").lowercased() let qrFormat = String(localized: "QR loaded. Connecting to %@:%@...").lowercased() return lower == setupApplied || Self.localizedFormat(qrFormat, matches: lower) || lower == checkingReachability } static func localizedFormat(_ format: String, matches value: String) -> Bool { guard let placeholder = try? NSRegularExpression(pattern: #"%(\d+\$)?@"#) else { return format == value } let formatRange = NSRange(format.startIndex..., in: format) let matches = placeholder.matches(in: format, range: formatRange) guard !matches.isEmpty else { return format == value } var pattern = "^" var cursor = format.startIndex for match in matches { guard let range = Range(match.range, in: format) else { return false } pattern += NSRegularExpression.escapedPattern(for: String(format[cursor.. { 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 String(localized: "Not loaded") } return self.appModel.talkMode.gatewayTalkApiKeyConfigured ? String(localized: "Configured") : String(localized: "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 String(localized: "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 } /// First-run state: no paired gateways yet (demo mode fakes a pairing), so /// the status card surfaces Scan QR as the primary action. var gatewayNeedsPairing: Bool { self.gatewayRegistry.entries.isEmpty && !self.appModel.isAppleReviewDemoModeEnabled } var gatewayStatusDetail: String { if self.appModel.isAppleReviewDemoModeEnabled { return String(localized: "Apple Review demo mode") } return self.gatewayConnected ? String(localized: "Connected") : self.appModel.gatewayDisplayStatusText } var gatewayStatusValue: String { if self.appModel.isAppleReviewDemoModeEnabled { return String(localized: "demo") } return self.gatewayConnected ? String(localized: "online") : String(localized: "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 String(localized: "Live gateway requests are disabled in demo mode.") } if self.notificationsNeedAttention { return String( localized: "Foreground approvals still appear while OpenClaw is connected.") } return self.gatewayConnected ? String(localized: "Gateway requests will appear here.") : String(localized: "Connect to the gateway.") } var gatewayTalkConfigDetail: String { if self.appModel.isAppleReviewDemoModeEnabled { return String(localized: "Demo mode only") } return self.appModel.talkMode.gatewayTalkTransportLabel } var gatewayTalkConfigValue: String { if self.appModel.isAppleReviewDemoModeEnabled { return String(localized: "demo") } return self.appModel.talkMode.gatewayTalkConfigLoaded ? String(localized: "loaded") : String(localized: "missing") } var gatewayTalkConfigColor: Color { if self.appModel.isAppleReviewDemoModeEnabled { return .secondary } return self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary } var gatewayAddress: String { self.appModel.gatewayRemoteAddress ?? String(localized: "Waiting for gateway") } var gatewayServer: String { self.appModel.gatewayServerName ?? "OpenClaw Gateway" } var pendingApproval: NodeAppModel.ExecApprovalPrompt? { self.appModel.pendingExecApprovalPrompt } var pendingApprovalCount: Int { self.appModel.pendingExecApprovalCount } var approvalWaitingText: String { if self.pendingApprovalCount == 1 { return String(localized: "1 waiting") } return String( format: String(localized: "%@ waiting"), self.pendingApprovalCount.formatted()) } var notificationsNeedAttention: Bool { self.notificationPresentation.needsAttention } var approvalItems: [SettingsApprovalItem] { guard let pendingApproval else { return [] } let pendingTitle = pendingApproval.commandPreview.map(OpenClawTextValue.verbatim) ?? OpenClawTextValue.localized("Review gateway action") let agentDetail = String( format: String(localized: "Agent: %@"), self.appModel.activeAgentName) return [ SettingsApprovalItem( id: "pending-real", icon: "terminal.fill", title: pendingTitle, detail: .verbatim(agentDetail), priority: self.appModel.pendingExecApprovalPromptResolving ? .localized("Resolving") : .localized("High"), color: OpenClawBrand.danger), SettingsApprovalItem( id: "pending-context", icon: "doc.text.fill", title: pendingApproval.allowsAllowAlways ? .localized("Permission can be saved") : .localized("One-time approval"), detail: "Gateway request", priority: pendingApproval.allowsAllowAlways ? .localized("Medium") : .localized("Review"), color: OpenClawBrand.warn), ] } var voiceDetail: String { if self.talkEnabled, self.voiceWakeEnabled { return String(localized: "Talk + Wake") } if self.talkEnabled { return String(localized: "Talk on") } if self.voiceWakeEnabled { return String(localized: "Wake on") } return String(localized: "Off") } var diagnosticsHealthValue: String { if self.appModel.isAppleReviewDemoModeEnabled { return String(localized: "demo") } if self.gatewayConnected { return String(localized: "ready") } if self.gatewayController.gateways.isEmpty { return String(localized: "check") } return String(localized: "partial") } var diagnosticsRunValue: String { guard let diagnosticsIssueCount else { return String(localized: "pending") } return diagnosticsIssueCount == 0 ? String(localized: "pass") : diagnosticsIssueCount.formatted() } var diagnosticsRunColor: Color { guard let diagnosticsIssueCount else { return .secondary } return diagnosticsIssueCount == 0 ? OpenClawBrand.ok : OpenClawBrand.warn } var locationPermissionDetailText: String? { if self.isChangingLocationMode { return String(localized: "Requesting iOS location permission…") } return self.locationSettingsPresentation.statusText } var locationPermissionWarningText: String? { guard let locationStatusText else { return nil } guard locationStatusText != self.locationPermissionDetailText else { return nil } return locationStatusText } var notificationStatusText: String { self.notificationPresentation.text } var notificationStatusColor: Color { self.notificationPresentation.color } var notificationServingActive: Bool { self.notificationPresentation.isActive } var notificationDisclosureAccepted: Bool { !PushBuildConfig.current.usesOpenClawHostedRelay || PushEnrollmentConsent.disclosureAccepted } var notificationToggleBinding: Binding { Binding( get: { self.notificationServingActive }, set: { self.handleNotificationServingToggleChange($0) }) } var notificationPresentation: SettingsNotificationPresentation { switch self.notificationStatus { case .checking: return .checking case .allowed: if !self.notificationServingEnabled { return .off } if !self.notificationDisclosureAccepted { return .setup } return .enabled case .notAllowed: return .denied case .notSet: return .notSet case .unknown: return .unknown } } var notificationStatusDetail: String { self.notificationPresentation.detail } 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 String( format: String( localized: "This build uses OpenClaw's hosted push relay at %@ for notification delivery data."), host) } return String( localized: "This build is not configured to use OpenClaw's hosted push relay.") } var notificationRelayDisclosureMessage: String { String( localized: "Enabling this sends delivery data through OpenClaw's hosted push relay.") } }