Files
openclaw/apps/ios/Sources/Design/SettingsProTabActions.swift
Peter Steinberger ac89350327 feat(apps): review durable approvals on mobile (#104913)
* feat(apps): Android, iPhone, and Watch approval clients

Squash-rebased #103912 segment onto the deep-links tip on current main.
Native approval surfaces: iOS approval presentation with gateway-switch
lease preservation and resolution fencing, watchOS inbox + approval
actions with shipped-shape payload codec, Android approval notices with
publication-tokened dismissal. Native i18n inventory regenerated.

(cherry picked from commit 428a76670ffeede54248b7bd7aa4438e2589851b)
(cherry picked from commit 80225d5707c3645eeea5435f266131037b50ede6)
(cherry picked from commit 2a23b714dc30d773a294aa45adc617e46b80732e)
(cherry picked from commit 9ff1153827769e2cbab7c11c153f0f8634662c28)
(cherry picked from commit 5b25723525bd562e5f97478af492f7b46ad30fd1)
(cherry picked from commit 8c80e8467b5fe89aad0d4c74a0573f1600ed3af9)
(cherry picked from commit ad4037bc9846bf6f082b41c129ee68aa344576c3)
(cherry picked from commit fdf767dd662cff3c8a5a6d571f38f153410651ca)
(cherry picked from commit 00c120376ffd992ea68ce29254a9bc0a25ed1740)
(cherry picked from commit f36a95213e)
(cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac)
(cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235)
(cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1)
(cherry picked from commit 2a60619722)
(cherry picked from commit 6f0c386567)
(cherry picked from commit 784a5857b7)
(cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db)

* fix(apps): harden approval reconciliation and watch states
2026-07-11 19:59:07 -07:00

1309 lines
53 KiB
Swift

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.notificationStatusColor)
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 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 = "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 = "Could not forget \(entry.name)."
self.refreshGatewayRegistry()
return
}
if GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, entry.stableID) {
self.clearManualCredentialFields()
}
self.setupStatusText = "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 {
"Saved endpoint unavailable"
}
return entry.useTLS ? "\(endpoint) • TLS" : endpoint
case .discovered:
return entry.useTLS ? "Discovered • TLS" : "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 = "Failed: invalid port"
return
}
guard await self.preflightGateway(host: host) else { return }
self.setupStatusText = "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 ? "TLS" : "plain"
self.setupStatusText = "Setup link loaded for \(link.host):\(link.port) (\(security)). Tap Connect to apply."
}
@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 = "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()
self.pendingTargetSuppression.releaseAutoConnect(.setupLink, controller: self.gatewayController)
return false
}
guard let parsedLink = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupStatusText = "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 = "Opening QR scanner..."
self.showQRScanner = true
}
func queueScannedResult(_ result: QRScannerResult, scanID: UInt64) {
guard self.scannerResultHandoff.queue(result, scanID: scanID) else { return }
self.setupStatusText = "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 = "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 = "QR loaded. Connecting to \(link.host):\(link.port)..."
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(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 = "Failed: host required"
return
}
guard self.manualPortIsValid else {
self.setupStatusText = "Failed: invalid port"
return
}
guard let port = self.resolvedManualPort(host: host) else {
self.setupStatusText = "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 = "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<Bool> {
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<String> {
Binding(
get: { self.gatewayToken },
set: { self.persistGatewayToken($0) })
}
var gatewayPasswordBinding: Binding<String> {
Binding(
get: { self.gatewayPassword },
set: { self.persistGatewayPassword($0) })
}
var manualHostBinding: Binding<String> {
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: "Gateway"
case .appleWatch: "Apple Watch"
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"
}
}
func sendDirectWatchSetup() async {
guard !self.isSendingWatchDirectSetup else { return }
self.isSendingWatchDirectSetup = true
self.watchDirectSetupStatusText = "Preparing one-time setup…"
defer { self.isSendingWatchDirectSetup = false }
do {
let result = try await self.appModel.sendDirectWatchSetup()
self.watchDirectSetupStatusText = result.deliveredImmediately
? "Setup sent. Open OpenClaw on the watch to connect."
: "Setup queued for the watch. Open OpenClaw before the code expires."
} catch {
self.watchDirectSetupStatusText = error.localizedDescription
}
}
var manualPortBinding: Binding<String> {
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.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<String> {
Binding(
get: { self.talkProviderSelectionRaw },
set: { newValue in
let selection = TalkModeProviderSelection.resolved(newValue)
self.talkProviderSelectionRaw = selection.rawValue
self.appModel.setTalkProviderSelection(selection.rawValue)
})
}
var talkRealtimeVoiceSelectionBinding: Binding<String> {
Binding(
get: { self.talkRealtimeVoiceSelectionRaw },
set: { newValue in
let voice = TalkModeRealtimeVoiceSelection.resolvedOverride(newValue) ?? ""
self.talkRealtimeVoiceSelectionRaw = voice
self.appModel.setTalkRealtimeVoiceSelection(voice)
})
}
var talkSpeakerphoneBinding: Binding<Bool> {
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 pendingApprovalCount: Int {
self.appModel.pendingExecApprovalCount
}
var approvalWaitingText: String {
self.pendingApprovalCount == 1 ? "1 waiting" : "\(self.pendingApprovalCount) waiting"
}
var notificationsNeedAttention: Bool {
self.notificationPresentation.needsAttention
}
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 locationPermissionDetailText: String? {
if self.isChangingLocationMode {
return "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<Bool> {
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 """
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."
}
}