mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 23:01:38 +00:00
* 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 commitf36a95213e) (cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac) (cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235) (cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1) (cherry picked from commit2a60619722) (cherry picked from commit6f0c386567) (cherry picked from commit784a5857b7) (cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db) * fix(apps): harden approval reconciliation and watch states
1309 lines
53 KiB
Swift
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."
|
|
}
|
|
}
|