mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-08 21:13:44 +08:00
* feat(ios): multi-gateway registry, per-gateway credentials, and switching * fix(ios): harden multi-gateway state isolation
1147 lines
47 KiB
Swift
1147 lines
47 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.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 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.connectingGatewayID == nil else { return }
|
|
self.connectingGatewayID = entry.stableID
|
|
self.setupStatusText = "Switching to \(entry.name)…"
|
|
defer {
|
|
self.connectingGatewayID = 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 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.notificationStatus == .allowed)
|
|
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.connectingGatewayID = gateway.id
|
|
defer {
|
|
self.connectingGatewayID = 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.connectingGatewayID = 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.connectingGatewayID = "manual"
|
|
self.manualGatewayEnabled = true
|
|
defer {
|
|
self.connectingGatewayID = nil
|
|
self.refreshGatewayRegistry()
|
|
}
|
|
let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(
|
|
host: host,
|
|
port: port)
|
|
self.selectGatewayCredentialTarget(stableID, allowManualOverride: true)
|
|
if self.appModel.activeGatewayConnectConfig?.effectiveStableID == stableID,
|
|
self.appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true
|
|
{
|
|
self.pendingManualAuthOverride = nil
|
|
}
|
|
let fieldsMatchTarget = self.gatewayCredentialFieldStableID == stableID
|
|
let pendingOverride = 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.connectingGatewayID == nil else { return nil }
|
|
let attemptID = UUID()
|
|
self.setupAttemptID = attemptID
|
|
self.connectingGatewayID = "setup-code"
|
|
return attemptID
|
|
}
|
|
|
|
func finishGatewaySetupAttempt(_ attemptID: UUID) {
|
|
guard self.setupAttemptID == attemptID else { return }
|
|
self.invalidateGatewaySetupAttempt()
|
|
}
|
|
|
|
func invalidateGatewaySetupAttempt() {
|
|
self.setupAttemptID = nil
|
|
self.connectingGatewayID = 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.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)
|
|
}
|
|
|
|
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 self.currentManualGatewayStableID == stableID {
|
|
return self.manualGatewayTLS ? stableID : nil
|
|
}
|
|
if let active = self.appModel.activeGatewayConnectConfig,
|
|
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 previousStableID != 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 .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<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 previousStableID != 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 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 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."
|
|
}
|
|
}
|