diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5b259dcc5ac..d36f1a94850 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
- Channels/CLI: add per-account/channel `defaultTo` outbound routing fallback so `openclaw agent --deliver` can send without explicit `--reply-to` when a default target is configured. (#16985) Thanks @KirillShchetinin.
- iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky.
- iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky.
+- iOS/Permissions: gate advertised iOS node capabilities/commands by live OS permission state (photos/contacts/calendar/reminders/motion), add Settings permission controls and disclosure, and refresh active gateway registration after permission-driven settings changes. (#22135) thanks @mbelinky.
- iOS/Tests: cover IPv4-mapped IPv6 loopback in manual TLS policy tests for connect validation paths. (#22045) Thanks @mbelinky.
- iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky.
- Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant.
diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift
index acfb9aab358..812cb593bf7 100644
--- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift
+++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift
@@ -1,15 +1,12 @@
import AVFoundation
-import Contacts
import CoreLocation
import CoreMotion
import CryptoKit
-import EventKit
import Foundation
import Darwin
import OpenClawKit
import Network
import Observation
-import Photos
import ReplayKit
import Security
import Speech
@@ -704,7 +701,7 @@ final class GatewayConnectionController {
var addr = in_addr()
let parsed = host.withCString { inet_pton(AF_INET, $0, &addr) == 1 }
guard parsed else { return false }
- let value = ntohl(addr.s_addr)
+ let value = UInt32(bigEndian: addr.s_addr)
let firstOctet = UInt8((value >> 24) & 0xFF)
return firstOctet == 127
}
@@ -783,6 +780,7 @@ final class GatewayConnectionController {
}
private func currentCaps() -> [String] {
+ let permissionSnapshot = IOSPermissionCenter.statusSnapshot()
var caps = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue]
// Default-on: if the key doesn't exist yet, treat it as enabled.
@@ -803,11 +801,19 @@ final class GatewayConnectionController {
if WatchMessagingService.isSupportedOnDevice() {
caps.append(OpenClawCapability.watch.rawValue)
}
- caps.append(OpenClawCapability.photos.rawValue)
- caps.append(OpenClawCapability.contacts.rawValue)
- caps.append(OpenClawCapability.calendar.rawValue)
- caps.append(OpenClawCapability.reminders.rawValue)
- if Self.motionAvailable() {
+ if permissionSnapshot.photosAllowed {
+ caps.append(OpenClawCapability.photos.rawValue)
+ }
+ if permissionSnapshot.contactsAllowed {
+ caps.append(OpenClawCapability.contacts.rawValue)
+ }
+ if permissionSnapshot.calendarReadAllowed || permissionSnapshot.calendarWriteAllowed {
+ caps.append(OpenClawCapability.calendar.rawValue)
+ }
+ if permissionSnapshot.remindersReadAllowed || permissionSnapshot.remindersWriteAllowed {
+ caps.append(OpenClawCapability.reminders.rawValue)
+ }
+ if Self.motionAvailable() && permissionSnapshot.motionAllowed {
caps.append(OpenClawCapability.motion.rawValue)
}
@@ -815,6 +821,7 @@ final class GatewayConnectionController {
}
private func currentCommands() -> [String] {
+ let permissionSnapshot = IOSPermissionCenter.statusSnapshot()
var commands: [String] = [
OpenClawCanvasCommand.present.rawValue,
OpenClawCanvasCommand.hide.rawValue,
@@ -858,12 +865,20 @@ final class GatewayConnectionController {
commands.append(OpenClawContactsCommand.add.rawValue)
}
if caps.contains(OpenClawCapability.calendar.rawValue) {
- commands.append(OpenClawCalendarCommand.events.rawValue)
- commands.append(OpenClawCalendarCommand.add.rawValue)
+ if permissionSnapshot.calendarReadAllowed {
+ commands.append(OpenClawCalendarCommand.events.rawValue)
+ }
+ if permissionSnapshot.calendarWriteAllowed {
+ commands.append(OpenClawCalendarCommand.add.rawValue)
+ }
}
if caps.contains(OpenClawCapability.reminders.rawValue) {
- commands.append(OpenClawRemindersCommand.list.rawValue)
- commands.append(OpenClawRemindersCommand.add.rawValue)
+ if permissionSnapshot.remindersReadAllowed {
+ commands.append(OpenClawRemindersCommand.list.rawValue)
+ }
+ if permissionSnapshot.remindersWriteAllowed {
+ commands.append(OpenClawRemindersCommand.add.rawValue)
+ }
}
if caps.contains(OpenClawCapability.motion.rawValue) {
commands.append(OpenClawMotionCommand.activity.rawValue)
@@ -874,6 +889,7 @@ final class GatewayConnectionController {
}
private func currentPermissions() -> [String: Bool] {
+ let permissionSnapshot = IOSPermissionCenter.statusSnapshot()
var permissions: [String: Bool] = [:]
permissions["camera"] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
permissions["microphone"] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
@@ -883,22 +899,23 @@ final class GatewayConnectionController {
&& CLLocationManager.locationServicesEnabled()
permissions["screenRecording"] = RPScreenRecorder.shared().isAvailable
- let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
- permissions["photos"] = photoStatus == .authorized || photoStatus == .limited
- let contactsStatus = CNContactStore.authorizationStatus(for: .contacts)
- permissions["contacts"] = contactsStatus == .authorized || contactsStatus == .limited
+ permissions["photos"] = permissionSnapshot.photosAllowed
+ permissions["photosDenied"] = permissionSnapshot.photos.isDeniedOrRestricted
+ permissions["contacts"] = permissionSnapshot.contactsAllowed
+ permissions["contactsDenied"] = permissionSnapshot.contacts.isDeniedOrRestricted
- let calendarStatus = EKEventStore.authorizationStatus(for: .event)
- permissions["calendar"] =
- calendarStatus == .authorized || calendarStatus == .fullAccess || calendarStatus == .writeOnly
- let remindersStatus = EKEventStore.authorizationStatus(for: .reminder)
- permissions["reminders"] =
- remindersStatus == .authorized || remindersStatus == .fullAccess || remindersStatus == .writeOnly
+ permissions["calendar"] = permissionSnapshot.calendarReadAllowed || permissionSnapshot.calendarWriteAllowed
+ permissions["calendarRead"] = permissionSnapshot.calendarReadAllowed
+ permissions["calendarWrite"] = permissionSnapshot.calendarWriteAllowed
+ permissions["calendarDenied"] = permissionSnapshot.calendar.isDeniedOrRestricted
- let motionStatus = CMMotionActivityManager.authorizationStatus()
- let pedometerStatus = CMPedometer.authorizationStatus()
- permissions["motion"] =
- motionStatus == .authorized || pedometerStatus == .authorized
+ permissions["reminders"] = permissionSnapshot.remindersReadAllowed || permissionSnapshot.remindersWriteAllowed
+ permissions["remindersRead"] = permissionSnapshot.remindersReadAllowed
+ permissions["remindersWrite"] = permissionSnapshot.remindersWriteAllowed
+ permissions["remindersDenied"] = permissionSnapshot.reminders.isDeniedOrRestricted
+
+ permissions["motion"] = permissionSnapshot.motionAllowed
+ permissions["motionDenied"] = permissionSnapshot.motion.isDeniedOrRestricted
let watchStatus = WatchMessagingService.currentStatusSnapshot()
permissions["watchSupported"] = watchStatus.supported
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index 37ab15e4a85..7ef344fc853 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -44,12 +44,22 @@
NSCameraUsageDescription
OpenClaw can capture photos or short video clips when requested via the gateway.
+ NSPhotoLibraryUsageDescription
+ OpenClaw can read your photo library when you ask it to share recent photos.
+ NSContactsUsageDescription
+ OpenClaw can read and create contacts when requested via the gateway.
NSLocalNetworkUsageDescription
OpenClaw discovers and connects to your OpenClaw gateway on the local network.
NSLocationAlwaysAndWhenInUseUsageDescription
OpenClaw can share your location in the background when you enable Always.
NSLocationWhenInUseUsageDescription
OpenClaw uses your location when you allow location sharing.
+ NSCalendarsFullAccessUsageDescription
+ OpenClaw can read and add calendar events when requested via the gateway.
+ NSRemindersFullAccessUsageDescription
+ OpenClaw can read and add reminders when requested via the gateway.
+ NSMotionUsageDescription
+ OpenClaw uses Motion & Fitness data for activity and pedometer commands.
NSMicrophoneUsageDescription
OpenClaw needs microphone access for voice wake.
NSSpeechRecognitionUsageDescription
diff --git a/apps/ios/Sources/Model/NodeAppModel+Permissions.swift b/apps/ios/Sources/Model/NodeAppModel+Permissions.swift
new file mode 100644
index 00000000000..9560b6035a8
--- /dev/null
+++ b/apps/ios/Sources/Model/NodeAppModel+Permissions.swift
@@ -0,0 +1,22 @@
+import Foundation
+import UIKit
+
+@MainActor
+extension NodeAppModel {
+ func permissionSnapshot() -> IOSPermissionSnapshot {
+ IOSPermissionCenter.statusSnapshot()
+ }
+
+ @discardableResult
+ func requestPermission(_ permission: IOSPermissionKind) async -> IOSPermissionSnapshot {
+ _ = await IOSPermissionCenter.request(permission)
+ return IOSPermissionCenter.statusSnapshot()
+ }
+
+ func openSystemSettings() {
+ guard let url = URL(string: UIApplication.openSettingsURLString) else {
+ return
+ }
+ UIApplication.shared.open(url)
+ }
+}
diff --git a/apps/ios/Sources/Permissions/IOSPermissionCenter.swift b/apps/ios/Sources/Permissions/IOSPermissionCenter.swift
new file mode 100644
index 00000000000..03a9b942a34
--- /dev/null
+++ b/apps/ios/Sources/Permissions/IOSPermissionCenter.swift
@@ -0,0 +1,303 @@
+import Contacts
+import CoreMotion
+import EventKit
+import Foundation
+import Photos
+
+enum IOSPermissionKind: String, CaseIterable, Identifiable, Sendable {
+ case photos
+ case contacts
+ case calendar
+ case reminders
+ case motion
+
+ var id: String { self.rawValue }
+
+ var title: String {
+ switch self {
+ case .photos:
+ "Photos"
+ case .contacts:
+ "Contacts"
+ case .calendar:
+ "Calendar"
+ case .reminders:
+ "Reminders"
+ case .motion:
+ "Motion & Fitness"
+ }
+ }
+}
+
+enum IOSPermissionState: String, Equatable, Sendable {
+ case granted
+ case limited
+ case writeOnly
+ case denied
+ case restricted
+ case notDetermined
+ case unavailable
+
+ var label: String {
+ switch self {
+ case .granted:
+ "Granted"
+ case .limited:
+ "Limited"
+ case .writeOnly:
+ "Write only"
+ case .denied:
+ "Denied"
+ case .restricted:
+ "Restricted"
+ case .notDetermined:
+ "Not requested"
+ case .unavailable:
+ "Unavailable"
+ }
+ }
+
+ var isDeniedOrRestricted: Bool {
+ self == .denied || self == .restricted
+ }
+}
+
+struct IOSPermissionSnapshot: Equatable, Sendable {
+ var photos: IOSPermissionState
+ var contacts: IOSPermissionState
+ var calendar: IOSPermissionState
+ var reminders: IOSPermissionState
+ var motion: IOSPermissionState
+
+ static let initial = IOSPermissionSnapshot(
+ photos: .notDetermined,
+ contacts: .notDetermined,
+ calendar: .notDetermined,
+ reminders: .notDetermined,
+ motion: .notDetermined)
+
+ func state(for kind: IOSPermissionKind) -> IOSPermissionState {
+ switch kind {
+ case .photos:
+ self.photos
+ case .contacts:
+ self.contacts
+ case .calendar:
+ self.calendar
+ case .reminders:
+ self.reminders
+ case .motion:
+ self.motion
+ }
+ }
+
+ var photosAllowed: Bool {
+ self.photos == .granted || self.photos == .limited
+ }
+
+ var contactsAllowed: Bool {
+ self.contacts == .granted || self.contacts == .limited
+ }
+
+ var calendarReadAllowed: Bool {
+ self.calendar == .granted
+ }
+
+ var calendarWriteAllowed: Bool {
+ self.calendar == .granted || self.calendar == .writeOnly
+ }
+
+ var remindersReadAllowed: Bool {
+ self.reminders == .granted
+ }
+
+ var remindersWriteAllowed: Bool {
+ self.reminders == .granted || self.reminders == .writeOnly
+ }
+
+ var motionAllowed: Bool {
+ self.motion == .granted
+ }
+}
+
+@MainActor
+enum IOSPermissionCenter {
+ static func statusSnapshot() -> IOSPermissionSnapshot {
+ IOSPermissionSnapshot(
+ photos: self.mapPhotoStatus(PHPhotoLibrary.authorizationStatus(for: .readWrite)),
+ contacts: self.mapContactsStatus(CNContactStore.authorizationStatus(for: .contacts)),
+ calendar: self.mapEventKitStatus(EKEventStore.authorizationStatus(for: .event)),
+ reminders: self.mapEventKitStatus(EKEventStore.authorizationStatus(for: .reminder)),
+ motion: self.motionState())
+ }
+
+ static func request(_ kind: IOSPermissionKind) async -> IOSPermissionState {
+ switch kind {
+ case .photos:
+ await self.requestPhotosIfNeeded()
+ case .contacts:
+ await self.requestContactsIfNeeded()
+ case .calendar:
+ await self.requestCalendarIfNeeded()
+ case .reminders:
+ await self.requestRemindersIfNeeded()
+ case .motion:
+ await self.requestMotionIfNeeded()
+ }
+ return self.statusSnapshot().state(for: kind)
+ }
+
+ private static func requestPhotosIfNeeded() async {
+ guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .notDetermined else {
+ return
+ }
+ _ = await withCheckedContinuation { (cont: CheckedContinuation) in
+ PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
+ cont.resume(returning: status)
+ }
+ }
+ }
+
+ private static func requestContactsIfNeeded() async {
+ guard CNContactStore.authorizationStatus(for: .contacts) == .notDetermined else {
+ return
+ }
+ let store = CNContactStore()
+ _ = await withCheckedContinuation { (cont: CheckedContinuation) in
+ store.requestAccess(for: .contacts) { granted, _ in
+ cont.resume(returning: granted)
+ }
+ }
+ }
+
+ private static func requestCalendarIfNeeded() async {
+ let status = EKEventStore.authorizationStatus(for: .event)
+ guard status == .notDetermined || status == .writeOnly else {
+ return
+ }
+ let store = EKEventStore()
+ _ = try? await store.requestFullAccessToEvents()
+ }
+
+ private static func requestRemindersIfNeeded() async {
+ let status = EKEventStore.authorizationStatus(for: .reminder)
+ guard status == .notDetermined || status == .writeOnly else {
+ return
+ }
+ let store = EKEventStore()
+ _ = try? await store.requestFullAccessToReminders()
+ }
+
+ private static func requestMotionIfNeeded() async {
+ guard self.motionState() == .notDetermined else {
+ return
+ }
+
+ let activityManager = CMMotionActivityManager()
+ await self.runPermissionProbe { complete in
+ let end = Date()
+ activityManager.queryActivityStarting(
+ from: end.addingTimeInterval(-120),
+ to: end,
+ to: OperationQueue()) { _, _ in
+ complete()
+ }
+ }
+
+ let pedometer = CMPedometer()
+ await self.runPermissionProbe { complete in
+ let end = Date()
+ pedometer.queryPedometerData(
+ from: end.addingTimeInterval(-120),
+ to: end) { _, _ in
+ complete()
+ }
+ }
+ }
+
+ private static func runPermissionProbe(start: (@escaping () -> Void) -> Void) async {
+ await withCheckedContinuation { (cont: CheckedContinuation) in
+ let lock = NSLock()
+ var resumed = false
+ start {
+ lock.lock()
+ defer { lock.unlock() }
+ guard !resumed else { return }
+ resumed = true
+ cont.resume(returning: ())
+ }
+ }
+ }
+
+ private static func mapPhotoStatus(_ status: PHAuthorizationStatus) -> IOSPermissionState {
+ switch status {
+ case .authorized:
+ .granted
+ case .limited:
+ .limited
+ case .denied:
+ .denied
+ case .restricted:
+ .restricted
+ case .notDetermined:
+ .notDetermined
+ @unknown default:
+ .restricted
+ }
+ }
+
+ private static func mapContactsStatus(_ status: CNAuthorizationStatus) -> IOSPermissionState {
+ switch status {
+ case .authorized:
+ .granted
+ case .limited:
+ .limited
+ case .denied:
+ .denied
+ case .restricted:
+ .restricted
+ case .notDetermined:
+ .notDetermined
+ @unknown default:
+ .restricted
+ }
+ }
+
+ private static func mapEventKitStatus(_ status: EKAuthorizationStatus) -> IOSPermissionState {
+ switch status {
+ case .authorized, .fullAccess:
+ .granted
+ case .writeOnly:
+ .writeOnly
+ case .denied:
+ .denied
+ case .restricted:
+ .restricted
+ case .notDetermined:
+ .notDetermined
+ @unknown default:
+ .restricted
+ }
+ }
+
+ private static func motionState() -> IOSPermissionState {
+ let available = CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable()
+ guard available else {
+ return .unavailable
+ }
+
+ let activity = CMMotionActivityManager.authorizationStatus()
+ let pedometer = CMPedometer.authorizationStatus()
+
+ if activity == .authorized || pedometer == .authorized {
+ return .granted
+ }
+ if activity == .restricted || pedometer == .restricted {
+ return .restricted
+ }
+ if activity == .denied || pedometer == .denied {
+ return .denied
+ }
+ return .notDetermined
+ }
+}
diff --git a/apps/ios/Sources/Settings/PermissionsDisclosureSection.swift b/apps/ios/Sources/Settings/PermissionsDisclosureSection.swift
new file mode 100644
index 00000000000..aa0cc8fb726
--- /dev/null
+++ b/apps/ios/Sources/Settings/PermissionsDisclosureSection.swift
@@ -0,0 +1,98 @@
+import SwiftUI
+
+struct PermissionsDisclosureSection: View {
+ let snapshot: IOSPermissionSnapshot
+ let requestingPermission: IOSPermissionKind?
+ let onRequest: (IOSPermissionKind) -> Void
+ let onOpenSettings: () -> Void
+ let onInfo: (IOSPermissionKind) -> Void
+
+ var body: some View {
+ DisclosureGroup("Permissions") {
+ self.permissionRow(.photos)
+ self.permissionRow(.contacts)
+ self.permissionRow(.calendar)
+ self.permissionRow(.reminders)
+ self.permissionRow(.motion)
+
+ Button {
+ self.onOpenSettings()
+ } label: {
+ Label("Open iOS Settings", systemImage: "gear")
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func permissionRow(_ kind: IOSPermissionKind) -> some View {
+ let state = self.snapshot.state(for: kind)
+ HStack(spacing: 8) {
+ Text(kind.title)
+ Spacer()
+ Text(state.label)
+ .font(.footnote)
+ .foregroundStyle(self.permissionStatusColor(for: state))
+ if self.requestingPermission == kind {
+ ProgressView()
+ .progressViewStyle(.circular)
+ }
+ if let action = self.permissionAction(for: state) {
+ Button(action.title) {
+ switch action {
+ case .request:
+ self.onRequest(kind)
+ case .openSettings:
+ self.onOpenSettings()
+ }
+ }
+ .disabled(self.requestingPermission != nil)
+ }
+ Button {
+ self.onInfo(kind)
+ } label: {
+ Image(systemName: "info.circle")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("\(kind.title) permission info")
+ }
+ }
+
+ private enum PermissionAction {
+ case request
+ case openSettings
+
+ var title: String {
+ switch self {
+ case .request:
+ "Request"
+ case .openSettings:
+ "Settings"
+ }
+ }
+ }
+
+ private func permissionAction(for state: IOSPermissionState) -> PermissionAction? {
+ switch state {
+ case .notDetermined, .writeOnly:
+ .request
+ case .denied, .restricted:
+ .openSettings
+ case .granted, .limited, .unavailable:
+ nil
+ }
+ }
+
+ private func permissionStatusColor(for state: IOSPermissionState) -> Color {
+ switch state {
+ case .granted, .limited:
+ .green
+ case .writeOnly:
+ .orange
+ case .denied, .restricted:
+ .red
+ case .notDetermined, .unavailable:
+ .secondary
+ }
+ }
+}
diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift
index a74f2fed952..0acf0530ffc 100644
--- a/apps/ios/Sources/Settings/SettingsTab.swift
+++ b/apps/ios/Sources/Settings/SettingsTab.swift
@@ -16,6 +16,7 @@ struct SettingsTab: View {
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@Environment(\.dismiss) private var dismiss
+ @Environment(\.scenePhase) private var scenePhase
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
@@ -42,7 +43,6 @@ struct SettingsTab: View {
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
@State private var connectingGatewayID: String?
- @State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
@State private var defaultShareInstruction: String = ""
@@ -51,6 +51,8 @@ struct SettingsTab: View {
@State private var manualGatewayPortText: String = ""
@State private var gatewayExpanded: Bool = true
@State private var selectedAgentPickerId: String = ""
+ @State private var permissionSnapshot: IOSPermissionSnapshot = .initial
+ @State private var requestingPermission: IOSPermissionKind?
@State private var showResetOnboardingAlert: Bool = false
@State private var activeFeatureHelp: FeatureHelp?
@@ -59,317 +61,23 @@ struct SettingsTab: View {
private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings")
var body: some View {
- NavigationStack {
- Form {
- Section {
- DisclosureGroup(isExpanded: self.$gatewayExpanded) {
- if !self.isGatewayConnected {
- Text(
- "1. Open Telegram and message your bot: /pair\n"
- + "2. Copy the setup code it returns\n"
- + "3. Paste here and tap Connect\n"
- + "4. Back in Telegram, run /pair approve")
- .font(.footnote)
- .foregroundStyle(.secondary)
+ self.settingsScreen
+ .gatewayTrustPromptAlert()
+ }
- if let warning = self.tailnetWarningText {
- Text(warning)
- .font(.footnote.weight(.semibold))
- .foregroundStyle(.orange)
- }
+ @ViewBuilder
+ private var settingsScreen: some View {
+ let base = NavigationStack {
+ self.settingsForm
+ }
+ self.lifecycleObservedSettingsScreen(self.presentedSettingsScreen(base))
+ }
- TextField("Paste setup code", text: self.$setupCode)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
-
- Button {
- Task { await self.applySetupCodeAndConnect() }
- } label: {
- if self.connectingGatewayID == "manual" {
- HStack(spacing: 8) {
- ProgressView()
- .progressViewStyle(.circular)
- Text("Connecting…")
- }
- } else {
- Text("Connect with setup code")
- }
- }
- .disabled(self.connectingGatewayID != nil
- || self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
-
- if let status = self.setupStatusLine {
- Text(status)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- }
-
- if self.isGatewayConnected {
- Picker("Bot", selection: self.$selectedAgentPickerId) {
- Text("Default").tag("")
- let defaultId = (self.appModel.gatewayDefaultAgentId ?? "")
- .trimmingCharacters(in: .whitespacesAndNewlines)
- ForEach(self.appModel.gatewayAgents.filter { $0.id != defaultId }, id: \.id) { agent in
- let name = (agent.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
- Text(name.isEmpty ? agent.id : name).tag(agent.id)
- }
- }
- Text("Controls which bot Chat and Talk speak to.")
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
-
- if self.appModel.gatewayServerName == nil {
- LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
- }
- LabeledContent("Status", value: self.appModel.gatewayStatusText)
- Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
-
- if let serverName = self.appModel.gatewayServerName {
- LabeledContent("Server", value: serverName)
- if let addr = self.appModel.gatewayRemoteAddress {
- let parts = Self.parseHostPort(from: addr)
- let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
- LabeledContent("Address") {
- Text(urlString)
- }
- .contextMenu {
- Button {
- UIPasteboard.general.string = urlString
- } label: {
- Label("Copy URL", systemImage: "doc.on.doc")
- }
-
- if let parts {
- Button {
- UIPasteboard.general.string = parts.host
- } label: {
- Label("Copy Host", systemImage: "doc.on.doc")
- }
-
- Button {
- UIPasteboard.general.string = "\(parts.port)"
- } label: {
- Label("Copy Port", systemImage: "doc.on.doc")
- }
- }
- }
- }
-
- Button("Disconnect", role: .destructive) {
- self.appModel.disconnectGateway()
- }
- } else {
- self.gatewayList(showing: .all)
- }
-
- DisclosureGroup("Advanced") {
- Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
-
- TextField("Host", text: self.$manualGatewayHost)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
-
- TextField("Port (optional)", text: self.manualPortBinding)
- .keyboardType(.numberPad)
-
- Toggle("Use TLS", isOn: self.$manualGatewayTLS)
-
- Button {
- Task { await self.connectManual() }
- } label: {
- if self.connectingGatewayID == "manual" {
- HStack(spacing: 8) {
- ProgressView()
- .progressViewStyle(.circular)
- Text("Connecting…")
- }
- } else {
- Text("Connect (Manual)")
- }
- }
- .disabled(self.connectingGatewayID != nil || self.manualGatewayHost
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .isEmpty || !self.manualPortIsValid)
-
- Text(
- "Use this when mDNS/Bonjour discovery is blocked. "
- + "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
- .font(.footnote)
- .foregroundStyle(.secondary)
-
- Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
- .onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
- self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
- }
-
- NavigationLink("Discovery Logs") {
- GatewayDiscoveryDebugLogView()
- }
-
- Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
-
- TextField("Gateway Auth Token", text: self.$gatewayToken)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
-
- SecureField("Gateway Password", text: self.$gatewayPassword)
-
- Button("Reset Onboarding", role: .destructive) {
- self.showResetOnboardingAlert = true
- }
-
- VStack(alignment: .leading, spacing: 6) {
- Text("Debug")
- .font(.footnote.weight(.semibold))
- .foregroundStyle(.secondary)
- Text(self.gatewayDebugText())
- .font(.system(size: 12, weight: .regular, design: .monospaced))
- .foregroundStyle(.secondary)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(10)
- .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
- }
- }
- } label: {
- HStack(spacing: 10) {
- Circle()
- .fill(self.isGatewayConnected ? Color.green : Color.secondary.opacity(0.35))
- .frame(width: 10, height: 10)
- Text("Gateway")
- Spacer()
- Text(self.gatewaySummaryText)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- }
- }
-
- Section("Device") {
- DisclosureGroup("Features") {
- self.featureToggle(
- "Voice Wake",
- isOn: self.$voiceWakeEnabled,
- help: "Enables wake-word activation to start a hands-free session.") { newValue in
- self.appModel.setVoiceWakeEnabled(newValue)
- }
- self.featureToggle(
- "Talk Mode",
- isOn: self.$talkEnabled,
- help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in
- self.appModel.setTalkEnabled(newValue)
- }
- self.featureToggle(
- "Background Listening",
- isOn: self.$talkBackgroundEnabled,
- help: "Keeps listening while the app is backgrounded. Uses more battery.")
-
- NavigationLink {
- VoiceWakeWordsSettingsView()
- } label: {
- LabeledContent(
- "Wake Words",
- value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
- }
-
- self.featureToggle(
- "Allow Camera",
- isOn: self.$cameraEnabled,
- help: "Allows the gateway to request photos or short video clips while OpenClaw is foregrounded.")
-
- HStack(spacing: 8) {
- Text("Location Access")
- Spacer()
- Button {
- self.activeFeatureHelp = FeatureHelp(
- title: "Location Access",
- message: "Controls location permissions for OpenClaw. Off disables location tools, While Using enables foreground location, and Always enables background location.")
- } label: {
- Image(systemName: "info.circle")
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.plain)
- .accessibilityLabel("Location Access info")
- }
- Picker("Location Access", selection: self.$locationEnabledModeRaw) {
- Text("Off").tag(OpenClawLocationMode.off.rawValue)
- Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
- Text("Always").tag(OpenClawLocationMode.always.rawValue)
- }
- .labelsHidden()
- .pickerStyle(.segmented)
-
- self.featureToggle(
- "Prevent Sleep",
- isOn: self.$preventSleep,
- help: "Keeps the screen awake while OpenClaw is open.")
-
- DisclosureGroup("Advanced") {
- self.featureToggle(
- "Voice Directive Hint",
- isOn: self.$talkVoiceDirectiveHintEnabled,
- help: "Adds voice-switching instructions to Talk prompts. Disable to reduce prompt size.")
- self.featureToggle(
- "Show Talk Button",
- isOn: self.$talkButtonEnabled,
- help: "Shows the floating Talk button in the main interface.")
- TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
- .lineLimit(2 ... 6)
- .textInputAutocapitalization(.sentences)
- HStack(spacing: 8) {
- Text("Default Share Instruction")
- .font(.footnote)
- .foregroundStyle(.secondary)
- Spacer()
- Button {
- self.activeFeatureHelp = FeatureHelp(
- title: "Default Share Instruction",
- message: "Appends this instruction when sharing content into OpenClaw from iOS.")
- } label: {
- Image(systemName: "info.circle")
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.plain)
- .accessibilityLabel("Default Share Instruction info")
- }
-
- VStack(alignment: .leading, spacing: 8) {
- Button {
- Task { await self.appModel.runSharePipelineSelfTest() }
- } label: {
- Label("Run Share Self-Test", systemImage: "checkmark.seal")
- }
- Text(self.appModel.lastShareEventText)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- }
- }
-
- DisclosureGroup("Device Info") {
- TextField("Name", text: self.$displayName)
- Text(self.instanceId)
- .font(.footnote)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
- LabeledContent("Device", value: self.deviceFamily())
- LabeledContent("Platform", value: self.platformString())
- LabeledContent("OpenClaw", value: self.openClawVersionString())
- }
- }
- }
+ private func presentedSettingsScreen(_ content: Content) -> some View {
+ content
.navigationTitle("Settings")
.toolbar {
- ToolbarItem(placement: .topBarTrailing) {
- Button {
- self.dismiss()
- } label: {
- Image(systemName: "xmark")
- }
- .accessibilityLabel("Close")
- }
+ self.closeToolbar
}
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button("Reset", role: .destructive) {
@@ -386,47 +94,42 @@ struct SettingsTab: View {
message: Text(help.message),
dismissButton: .default(Text("OK")))
}
+ }
+
+ @ToolbarContentBuilder
+ private var closeToolbar: some ToolbarContent {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ self.dismiss()
+ } label: {
+ Image(systemName: "xmark")
+ }
+ .accessibilityLabel("Close")
+ }
+ }
+
+ private func lifecycleObservedSettingsScreen(_ content: Content) -> some View {
+ content
.onAppear {
- self.lastLocationModeRaw = self.locationEnabledModeRaw
- self.syncManualPortText()
- let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
- if !trimmedInstanceId.isEmpty {
- self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
- self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
- }
- self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
- self.appModel.refreshLastShareEventFromRelay()
- // Keep setup front-and-center when disconnected; keep things compact once connected.
- self.gatewayExpanded = !self.isGatewayConnected
- self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
+ self.handleOnAppear()
+ }
+ .onChange(of: self.scenePhase) { _, newValue in
+ self.handleScenePhaseChange(newValue)
}
.onChange(of: self.selectedAgentPickerId) { _, newValue in
- let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
- self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
+ self.handleSelectedAgentPickerChange(newValue)
}
.onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
- if newValue != self.selectedAgentPickerId {
- self.selectedAgentPickerId = newValue
- }
+ self.handleAppSelectedAgentIdChange(newValue)
}
.onChange(of: self.preferredGatewayStableID) { _, newValue in
- let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return }
- GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
+ self.handlePreferredGatewayStableIdChange(newValue)
}
.onChange(of: self.gatewayToken) { _, newValue in
- guard !self.suppressCredentialPersist else { return }
- let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
- let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !instanceId.isEmpty else { return }
- GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
+ self.handleGatewayTokenChange(newValue)
}
.onChange(of: self.gatewayPassword) { _, newValue in
- guard !self.suppressCredentialPersist else { return }
- let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
- let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !instanceId.isEmpty else { return }
- GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
+ self.handleGatewayPasswordChange(newValue)
}
.onChange(of: self.defaultShareInstruction) { _, newValue in
ShareToAgentSettings.saveDefaultInstruction(newValue)
@@ -435,41 +138,430 @@ struct SettingsTab: View {
self.syncManualPortText()
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
- if newValue != nil {
- self.setupCode = ""
- self.setupStatusText = nil
- return
- }
- if self.manualGatewayEnabled {
- self.setupStatusText = self.appModel.gatewayStatusText
- }
+ self.handleGatewayServerNameChange(newValue)
}
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
- guard self.manualGatewayEnabled || self.connectingGatewayID == "manual" else { return }
- let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return }
- self.setupStatusText = trimmed
+ self.handleGatewayStatusTextChange(newValue)
}
- .onChange(of: self.locationEnabledModeRaw) { _, newValue in
- let previous = self.lastLocationModeRaw
- self.lastLocationModeRaw = newValue
- guard let mode = OpenClawLocationMode(rawValue: newValue) else { return }
- Task {
- let granted = await self.appModel.requestLocationPermissions(mode: mode)
- if !granted {
- await MainActor.run {
- self.locationEnabledModeRaw = previous
- self.lastLocationModeRaw = previous
+ .onChange(of: self.locationEnabledModeRaw) { oldValue, newValue in
+ self.handleLocationModeChange(from: oldValue, to: newValue)
+ }
+ }
+
+ private func handleOnAppear() {
+ self.syncManualPortText()
+ let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedInstanceId.isEmpty {
+ self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
+ self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
+ }
+ self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
+ self.appModel.refreshLastShareEventFromRelay()
+ // Keep setup front-and-center when disconnected; keep things compact once connected.
+ self.gatewayExpanded = !self.isGatewayConnected
+ self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
+ self.refreshPermissionSnapshot()
+ }
+
+ private func handleScenePhaseChange(_ newValue: ScenePhase) {
+ guard newValue == .active else { return }
+ self.refreshPermissionSnapshot()
+ self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
+ }
+
+ private func handleSelectedAgentPickerChange(_ newValue: String) {
+ let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
+ }
+
+ private func handleAppSelectedAgentIdChange(_ newValue: String) {
+ if newValue != self.selectedAgentPickerId {
+ self.selectedAgentPickerId = newValue
+ }
+ }
+
+ private func handlePreferredGatewayStableIdChange(_ newValue: String) {
+ let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
+ }
+
+ private func handleGatewayTokenChange(_ newValue: String) {
+ guard !self.suppressCredentialPersist else { return }
+ let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !instanceId.isEmpty else { return }
+ GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
+ }
+
+ private func handleGatewayPasswordChange(_ newValue: String) {
+ guard !self.suppressCredentialPersist else { return }
+ let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !instanceId.isEmpty else { return }
+ GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
+ }
+
+ private func handleGatewayServerNameChange(_ newValue: String?) {
+ if newValue != nil {
+ self.setupCode = ""
+ self.setupStatusText = nil
+ return
+ }
+ if self.manualGatewayEnabled {
+ self.setupStatusText = self.appModel.gatewayStatusText
+ }
+ }
+
+ private func handleGatewayStatusTextChange(_ newValue: String) {
+ guard self.manualGatewayEnabled || self.connectingGatewayID == "manual" else { return }
+ let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ self.setupStatusText = trimmed
+ }
+
+ private func handleLocationModeChange(from oldValue: String, to newValue: String) {
+ guard let mode = OpenClawLocationMode(rawValue: newValue) else { return }
+ Task {
+ let granted = await self.appModel.requestLocationPermissions(mode: mode)
+ if !granted {
+ await MainActor.run {
+ self.locationEnabledModeRaw = oldValue
+ }
+ return
+ }
+ await MainActor.run {
+ self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var settingsForm: some View {
+ Form {
+ self.gatewaySection
+ self.deviceSection
+ }
+ }
+
+ @ViewBuilder
+ private var gatewaySection: some View {
+ Section {
+ DisclosureGroup(isExpanded: self.$gatewayExpanded) {
+ if !self.isGatewayConnected {
+ Text(
+ "1. Open Telegram and message your bot: /pair\n"
+ + "2. Copy the setup code it returns\n"
+ + "3. Paste here and tap Connect\n"
+ + "4. Back in Telegram, run /pair approve")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+
+ if let warning = self.tailnetWarningText {
+ Text(warning)
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(.orange)
+ }
+
+ TextField("Paste setup code", text: self.$setupCode)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+
+ Button {
+ Task { await self.applySetupCodeAndConnect() }
+ } label: {
+ if self.connectingGatewayID == "manual" {
+ HStack(spacing: 8) {
+ ProgressView()
+ .progressViewStyle(.circular)
+ Text("Connecting…")
+ }
+ } else {
+ Text("Connect with setup code")
}
- return
}
- await MainActor.run {
- self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
+ .disabled(self.connectingGatewayID != nil
+ || self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+
+ if let status = self.setupStatusLine {
+ Text(status)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
}
}
+
+ if self.isGatewayConnected {
+ Picker("Bot", selection: self.$selectedAgentPickerId) {
+ Text("Default").tag("")
+ let defaultId = (self.appModel.gatewayDefaultAgentId ?? "")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ ForEach(self.appModel.gatewayAgents.filter { $0.id != defaultId }, id: \.id) { agent in
+ let name = (agent.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ Text(name.isEmpty ? agent.id : name).tag(agent.id)
+ }
+ }
+ Text("Controls which bot Chat and Talk speak to.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ if self.appModel.gatewayServerName == nil {
+ LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
+ }
+ LabeledContent("Status", value: self.appModel.gatewayStatusText)
+ Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
+
+ if let serverName = self.appModel.gatewayServerName {
+ LabeledContent("Server", value: serverName)
+ if let addr = self.appModel.gatewayRemoteAddress {
+ let parts = Self.parseHostPort(from: addr)
+ let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
+ LabeledContent("Address") {
+ Text(urlString)
+ }
+ .contextMenu {
+ Button {
+ UIPasteboard.general.string = urlString
+ } label: {
+ Label("Copy URL", systemImage: "doc.on.doc")
+ }
+
+ if let parts {
+ Button {
+ UIPasteboard.general.string = parts.host
+ } label: {
+ Label("Copy Host", systemImage: "doc.on.doc")
+ }
+
+ Button {
+ UIPasteboard.general.string = "\(parts.port)"
+ } label: {
+ Label("Copy Port", systemImage: "doc.on.doc")
+ }
+ }
+ }
+ }
+
+ Button("Disconnect", role: .destructive) {
+ self.appModel.disconnectGateway()
+ }
+ } else {
+ self.gatewayList(showing: .all)
+ }
+
+ DisclosureGroup("Advanced") {
+ Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
+
+ TextField("Host", text: self.$manualGatewayHost)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+
+ TextField("Port (optional)", text: self.manualPortBinding)
+ .keyboardType(.numberPad)
+
+ Toggle("Use TLS", isOn: self.$manualGatewayTLS)
+
+ Button {
+ Task { await self.connectManual() }
+ } label: {
+ if self.connectingGatewayID == "manual" {
+ HStack(spacing: 8) {
+ ProgressView()
+ .progressViewStyle(.circular)
+ Text("Connecting…")
+ }
+ } else {
+ Text("Connect (Manual)")
+ }
+ }
+ .disabled(self.connectingGatewayID != nil || self.manualGatewayHost
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .isEmpty || !self.manualPortIsValid)
+
+ Text(
+ "Use this when mDNS/Bonjour discovery is blocked. "
+ + "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+
+ Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
+ .onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
+ self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
+ }
+
+ NavigationLink("Discovery Logs") {
+ GatewayDiscoveryDebugLogView()
+ }
+
+ Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
+
+ TextField("Gateway Auth Token", text: self.$gatewayToken)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+
+ SecureField("Gateway Password", text: self.$gatewayPassword)
+
+ Button("Reset Onboarding", role: .destructive) {
+ self.showResetOnboardingAlert = true
+ }
+
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Debug")
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Text(self.gatewayDebugText())
+ .font(.system(size: 12, weight: .regular, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(10)
+ .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
+ }
+ }
+ } label: {
+ HStack(spacing: 10) {
+ Circle()
+ .fill(self.isGatewayConnected ? Color.green : Color.secondary.opacity(0.35))
+ .frame(width: 10, height: 10)
+ Text("Gateway")
+ Spacer()
+ Text(self.gatewaySummaryText)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
}
}
- .gatewayTrustPromptAlert()
+ }
+
+ @ViewBuilder
+ private var deviceSection: some View {
+ Section("Device") {
+ DisclosureGroup("Features") {
+ self.featureToggle(
+ "Voice Wake",
+ isOn: self.$voiceWakeEnabled,
+ help: "Enables wake-word activation to start a hands-free session.") { newValue in
+ self.appModel.setVoiceWakeEnabled(newValue)
+ }
+ self.featureToggle(
+ "Talk Mode",
+ isOn: self.$talkEnabled,
+ help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in
+ self.appModel.setTalkEnabled(newValue)
+ }
+ self.featureToggle(
+ "Background Listening",
+ isOn: self.$talkBackgroundEnabled,
+ help: "Keeps listening while the app is backgrounded. Uses more battery.")
+
+ NavigationLink {
+ VoiceWakeWordsSettingsView()
+ } label: {
+ LabeledContent(
+ "Wake Words",
+ value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
+ }
+
+ self.featureToggle(
+ "Allow Camera",
+ isOn: self.$cameraEnabled,
+ help: "Allows the gateway to request photos or short video clips while OpenClaw is foregrounded.")
+
+ HStack(spacing: 8) {
+ Text("Location Access")
+ Spacer()
+ Button {
+ self.activeFeatureHelp = FeatureHelp(
+ title: "Location Access",
+ message: "Controls location permissions for OpenClaw. Off disables location tools, While Using enables foreground location, and Always enables background location.")
+ } label: {
+ Image(systemName: "info.circle")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Location Access info")
+ }
+ Picker("Location Access", selection: self.$locationEnabledModeRaw) {
+ Text("Off").tag(OpenClawLocationMode.off.rawValue)
+ Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
+ Text("Always").tag(OpenClawLocationMode.always.rawValue)
+ }
+ .labelsHidden()
+ .pickerStyle(.segmented)
+
+ self.featureToggle(
+ "Prevent Sleep",
+ isOn: self.$preventSleep,
+ help: "Keeps the screen awake while OpenClaw is open.")
+
+ DisclosureGroup("Advanced") {
+ self.featureToggle(
+ "Voice Directive Hint",
+ isOn: self.$talkVoiceDirectiveHintEnabled,
+ help: "Adds voice-switching instructions to Talk prompts. Disable to reduce prompt size.")
+ self.featureToggle(
+ "Show Talk Button",
+ isOn: self.$talkButtonEnabled,
+ help: "Shows the floating Talk button in the main interface.")
+ TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
+ .lineLimit(2 ... 6)
+ .textInputAutocapitalization(.sentences)
+ HStack(spacing: 8) {
+ Text("Default Share Instruction")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Button {
+ self.activeFeatureHelp = FeatureHelp(
+ title: "Default Share Instruction",
+ message: "Appends this instruction when sharing content into OpenClaw from iOS.")
+ } label: {
+ Image(systemName: "info.circle")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Default Share Instruction info")
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ Button {
+ Task { await self.appModel.runSharePipelineSelfTest() }
+ } label: {
+ Label("Run Share Self-Test", systemImage: "checkmark.seal")
+ }
+ Text(self.appModel.lastShareEventText)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ DisclosureGroup("Device Info") {
+ TextField("Name", text: self.$displayName)
+ Text(self.instanceId)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ LabeledContent("Device", value: self.deviceFamily())
+ LabeledContent("Platform", value: self.platformString())
+ LabeledContent("OpenClaw", value: self.openClawVersionString())
+ }
+
+ PermissionsDisclosureSection(
+ snapshot: self.permissionSnapshot,
+ requestingPermission: self.requestingPermission,
+ onRequest: { kind in
+ Task { await self.requestPermission(kind) }
+ },
+ onOpenSettings: {
+ self.appModel.openSystemSettings()
+ },
+ onInfo: { kind in
+ self.activeFeatureHelp = FeatureHelp(
+ title: kind.title,
+ message: self.permissionHelp(for: kind))
+ })
+ }
}
@ViewBuilder
@@ -609,6 +701,33 @@ struct SettingsTab: View {
}
}
+ private func permissionHelp(for kind: IOSPermissionKind) -> String {
+ switch kind {
+ case .photos:
+ "Required for photos.latest tool access."
+ case .contacts:
+ "Required for contacts.search and contacts.add."
+ case .calendar:
+ "Full access enables calendar.events and calendar.add."
+ case .reminders:
+ "Full access enables reminders.list and reminders.add."
+ case .motion:
+ "Required for motion.activity and motion.pedometer."
+ }
+ }
+
+ private func refreshPermissionSnapshot() {
+ self.permissionSnapshot = self.appModel.permissionSnapshot()
+ }
+
+ private func requestPermission(_ kind: IOSPermissionKind) async {
+ self.requestingPermission = kind
+ _ = await self.appModel.requestPermission(kind)
+ self.refreshPermissionSnapshot()
+ self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
+ self.requestingPermission = nil
+ }
+
private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
self.connectingGatewayID = gateway.id
self.manualGatewayEnabled = false
diff --git a/apps/ios/project.yml b/apps/ios/project.yml
index 9b43db118ef..8268cbd29a6 100644
--- a/apps/ios/project.yml
+++ b/apps/ios/project.yml
@@ -108,8 +108,13 @@ targets:
NSBonjourServices:
- _openclaw-gw._tcp
NSCameraUsageDescription: OpenClaw can capture photos or short video clips when requested via the gateway.
+ NSPhotoLibraryUsageDescription: OpenClaw can read your photo library when you ask it to share recent photos.
+ NSContactsUsageDescription: OpenClaw can read and create contacts when requested via the gateway.
NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing.
NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always.
+ NSCalendarsFullAccessUsageDescription: OpenClaw can read and add calendar events when requested via the gateway.
+ NSRemindersFullAccessUsageDescription: OpenClaw can read and add reminders when requested via the gateway.
+ NSMotionUsageDescription: OpenClaw uses Motion & Fitness data for activity and pedometer commands.
NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake.
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
UISupportedInterfaceOrientations: