mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 14:41:35 +00:00
* fix(ios): forgetting a paired gateway does nothing Confirming "Forget Gateway" in Settings never removed the gateway. The confirmation dialog derived its isPresented binding from the payload it later needed and cleared that payload on dismissal, so by the time the destructive button's action ran, forgetPendingGateway() found nil at its opening guard and returned without calling forgetGateway(stableID:). Nothing was logged either, because the failure branch was never reached. Hand the entry to the action through the dialog's presenting: parameter instead of reading it back from view state, and take it as a parameter rather than looking it up. Everything downstream is unchanged; it simply runs now. * Shorten the dialog comment to one line The three-line version repeated in prose what the next line already shows. What is worth keeping is the trap: someone simplifying this back to reading the entry from view state reintroduces the bug. * fix(ios): harden forget gateway payload handoff Co-authored-by: Cihat Gündüz <anthropic@fline.dev> * test(ios): run gateway settings source guards * test(ios): allow forget dialog state cleanup * test(ci): provide rg in PR review fixture --------- Co-authored-by: Cihat Gündüz <anthropic@fline.dev> Co-authored-by: Peter Steinberger <steipete@gmail.com>
1413 lines
58 KiB
Swift
1413 lines
58 KiB
Swift
import CoreLocation
|
|
import OpenClawKit
|
|
import SwiftUI
|
|
import UIKit
|
|
import UserNotifications
|
|
|
|
extension SettingsProTab {
|
|
func detailStatusCard(
|
|
icon: String,
|
|
title: OpenClawTextValue,
|
|
detail: OpenClawTextValue,
|
|
value: OpenClawTextValue,
|
|
color: Color,
|
|
actionTitle: LocalizedStringKey? = nil,
|
|
actionSystemImage: String = "arrow.right",
|
|
action: (() -> Void)? = nil) -> some View
|
|
{
|
|
Section {
|
|
HStack(spacing: 12) {
|
|
SettingsIcon(systemName: icon, color: color)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
title.text
|
|
.font(OpenClawType.headline)
|
|
detail.text
|
|
.font(OpenClawType.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
Spacer(minLength: 8)
|
|
value.text
|
|
.font(OpenClawType.subheadMedium)
|
|
.foregroundStyle(color)
|
|
}
|
|
if let action, let actionTitle {
|
|
Button(action: action) {
|
|
Label(actionTitle, systemImage: actionSystemImage)
|
|
.font(OpenClawType.subheadSemiBold)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(OpenClawBrand.accent)
|
|
}
|
|
}
|
|
}
|
|
|
|
var diagnosticChecksCard: some View {
|
|
Section("Checks") {
|
|
self.diagnosticCheckRow(
|
|
icon: "stethoscope",
|
|
title: "Last Run",
|
|
detail: .verbatim(self.diagnosticsLastRunText),
|
|
value: .verbatim(self.diagnosticsRunValue),
|
|
color: self.diagnosticsRunColor)
|
|
self.diagnosticCheckRow(
|
|
icon: "antenna.radiowaves.left.and.right",
|
|
title: "Gateway Link",
|
|
detail: .verbatim(self.gatewayStatusDetail),
|
|
value: .verbatim(self.gatewayStatusValue),
|
|
color: self.gatewayStatusColor)
|
|
self.diagnosticCheckRow(
|
|
icon: "dot.radiowaves.left.and.right",
|
|
title: "Discovery",
|
|
detail: .verbatim(self.gatewayController.discoveryStatusText),
|
|
value: .verbatim(self.gatewayController.gateways.count.formatted()),
|
|
color: self.gatewayController.gateways.isEmpty ? .secondary : OpenClawBrand.accent)
|
|
self.diagnosticCheckRow(
|
|
icon: "waveform",
|
|
title: "Talk Config",
|
|
detail: .verbatim(self.gatewayTalkConfigDetail),
|
|
value: .verbatim(self.gatewayTalkConfigValue),
|
|
color: self.gatewayTalkConfigColor)
|
|
self.diagnosticCheckRow(
|
|
icon: "bell",
|
|
title: "Notifications",
|
|
detail: "Approval and event alert channel",
|
|
value: .verbatim(self.notificationStatusText),
|
|
color: self.notificationStatusColor)
|
|
self.diagnosticCheckRow(
|
|
icon: "rectangle.on.rectangle",
|
|
title: "Screen Capture",
|
|
detail: "Live foreground capture state",
|
|
value: .verbatim(self.appModel.screenRecordActive
|
|
? String(localized: "live")
|
|
: String(localized: "idle")),
|
|
color: self.appModel.screenRecordActive ? OpenClawBrand.ok : .secondary)
|
|
self.diagnosticCheckRow(
|
|
icon: "mic",
|
|
title: "Voice Wake",
|
|
detail: .verbatim(self.appModel.voiceWake.statusText),
|
|
value: .verbatim(self.voiceWakeEnabled
|
|
? String(localized: "on")
|
|
: String(localized: "off")),
|
|
color: self.voiceWakeEnabled ? OpenClawBrand.ok : .secondary)
|
|
}
|
|
}
|
|
|
|
func diagnosticCheckRow(
|
|
icon: String,
|
|
title: OpenClawTextValue,
|
|
detail: OpenClawTextValue,
|
|
value: OpenClawTextValue,
|
|
color: Color) -> some View
|
|
{
|
|
HStack(spacing: 12) {
|
|
SettingsIcon(systemName: icon, color: color)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
title.text
|
|
.font(OpenClawType.subheadSemiBold)
|
|
detail.text
|
|
.font(OpenClawType.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer(minLength: 8)
|
|
value.text
|
|
.font(OpenClawType.subhead)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
func detailListCard(@ViewBuilder content: () -> some View) -> some View {
|
|
Section {
|
|
content()
|
|
}
|
|
}
|
|
|
|
func reconnectGateway() async {
|
|
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
|
|
guard !self.isReconnectingGateway else { return }
|
|
self.isReconnectingGateway = true
|
|
defer { self.isReconnectingGateway = false }
|
|
await self.gatewayController.connectActiveGateway()
|
|
}
|
|
|
|
func switchGateway(to entry: GatewaySettingsStore.GatewayRegistryEntry) async {
|
|
guard self.connectingGateway == nil else { return }
|
|
self.connectingGateway = .gateway(entry.id)
|
|
self.setupStatusText = String(
|
|
format: String(localized: "Switching to %@…"),
|
|
entry.name)
|
|
defer {
|
|
self.connectingGateway = nil
|
|
self.refreshGatewayRegistry()
|
|
}
|
|
if let failure = await self.gatewayController.switchToGateway(stableID: entry.stableID) {
|
|
self.setupStatusText = failure
|
|
return
|
|
}
|
|
self.selectGatewayCredentialTarget(entry.stableID, allowManualOverride: false)
|
|
}
|
|
|
|
func forgetGateway(_ entry: GatewaySettingsStore.GatewayRegistryEntry) async {
|
|
self.pendingForgetGateway = nil
|
|
guard await self.gatewayController.forgetGateway(stableID: entry.stableID) else {
|
|
self.setupStatusText = String(
|
|
format: String(localized: "Could not forget %@."),
|
|
entry.name)
|
|
self.refreshGatewayRegistry()
|
|
return
|
|
}
|
|
if GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, entry.stableID) {
|
|
self.clearManualCredentialFields()
|
|
}
|
|
self.setupStatusText = String(
|
|
format: String(localized: "Forgot %@."),
|
|
entry.name)
|
|
self.refreshGatewayRegistry()
|
|
}
|
|
|
|
func refreshGatewayRegistry() {
|
|
self.gatewayRegistry = GatewaySettingsStore.loadGatewayRegistry()
|
|
}
|
|
|
|
func gatewayEndpointSummary(_ entry: GatewaySettingsStore.GatewayRegistryEntry) -> String {
|
|
switch entry.kind {
|
|
case .manual:
|
|
let endpoint = if let host = entry.host, let port = entry.port {
|
|
"\(host):\(port)"
|
|
} else {
|
|
String(localized: "Saved endpoint unavailable")
|
|
}
|
|
return entry.useTLS ? "\(endpoint) • TLS" : endpoint
|
|
case .discovered:
|
|
return entry.useTLS
|
|
? String(localized: "Discovered • TLS")
|
|
: String(localized: "Discovered")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func runDiagnostics() async {
|
|
guard !self.isRefreshingGateway else { return }
|
|
self.isRefreshingGateway = true
|
|
defer { self.isRefreshingGateway = false }
|
|
|
|
if !self.appModel.isAppleReviewDemoModeEnabled {
|
|
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
|
self.gatewayController.restartDiscovery()
|
|
await self.appModel.refreshGatewayOverviewIfConnected()
|
|
}
|
|
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
|
|
self.applyNotificationStatus(notificationSettings.authorizationStatus)
|
|
self.registerForRemoteNotificationsIfEnrollmentReady()
|
|
|
|
let issueCount = SettingsDiagnostics.issueCount(
|
|
gatewayConnected: self.gatewayDiagnosticConnected,
|
|
discoveredGatewayCount: self.gatewayController.gateways.count,
|
|
talkConfigLoaded: self.gatewayDiagnosticTalkConfigLoaded,
|
|
notificationsAllowed: self.notificationServingActive)
|
|
self.diagnosticsIssueCount = issueCount
|
|
self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date())
|
|
}
|
|
|
|
func syncSettingsState() {
|
|
self.refreshGatewayRegistry()
|
|
self.manualGatewayPortText = self.manualGatewayPort > 0 ? String(self.manualGatewayPort) : ""
|
|
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
|
|
self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
|
|
self.refreshLocationPermissionSummary()
|
|
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmedInstanceId.isEmpty else { return }
|
|
guard let stableID = self.currentManualGatewayStableID else {
|
|
self.gatewayCredentialFieldStableID = nil
|
|
self.gatewayToken = ""
|
|
self.gatewayPassword = ""
|
|
self.pendingManualAuthOverride = nil
|
|
return
|
|
}
|
|
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
|
instanceId: trimmedInstanceId,
|
|
gatewayStableID: stableID)
|
|
let ownsFields = credentials.hasCredentials || credentials.suppressStoredDeviceAuth
|
|
self.gatewayCredentialFieldStableID = ownsFields ? stableID : nil
|
|
self.gatewayToken = credentials.token ?? ""
|
|
self.gatewayPassword = credentials.password ?? ""
|
|
self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted(
|
|
instanceId: trimmedInstanceId,
|
|
targetStableID: stableID)
|
|
}
|
|
|
|
func refreshLocationPermissionSummary(desiredMode modeOverride: OpenClawLocationMode? = nil) {
|
|
let mode = modeOverride ?? OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
|
|
let authorization = self.appModel.locationAuthorizationSnapshot
|
|
self.locationPermissionRefreshID &+= 1
|
|
let refreshID = self.locationPermissionRefreshID
|
|
let currentSummary = self.locationPermissionSummary
|
|
self.locationPermissionSummary = LocationPermissionSummary(
|
|
desiredMode: mode,
|
|
locationServicesEnabled: currentSummary.locationServicesEnabled,
|
|
authorizationStatus: authorization.authorizationStatus,
|
|
accuracyAuthorization: authorization.accuracyAuthorization)
|
|
Task {
|
|
let locationServicesEnabled = await Self.locationServicesEnabled()
|
|
guard refreshID == self.locationPermissionRefreshID else { return }
|
|
let latestAuthorization = self.appModel.locationAuthorizationSnapshot
|
|
let latestMode = modeOverride ?? OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
|
|
self.locationPermissionSummary = LocationPermissionSummary(
|
|
desiredMode: latestMode,
|
|
locationServicesEnabled: locationServicesEnabled,
|
|
authorizationStatus: latestAuthorization.authorizationStatus,
|
|
accuracyAuthorization: latestAuthorization.accuracyAuthorization)
|
|
}
|
|
}
|
|
|
|
private static func locationServicesEnabled() async -> Bool {
|
|
await Task.detached(priority: .utility) {
|
|
CLLocationManager.locationServicesEnabled()
|
|
}.value
|
|
}
|
|
|
|
func syncAfterOnboardingReset() {
|
|
self.invalidateGatewaySetupAttempt()
|
|
self.setupStatusText = nil
|
|
self.stagedGatewaySetupLink = nil
|
|
self.pendingManualAuthOverride = nil
|
|
self.syncSettingsState()
|
|
self.pendingTargetSuppression.releaseAutoConnect(controller: self.gatewayController)
|
|
}
|
|
|
|
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
|
let supersededSetupLease = self.takeStagedGatewaySetupSuppression()
|
|
defer {
|
|
if let supersededSetupLease {
|
|
self.gatewayController.resumeAutoConnect(after: supersededSetupLease)
|
|
}
|
|
}
|
|
self.connectingGateway = .gateway(gateway.id)
|
|
defer {
|
|
self.connectingGateway = nil
|
|
self.refreshGatewayRegistry()
|
|
}
|
|
self.manualGatewayEnabled = false
|
|
self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)
|
|
GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
|
|
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
|
|
if let err = await self.gatewayController.connectWithDiagnostics(gateway) {
|
|
self.setupStatusText = err
|
|
}
|
|
}
|
|
|
|
func applySetupCodeAndConnect() async {
|
|
guard let attemptID = self.beginGatewaySetupAttempt() else { return }
|
|
defer {
|
|
self.finishGatewaySetupAttempt(attemptID)
|
|
self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController)
|
|
}
|
|
self.setupStatusText = nil
|
|
guard await self.applySetupCode(attemptID: attemptID) else { return }
|
|
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard self.resolvedManualPort(host: host) != nil else {
|
|
self.setupStatusText = String(localized: "Failed: invalid port")
|
|
return
|
|
}
|
|
guard await self.preflightGateway(host: host) else { return }
|
|
self.setupStatusText = String(localized: "Setup code applied. Connecting...")
|
|
await self.connectManual(setupAttemptID: attemptID)
|
|
}
|
|
|
|
func applyGatewaySetupLink(_ link: GatewayConnectDeepLink) {
|
|
// Only the root-selected Gateway destination may destructively claim a
|
|
// setup link; other Settings views can remain mounted behind onboarding.
|
|
self.showQRScanner = false
|
|
self.scannerResultHandoff.cancel()
|
|
let lease = self.gatewayController.cancelPendingConnectionAttempts()
|
|
self.pendingTargetSuppression.replace(owner: .setupLink, lease: lease)
|
|
self.setupCode = ""
|
|
self.setupStatusText = nil
|
|
self.stagedGatewaySetupLink = link
|
|
let security = link.tls ? String(localized: "TLS") : String(localized: "plain")
|
|
self.setupStatusText = String(
|
|
format: String(
|
|
localized: "Setup link loaded for %@:%@ (%@). Tap Connect to apply."),
|
|
link.host,
|
|
link.port.formatted(),
|
|
security)
|
|
}
|
|
|
|
@discardableResult
|
|
func applySetupCode(attemptID: UUID) async -> Bool {
|
|
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let stagedLink = self.stagedGatewaySetupLink
|
|
guard !raw.isEmpty || stagedLink != nil else {
|
|
self.setupStatusText = String(localized: "Paste a setup code to continue.")
|
|
return false
|
|
}
|
|
|
|
if AppleReviewDemoMode.isSetupCode(raw) {
|
|
self.stagedGatewaySetupLink = nil
|
|
self.setupCode = ""
|
|
self.setupStatusText = String(localized: "Apple Review demo mode enabled.")
|
|
self.appModel.enterAppleReviewDemoMode()
|
|
self.pendingTargetSuppression.releaseAutoConnect(.setupLink, controller: self.gatewayController)
|
|
return false
|
|
}
|
|
|
|
guard let parsedLink = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else {
|
|
self.setupStatusText = String(
|
|
localized: "Setup code not recognized or uses an insecure ws:// gateway URL.")
|
|
return false
|
|
}
|
|
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
|
|
guard self.setupAttemptID == attemptID else { return false }
|
|
self.stagedGatewaySetupLink = nil
|
|
self.setupCode = ""
|
|
await self.applyGatewayLink(link)
|
|
return true
|
|
}
|
|
|
|
func applyGatewayLink(_ link: GatewayConnectDeepLink) async {
|
|
self.manualGatewayHost = link.host
|
|
self.manualGatewayPort = link.port
|
|
self.manualGatewayPortText = String(link.port)
|
|
self.manualGatewayTLS = link.tls
|
|
let instanceId = GatewaySettingsStore.currentInstanceID()
|
|
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
|
|
self.gatewayCredentialFieldStableID = setupAuth.targetStableID
|
|
if setupAuth.hasBootstrapToken {
|
|
await GatewayOnboardingReset.prepareForBootstrapPairing(
|
|
appModel: self.appModel,
|
|
instanceId: instanceId,
|
|
gatewayStableID: setupAuth.targetStableID)
|
|
}
|
|
if !instanceId.isEmpty {
|
|
GatewaySettingsStore.saveGatewayCredentials(
|
|
token: setupAuth.token,
|
|
bootstrapToken: setupAuth.bootstrapToken,
|
|
password: setupAuth.password,
|
|
gatewayStableID: setupAuth.targetStableID,
|
|
suppressStoredDeviceAuth: true,
|
|
instanceId: instanceId)
|
|
}
|
|
self.gatewayToken = setupAuth.token
|
|
self.gatewayPassword = setupAuth.password
|
|
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
|
|
}
|
|
|
|
func openGatewayQRScanner() {
|
|
self.invalidateGatewaySetupAttempt()
|
|
let lease = self.gatewayController.cancelPendingConnectionAttempts(suspendCurrentGateway: true)
|
|
self.stagedGatewaySetupLink = nil
|
|
self.pendingTargetSuppression.replace(owner: .qrScanner, lease: lease)
|
|
self.scannerScanID = self.scannerResultHandoff.beginScan()
|
|
self.connectingGateway = nil
|
|
self.setupStatusText = String(localized: "Opening QR scanner...")
|
|
self.showQRScanner = true
|
|
}
|
|
|
|
func queueScannedResult(_ result: QRScannerResult, scanID: UInt64) {
|
|
guard self.scannerResultHandoff.queue(result, scanID: scanID) else { return }
|
|
self.setupStatusText = String(localized: "QR loaded. Closing scanner...")
|
|
self.showQRScanner = false
|
|
}
|
|
|
|
func processQueuedScannerResult() {
|
|
let delivery = self.scannerResultHandoff.processAfterDismissal { result in
|
|
switch result {
|
|
case let .gatewayLink(link):
|
|
self.handleScannedGatewayLink(link)
|
|
case let .setupCode(code):
|
|
self.handleScannedSetupCode(code)
|
|
}
|
|
}
|
|
if delivery == nil {
|
|
self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController)
|
|
}
|
|
}
|
|
|
|
func handleScannedGatewayLink(_ link: GatewayConnectDeepLink) {
|
|
self.showQRScanner = false
|
|
guard let attemptID = self.beginGatewaySetupAttempt() else { return }
|
|
self.setupCode = ""
|
|
Task { await self.connectAfterScannedGatewayLink(link, attemptID: attemptID) }
|
|
}
|
|
|
|
func handleScannedSetupCode(_ code: String) {
|
|
guard AppleReviewDemoMode.isSetupCode(code) else { return }
|
|
self.showQRScanner = false
|
|
self.setupCode = ""
|
|
self.stagedGatewaySetupLink = nil
|
|
self.setupStatusText = String(localized: "Apple Review demo mode enabled.")
|
|
self.appModel.enterAppleReviewDemoMode()
|
|
self.pendingTargetSuppression.releaseAutoConnect(.qrScanner, controller: self.gatewayController)
|
|
}
|
|
|
|
func clearStagedGatewaySetupLink() {
|
|
guard self.stagedGatewaySetupLink != nil else { return }
|
|
self.stagedGatewaySetupLink = nil
|
|
self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController)
|
|
}
|
|
|
|
private func takeStagedGatewaySetupSuppression() -> GatewayConnectionController.AutoConnectSuppressionLease? {
|
|
self.stagedGatewaySetupLink = nil
|
|
return self.pendingTargetSuppression.take(ifOwnedBy: .setupLink)
|
|
}
|
|
|
|
func connectAfterScannedGatewayLink(_ parsedLink: GatewayConnectDeepLink, attemptID: UUID) async {
|
|
defer {
|
|
self.finishGatewaySetupAttempt(attemptID)
|
|
self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController)
|
|
}
|
|
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
|
|
guard self.setupAttemptID == attemptID else { return }
|
|
await self.applyGatewayLink(link)
|
|
self.setupStatusText = String(
|
|
format: String(localized: "QR loaded. Connecting to %@:%@..."),
|
|
link.host,
|
|
link.port.formatted())
|
|
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard self.resolvedManualPort(host: host) != nil else {
|
|
self.setupStatusText = String(localized: "Failed: invalid port")
|
|
return
|
|
}
|
|
guard await self.preflightGateway(host: host) else { return }
|
|
await self.connectManual(setupAttemptID: attemptID)
|
|
}
|
|
|
|
func connectManual(setupAttemptID: UUID? = nil) async {
|
|
if let setupAttemptID {
|
|
guard self.setupAttemptID == setupAttemptID else { return }
|
|
} else {
|
|
self.invalidateGatewaySetupAttempt()
|
|
}
|
|
let supersededSetupLease = self.takeStagedGatewaySetupSuppression()
|
|
defer {
|
|
if let supersededSetupLease {
|
|
self.gatewayController.resumeAutoConnect(after: supersededSetupLease)
|
|
}
|
|
}
|
|
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !host.isEmpty else {
|
|
self.setupStatusText = String(localized: "Failed: host required")
|
|
return
|
|
}
|
|
guard self.manualPortIsValid else {
|
|
self.setupStatusText = String(localized: "Failed: invalid port")
|
|
return
|
|
}
|
|
guard let port = self.resolvedManualPort(host: host) else {
|
|
self.setupStatusText = String(localized: "Failed: invalid port")
|
|
return
|
|
}
|
|
self.connectingGateway = .manual
|
|
self.manualGatewayEnabled = true
|
|
defer {
|
|
self.connectingGateway = nil
|
|
self.refreshGatewayRegistry()
|
|
}
|
|
let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(
|
|
host: host,
|
|
port: port)
|
|
self.selectGatewayCredentialTarget(stableID, allowManualOverride: true)
|
|
if GatewayStableIdentifier.matches(
|
|
self.appModel.activeGatewayConnectConfig?.effectiveStableID,
|
|
stableID),
|
|
self.appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true
|
|
{
|
|
self.pendingManualAuthOverride = nil
|
|
}
|
|
let fieldsMatchTarget = GatewayStableIdentifier.matches(
|
|
self.gatewayCredentialFieldStableID,
|
|
stableID)
|
|
let pendingOverride = GatewayStableIdentifier.matches(
|
|
self.pendingManualAuthOverride?.targetStableID,
|
|
stableID)
|
|
? self.pendingManualAuthOverride
|
|
: nil
|
|
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
|
|
token: fieldsMatchTarget ? self.gatewayToken : nil,
|
|
pendingOverride: pendingOverride,
|
|
password: fieldsMatchTarget ? self.gatewayPassword : nil,
|
|
targetStableID: stableID)
|
|
let instanceId = GatewaySettingsStore.currentInstanceID()
|
|
if !instanceId.isEmpty, fieldsMatchTarget || pendingOverride != nil {
|
|
GatewaySettingsStore.saveGatewayCredentials(
|
|
token: authOverride?.token,
|
|
bootstrapToken: authOverride?.bootstrapToken,
|
|
password: authOverride?.password,
|
|
gatewayStableID: stableID,
|
|
suppressStoredDeviceAuth: authOverride?.suppressStoredDeviceAuth == true,
|
|
instanceId: instanceId)
|
|
}
|
|
await self.gatewayController.connectManual(
|
|
host: host,
|
|
port: port,
|
|
useTLS: self.manualGatewayTLS,
|
|
authOverride: authOverride)
|
|
// The controller now owns this attempt's immutable override. A later retry must reload
|
|
// durable state so a spent bootstrap token cannot be resurrected from the live view.
|
|
self.pendingManualAuthOverride = nil
|
|
}
|
|
|
|
func preflightGateway(host: String) async -> Bool {
|
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return false }
|
|
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
|
|
self.setupStatusText = String(
|
|
localized: "Tailscale is off on this device. Turn it on, then try again.")
|
|
return false
|
|
}
|
|
self.gatewayController.requestLocalNetworkAccess(reason: "settings_preflight")
|
|
return true
|
|
}
|
|
|
|
func resetOnboarding() async {
|
|
self.invalidateGatewaySetupAttempt()
|
|
self.setupStatusText = nil
|
|
self.setupCode = ""
|
|
self.gatewayAutoConnect = false
|
|
self.suppressCredentialPersist = true
|
|
defer { self.suppressCredentialPersist = false }
|
|
self.gatewayToken = ""
|
|
self.gatewayPassword = ""
|
|
self.gatewayCredentialFieldStableID = nil
|
|
self.pendingManualAuthOverride = nil
|
|
await GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
|
|
self.onboardingComplete = false
|
|
self.hasConnectedOnce = false
|
|
self.manualGatewayEnabled = false
|
|
self.manualGatewayHost = ""
|
|
self.onboardingRequestID += 1
|
|
}
|
|
|
|
func beginGatewaySetupAttempt() -> UUID? {
|
|
guard self.connectingGateway == nil else { return nil }
|
|
let attemptID = UUID()
|
|
self.setupAttemptID = attemptID
|
|
self.connectingGateway = .setupCode
|
|
return attemptID
|
|
}
|
|
|
|
func finishGatewaySetupAttempt(_ attemptID: UUID) {
|
|
guard self.setupAttemptID == attemptID else { return }
|
|
self.invalidateGatewaySetupAttempt()
|
|
}
|
|
|
|
func invalidateGatewaySetupAttempt() {
|
|
self.setupAttemptID = nil
|
|
self.connectingGateway = nil
|
|
}
|
|
|
|
func handleLocationModeChange(_ newValue: String) {
|
|
guard !self.isChangingLocationMode else { return }
|
|
guard newValue != self.previousLocationModeRaw else { return }
|
|
guard let mode = OpenClawLocationMode(rawValue: newValue) else { return }
|
|
let previous = self.previousLocationModeRaw
|
|
Task {
|
|
await self.applyLocationMode(mode, rawValue: newValue, previous: previous)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func applyLocationMode(
|
|
_ mode: OpenClawLocationMode,
|
|
rawValue: String,
|
|
previous: String) async
|
|
{
|
|
self.isChangingLocationMode = true
|
|
self.locationStatusText = nil
|
|
self.refreshLocationPermissionSummary(desiredMode: mode)
|
|
defer { self.isChangingLocationMode = false }
|
|
|
|
if mode == .off {
|
|
_ = await self.appModel.requestLocationPermissions(mode: mode)
|
|
self.pendingLocationMode = nil
|
|
self.locationModeRaw = rawValue
|
|
self.previousLocationModeRaw = rawValue
|
|
self.refreshLocationPermissionSummary(desiredMode: mode)
|
|
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
|
return
|
|
}
|
|
|
|
let granted = await self.appModel.requestLocationPermissions(mode: mode)
|
|
self.refreshLocationPermissionSummary(desiredMode: mode)
|
|
if granted {
|
|
self.pendingLocationMode = nil
|
|
self.locationModeRaw = rawValue
|
|
self.previousLocationModeRaw = rawValue
|
|
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
|
} else {
|
|
self.locationModeRaw = previous
|
|
self.previousLocationModeRaw = previous
|
|
self.refreshLocationPermissionSummary(
|
|
desiredMode: OpenClawLocationMode(rawValue: previous) ?? .off)
|
|
let presentation = self.locationSettingsPresentation(selectedMode: mode)
|
|
self.locationStatusText = presentation.statusText
|
|
}
|
|
}
|
|
|
|
var selectedLocationMode: OpenClawLocationMode {
|
|
OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
|
|
}
|
|
|
|
var displayedLocationMode: OpenClawLocationMode {
|
|
self.pendingLocationMode ?? self.selectedLocationMode
|
|
}
|
|
|
|
var locationSettingsPresentation: LocationSettingsPresentation {
|
|
self.locationSettingsPresentation(selectedMode: self.displayedLocationMode)
|
|
}
|
|
|
|
func locationSettingsPresentation(selectedMode: OpenClawLocationMode) -> LocationSettingsPresentation {
|
|
var summary = self.locationPermissionSummary
|
|
summary.desiredMode = selectedMode
|
|
return LocationSettingsPresentation(selectedMode: selectedMode, summary: summary)
|
|
}
|
|
|
|
func handleLocationSharingTap() {
|
|
guard !self.isChangingLocationMode else { return }
|
|
self.performLocationSettingsAction(self.locationSettingsPresentation.toggleAction())
|
|
}
|
|
|
|
func selectLocationAccessLevel(_ mode: OpenClawLocationMode) {
|
|
guard mode != .off else { return }
|
|
guard !self.isChangingLocationMode else { return }
|
|
let presentation = self.locationSettingsPresentation(selectedMode: mode)
|
|
self.performLocationSettingsAction(presentation.accessLevelAction(mode: mode))
|
|
}
|
|
|
|
func performLocationSettingsAction(_ action: LocationSettingsAction) {
|
|
switch action {
|
|
case let .setMode(mode):
|
|
self.setLocationMode(mode)
|
|
case let .openAppSettings(mode):
|
|
self.pendingLocationMode = mode
|
|
self.locationStatusText = self.locationSettingsPresentation(selectedMode: mode).statusText
|
|
self.openLocationSettings()
|
|
}
|
|
}
|
|
|
|
func setLocationMode(_ mode: OpenClawLocationMode) {
|
|
let rawValue = mode.rawValue
|
|
let previous = self.previousLocationModeRaw
|
|
if self.locationModeRaw != rawValue {
|
|
self.locationModeRaw = rawValue
|
|
return
|
|
}
|
|
Task {
|
|
await self.applyLocationMode(mode, rawValue: rawValue, previous: previous)
|
|
}
|
|
}
|
|
|
|
func applyPendingLocationModeIfAvailable() {
|
|
guard let mode = self.pendingLocationMode else { return }
|
|
Task {
|
|
let locationServicesEnabled = await Self.locationServicesEnabled()
|
|
let authorization = self.appModel.locationAuthorizationSnapshot
|
|
let summary = LocationPermissionSummary(
|
|
desiredMode: mode,
|
|
locationServicesEnabled: locationServicesEnabled,
|
|
authorizationStatus: authorization.authorizationStatus,
|
|
accuracyAuthorization: authorization.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: String(localized: "Gateway")
|
|
case .systemAgent: String(localized: "OpenClaw")
|
|
case .appleWatch: String(localized: "Apple Watch")
|
|
case .approvals: String(localized: "Approvals")
|
|
case .permissions: String(localized: "Permissions")
|
|
case .channels: String(localized: "Channels")
|
|
case .skills: String(localized: "Skills")
|
|
case .voice: String(localized: "Voice & Talk")
|
|
case .diagnostics: String(localized: "Diagnostics")
|
|
case .privacy: String(localized: "Privacy")
|
|
case .notifications: String(localized: "Notifications")
|
|
case .licenses: String(localized: "Licenses")
|
|
case .about: String(localized: "About")
|
|
}
|
|
}
|
|
|
|
func sendDirectWatchSetup() async {
|
|
guard !self.isSendingWatchDirectSetup else { return }
|
|
self.isSendingWatchDirectSetup = true
|
|
self.watchDirectSetupStatusText = String(localized: "Preparing one-time setup…")
|
|
defer { self.isSendingWatchDirectSetup = false }
|
|
do {
|
|
let result = try await self.appModel.sendDirectWatchSetup()
|
|
self.watchDirectSetupStatusText = result.deliveredImmediately
|
|
? String(localized: "Setup sent. Open OpenClaw on the watch to connect.")
|
|
: String(
|
|
localized: "Setup queued for the watch. Open OpenClaw before the code expires.")
|
|
} catch {
|
|
self.watchDirectSetupStatusText = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
var manualPortBinding: Binding<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.localizedMessage
|
|
}
|
|
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly }
|
|
if let friendly = self.friendlyGatewayMessage(from: trimmedSetup) { return friendly }
|
|
if self.isTransientSetupStatus(trimmedSetup),
|
|
!gatewayStatus.isEmpty,
|
|
gatewayStatus != "Offline"
|
|
{
|
|
return gatewayStatus
|
|
}
|
|
if !trimmedSetup.isEmpty { return trimmedSetup }
|
|
if gatewayStatus.isEmpty || gatewayStatus == "Offline" { return nil }
|
|
return gatewayStatus
|
|
}
|
|
|
|
var canApplyGatewaySetup: Bool {
|
|
!self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
|| self.stagedGatewaySetupLink != nil
|
|
}
|
|
|
|
var tailnetWarningText: String? {
|
|
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !host.isEmpty, Self.isTailnetHostOrIP(host), !Self.hasTailnetIPv4() else { return nil }
|
|
return String(
|
|
localized: "This gateway is on your tailnet. Turn on Tailscale on this device, then tap Connect.")
|
|
}
|
|
|
|
func friendlyGatewayMessage(from raw: String) -> String? {
|
|
let lower = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if lower.contains("pairing required") {
|
|
return String(
|
|
localized: "Pairing required. Run /pair approve in your OpenClaw chat, then connect again.")
|
|
}
|
|
if lower.contains("device nonce required") || lower.contains("device nonce mismatch") {
|
|
return String(localized: "Secure handshake failed. Check Tailscale, then connect again.")
|
|
}
|
|
if lower.contains("tls fingerprint verification timed out")
|
|
|| lower.contains("no tls endpoint detected")
|
|
{
|
|
return raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
if lower.contains("timed out") {
|
|
return String(
|
|
localized: "Connection timed out. Make sure Tailscale is connected, then try again.")
|
|
}
|
|
if lower.contains("unauthorized role") {
|
|
return String(
|
|
localized: "Connected, but some controls are restricted for nodes. This is expected.")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isTransientSetupStatus(_ raw: String) -> Bool {
|
|
let lower = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let setupApplied = String(localized: "Setup code applied. Connecting...").lowercased()
|
|
let checkingReachability = String(localized: "Checking gateway reachability...").lowercased()
|
|
let qrFormat = String(localized: "QR loaded. Connecting to %@:%@...").lowercased()
|
|
return lower == setupApplied
|
|
|| Self.localizedFormat(qrFormat, matches: lower)
|
|
|| lower == checkingReachability
|
|
}
|
|
|
|
static func localizedFormat(_ format: String, matches value: String) -> Bool {
|
|
guard let placeholder = try? NSRegularExpression(pattern: #"%(\d+\$)?@"#) else {
|
|
return format == value
|
|
}
|
|
let formatRange = NSRange(format.startIndex..., in: format)
|
|
let matches = placeholder.matches(in: format, range: formatRange)
|
|
guard !matches.isEmpty else { return format == value }
|
|
|
|
var pattern = "^"
|
|
var cursor = format.startIndex
|
|
for match in matches {
|
|
guard let range = Range(match.range, in: format) else { return false }
|
|
pattern += NSRegularExpression.escapedPattern(for: String(format[cursor..<range.lowerBound]))
|
|
pattern += #"[\s\S]+?"#
|
|
cursor = range.upperBound
|
|
}
|
|
pattern += NSRegularExpression.escapedPattern(for: String(format[cursor...]))
|
|
pattern += "$"
|
|
return value.range(of: pattern, options: .regularExpression) != nil
|
|
}
|
|
|
|
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 String(localized: "Not loaded")
|
|
}
|
|
return self.appModel.talkMode.gatewayTalkApiKeyConfigured
|
|
? String(localized: "Configured")
|
|
: String(localized: "Not configured")
|
|
}
|
|
|
|
var gatewayTalkActiveVoiceDetail: String {
|
|
let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let subtitle = (self.appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if title.isEmpty { return String(localized: "Not active") }
|
|
if subtitle.isEmpty { return title }
|
|
return "\(title) • \(subtitle)"
|
|
}
|
|
|
|
var gatewayTalkLastIssueDetail: String? {
|
|
let detail = (self.appModel.talkMode.gatewayTalkLastIssueText ?? "")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return detail.isEmpty ? nil : detail
|
|
}
|
|
|
|
func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
|
|
var lines: [String] = []
|
|
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
|
|
if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") }
|
|
let gw = gateway.gatewayPort.map(String.init)
|
|
let canvas = gateway.canvasPort.map(String.init)
|
|
if gw != nil || canvas != nil {
|
|
lines.append("Ports: gateway \(gw ?? "-") / canvas \(canvas ?? "-")")
|
|
}
|
|
return lines.isEmpty ? [gateway.debugID] : lines
|
|
}
|
|
|
|
var gatewayConnected: Bool {
|
|
!self.appModel.isAppleReviewDemoModeEnabled &&
|
|
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
|
}
|
|
|
|
/// First-run state: no paired gateways yet (demo mode fakes a pairing), so
|
|
/// the status card surfaces Scan QR as the primary action.
|
|
var gatewayNeedsPairing: Bool {
|
|
self.gatewayRegistry.entries.isEmpty && !self.appModel.isAppleReviewDemoModeEnabled
|
|
}
|
|
|
|
var gatewayStatusDetail: String {
|
|
if self.appModel.isAppleReviewDemoModeEnabled {
|
|
return String(localized: "Apple Review demo mode")
|
|
}
|
|
return self.gatewayConnected
|
|
? String(localized: "Connected")
|
|
: self.appModel.gatewayDisplayStatusText
|
|
}
|
|
|
|
var gatewayStatusValue: String {
|
|
if self.appModel.isAppleReviewDemoModeEnabled { return String(localized: "demo") }
|
|
return self.gatewayConnected ? String(localized: "online") : String(localized: "offline")
|
|
}
|
|
|
|
var gatewayStatusColor: Color {
|
|
if self.appModel.isAppleReviewDemoModeEnabled { return OpenClawBrand.accent }
|
|
return self.gatewayConnected ? OpenClawBrand.ok : .secondary
|
|
}
|
|
|
|
var gatewayDiagnosticConnected: Bool {
|
|
self.appModel.isAppleReviewDemoModeEnabled || self.gatewayConnected
|
|
}
|
|
|
|
var gatewayDiagnosticTalkConfigLoaded: Bool {
|
|
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.talkMode.gatewayTalkConfigLoaded
|
|
}
|
|
|
|
var approvalEmptyDetail: String {
|
|
if self.appModel.isAppleReviewDemoModeEnabled {
|
|
return String(localized: "Live gateway requests are disabled in demo mode.")
|
|
}
|
|
if self.notificationsNeedAttention {
|
|
return String(
|
|
localized: "Foreground approvals still appear while OpenClaw is connected.")
|
|
}
|
|
return self.gatewayConnected
|
|
? String(localized: "Gateway requests will appear here.")
|
|
: String(localized: "Connect to the gateway.")
|
|
}
|
|
|
|
var gatewayTalkConfigDetail: String {
|
|
if self.appModel.isAppleReviewDemoModeEnabled { return String(localized: "Demo mode only") }
|
|
return self.appModel.talkMode.gatewayTalkTransportLabel
|
|
}
|
|
|
|
var gatewayTalkConfigValue: String {
|
|
if self.appModel.isAppleReviewDemoModeEnabled { return String(localized: "demo") }
|
|
return self.appModel.talkMode.gatewayTalkConfigLoaded
|
|
? String(localized: "loaded")
|
|
: String(localized: "missing")
|
|
}
|
|
|
|
var gatewayTalkConfigColor: Color {
|
|
if self.appModel.isAppleReviewDemoModeEnabled { return .secondary }
|
|
return self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary
|
|
}
|
|
|
|
var gatewayAddress: String {
|
|
self.appModel.gatewayRemoteAddress ?? String(localized: "Waiting for gateway")
|
|
}
|
|
|
|
var gatewayServer: String {
|
|
self.appModel.gatewayServerName ?? "OpenClaw Gateway"
|
|
}
|
|
|
|
var pendingApproval: NodeAppModel.ExecApprovalPrompt? {
|
|
self.appModel.pendingExecApprovalPrompt
|
|
}
|
|
|
|
var pendingApprovalCount: Int {
|
|
self.appModel.pendingExecApprovalCount
|
|
}
|
|
|
|
var approvalWaitingText: String {
|
|
if self.pendingApprovalCount == 1 {
|
|
return String(localized: "1 waiting")
|
|
}
|
|
return String(
|
|
format: String(localized: "%@ waiting"),
|
|
self.pendingApprovalCount.formatted())
|
|
}
|
|
|
|
var notificationsNeedAttention: Bool {
|
|
self.notificationPresentation.needsAttention
|
|
}
|
|
|
|
var approvalItems: [SettingsApprovalItem] {
|
|
guard let pendingApproval else { return [] }
|
|
let pendingTitle = pendingApproval.commandPreview.map(OpenClawTextValue.verbatim)
|
|
?? OpenClawTextValue.localized("Review gateway action")
|
|
let agentDetail = String(
|
|
format: String(localized: "Agent: %@"),
|
|
self.appModel.activeAgentName)
|
|
return [
|
|
SettingsApprovalItem(
|
|
id: "pending-real",
|
|
icon: "terminal.fill",
|
|
title: pendingTitle,
|
|
detail: .verbatim(agentDetail),
|
|
priority: self.appModel.pendingExecApprovalPromptResolving
|
|
? .localized("Resolving")
|
|
: .localized("High"),
|
|
color: OpenClawBrand.danger),
|
|
SettingsApprovalItem(
|
|
id: "pending-context",
|
|
icon: "doc.text.fill",
|
|
title: pendingApproval.allowsAllowAlways
|
|
? .localized("Permission can be saved")
|
|
: .localized("One-time approval"),
|
|
detail: "Gateway request",
|
|
priority: pendingApproval.allowsAllowAlways
|
|
? .localized("Medium")
|
|
: .localized("Review"),
|
|
color: OpenClawBrand.warn),
|
|
]
|
|
}
|
|
|
|
var voiceDetail: String {
|
|
if self.talkEnabled, self.voiceWakeEnabled { return String(localized: "Talk + Wake") }
|
|
if self.talkEnabled { return String(localized: "Talk on") }
|
|
if self.voiceWakeEnabled { return String(localized: "Wake on") }
|
|
return String(localized: "Off")
|
|
}
|
|
|
|
var diagnosticsHealthValue: String {
|
|
if self.appModel.isAppleReviewDemoModeEnabled { return String(localized: "demo") }
|
|
if self.gatewayConnected { return String(localized: "ready") }
|
|
if self.gatewayController.gateways.isEmpty { return String(localized: "check") }
|
|
return String(localized: "partial")
|
|
}
|
|
|
|
var diagnosticsRunValue: String {
|
|
guard let diagnosticsIssueCount else { return String(localized: "pending") }
|
|
return diagnosticsIssueCount == 0
|
|
? String(localized: "pass")
|
|
: diagnosticsIssueCount.formatted()
|
|
}
|
|
|
|
var diagnosticsRunColor: Color {
|
|
guard let diagnosticsIssueCount else { return .secondary }
|
|
return diagnosticsIssueCount == 0 ? OpenClawBrand.ok : OpenClawBrand.warn
|
|
}
|
|
|
|
var locationPermissionDetailText: String? {
|
|
if self.isChangingLocationMode {
|
|
return String(localized: "Requesting iOS location permission…")
|
|
}
|
|
return self.locationSettingsPresentation.statusText
|
|
}
|
|
|
|
var locationPermissionWarningText: String? {
|
|
guard let locationStatusText else { return nil }
|
|
guard locationStatusText != self.locationPermissionDetailText else { return nil }
|
|
return locationStatusText
|
|
}
|
|
|
|
var notificationStatusText: String {
|
|
self.notificationPresentation.text
|
|
}
|
|
|
|
var notificationStatusColor: Color {
|
|
self.notificationPresentation.color
|
|
}
|
|
|
|
var notificationServingActive: Bool {
|
|
self.notificationPresentation.isActive
|
|
}
|
|
|
|
var notificationDisclosureAccepted: Bool {
|
|
!PushBuildConfig.current.usesOpenClawHostedRelay
|
|
|| PushEnrollmentConsent.disclosureAccepted
|
|
}
|
|
|
|
var notificationToggleBinding: Binding<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 String(
|
|
format: String(
|
|
localized: "This build uses OpenClaw's hosted push relay at %@ for notification delivery data."),
|
|
host)
|
|
}
|
|
return String(
|
|
localized: "This build is not configured to use OpenClaw's hosted push relay.")
|
|
}
|
|
|
|
var notificationRelayDisclosureMessage: String {
|
|
String(
|
|
localized: "Enabling this sends delivery data through OpenClaw's hosted push relay.")
|
|
}
|
|
}
|