diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c8864a4e69..59fc9f276b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Fixes
+- iOS: restore first-use Contacts, Calendar, and Reminders permission prompts and add Privacy & Access status/actions in Settings. Thanks @BunsDev.
- CLI/migrate: humanize Codex conflict-status messaging across the migrate UI so selection prompts and plan/result rows say "Codex skill already installed in workspace" instead of surfacing internal `MIGRATION_REASON_*` codes. Thanks @sjf.
- CLI/migrate: render migrate result rows with distinct glyphs for manual-review (π) and archive (π) items instead of the misleading "skipped" and "migrated" checkmarks, so users can see which entries still need attention versus which were filed away. Thanks @sjf.
- CLI/migrate: split Codex migrate output into separate preview and result phases so the Before plan and After result render through clack with independently tunable copy. Thanks @sjf.
diff --git a/apps/ios/Sources/Calendar/CalendarService.swift b/apps/ios/Sources/Calendar/CalendarService.swift
index 94b2d9ea3f5..bed56de4e8f 100644
--- a/apps/ios/Sources/Calendar/CalendarService.swift
+++ b/apps/ios/Sources/Calendar/CalendarService.swift
@@ -4,15 +4,19 @@ import OpenClawKit
final class CalendarService: CalendarServicing {
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload {
- let store = EKEventStore()
let status = EKEventStore.authorizationStatus(for: .event)
- let authorized = EventKitAuthorization.allowsRead(status: status)
+ let authorized: Bool = if status == .notDetermined || status == .writeOnly {
+ await Self.requestFullEventAccess()
+ } else {
+ EventKitAuthorization.allowsRead(status: status)
+ }
guard authorized else {
throw NSError(domain: "Calendar", code: 1, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
])
}
+ let store = EKEventStore()
let (start, end) = Self.resolveRange(
startISO: params.startISO,
endISO: params.endISO)
@@ -37,15 +41,19 @@ final class CalendarService: CalendarServicing {
}
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload {
- let store = EKEventStore()
let status = EKEventStore.authorizationStatus(for: .event)
- let authorized = EventKitAuthorization.allowsWrite(status: status)
+ let authorized: Bool = if status == .notDetermined {
+ await Self.requestWriteOnlyEventAccess()
+ } else {
+ EventKitAuthorization.allowsWrite(status: status)
+ }
guard authorized else {
throw NSError(domain: "Calendar", code: 2, userInfo: [
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
])
}
+ let store = EKEventStore()
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else {
throw NSError(domain: "Calendar", code: 3, userInfo: [
@@ -95,6 +103,24 @@ final class CalendarService: CalendarServicing {
return OpenClawCalendarAddPayload(event: payload)
}
+ private static func requestFullEventAccess() async -> Bool {
+ await PermissionRequestBridge.awaitRequest { completion in
+ let store = EKEventStore()
+ store.requestFullAccessToEvents { granted, _ in
+ completion(granted)
+ }
+ }
+ }
+
+ private static func requestWriteOnlyEventAccess() async -> Bool {
+ await PermissionRequestBridge.awaitRequest { completion in
+ let store = EKEventStore()
+ store.requestWriteOnlyAccessToEvents { granted, _ in
+ completion(granted)
+ }
+ }
+ }
+
private static func resolveCalendar(
store: EKEventStore,
calendarId: String?,
diff --git a/apps/ios/Sources/Contacts/ContactsService.swift b/apps/ios/Sources/Contacts/ContactsService.swift
index c2881436a04..1a25948829a 100644
--- a/apps/ios/Sources/Contacts/ContactsService.swift
+++ b/apps/ios/Sources/Contacts/ContactsService.swift
@@ -97,14 +97,17 @@ final class ContactsService: ContactsServicing {
return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
}
- private static func ensureAuthorization(store: CNContactStore, status: CNAuthorizationStatus) async -> Bool {
+ private static func ensureAuthorization(status: CNAuthorizationStatus) async -> Bool {
switch status {
case .authorized, .limited:
return true
case .notDetermined:
- // Donβt prompt during node.invoke; the caller should instruct the user to grant permission.
- // Prompts block the invoke and lead to timeouts in headless flows.
- return false
+ return await PermissionRequestBridge.awaitRequest { completion in
+ let store = CNContactStore()
+ store.requestAccess(for: .contacts) { granted, _ in
+ completion(granted)
+ }
+ }
case .restricted, .denied:
return false
@unknown default:
@@ -113,15 +116,14 @@ final class ContactsService: ContactsServicing {
}
private static func authorizedStore() async throws -> CNContactStore {
- let store = CNContactStore()
let status = CNContactStore.authorizationStatus(for: .contacts)
- let authorized = await Self.ensureAuthorization(store: store, status: status)
+ let authorized = await Self.ensureAuthorization(status: status)
guard authorized else {
throw NSError(domain: "Contacts", code: 1, userInfo: [
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
])
}
- return store
+ return CNContactStore()
}
private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] {
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index 94ef26d355c..70810de5619 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -52,6 +52,14 @@
NSCameraUsageDescription
OpenClaw can capture photos or short video clips when requested via the gateway.
+ NSCalendarsUsageDescription
+ OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.
+ NSCalendarsFullAccessUsageDescription
+ OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.
+ NSCalendarsWriteOnlyAccessUsageDescription
+ OpenClaw uses your calendars to add events when you enable calendar access.
+ NSContactsUsageDescription
+ OpenClaw uses your contacts so you can search and reference people while using the assistant.
NSLocalNetworkUsageDescription
OpenClaw discovers and connects to your OpenClaw gateway on the local network.
NSLocationAlwaysAndWhenInUseUsageDescription
@@ -64,6 +72,8 @@
OpenClaw may use motion data to support device-aware interactions and automations.
NSPhotoLibraryUsageDescription
OpenClaw needs photo library access when you choose existing photos to share with your assistant.
+ NSRemindersFullAccessUsageDescription
+ OpenClaw uses your reminders to list, add, and complete tasks when you enable reminders access.
NSSpeechRecognitionUsageDescription
OpenClaw uses on-device speech recognition for voice wake.
NSSupportsLiveActivities
diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
index 3ae10c70417..c084b0e4de8 100644
--- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
+++ b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
@@ -1,4 +1,5 @@
import Foundation
+import OpenClawKit
import SwiftUI
struct GatewayOnboardingView: View {
diff --git a/apps/ios/Sources/Permissions/PermissionRequestBridge.swift b/apps/ios/Sources/Permissions/PermissionRequestBridge.swift
new file mode 100644
index 00000000000..927b5679f18
--- /dev/null
+++ b/apps/ios/Sources/Permissions/PermissionRequestBridge.swift
@@ -0,0 +1,64 @@
+import Foundation
+
+enum PermissionRequestBridge {
+ final class Box: @unchecked Sendable {
+ private let lock = NSLock()
+ private var continuation: CheckedContinuation?
+ private var hasResumed = false
+
+ func install(_ continuation: CheckedContinuation) -> Bool {
+ self.lock.lock()
+ if self.hasResumed {
+ self.lock.unlock()
+ continuation.resume(returning: false)
+ return false
+ }
+ self.continuation = continuation
+ self.lock.unlock()
+ return true
+ }
+
+ func resume(_ value: Bool) {
+ self.lock.lock()
+ guard !self.hasResumed else {
+ self.lock.unlock()
+ return
+ }
+ self.hasResumed = true
+ let continuation = self.continuation
+ self.continuation = nil
+ self.lock.unlock()
+ continuation?.resume(returning: value)
+ }
+
+ func canStartRequest() -> Bool {
+ self.lock.lock()
+ let canStart = !self.hasResumed
+ self.lock.unlock()
+ return canStart
+ }
+ }
+
+ static func awaitRequest(
+ _ start: @escaping @Sendable (@escaping @Sendable (Bool) -> Void) -> Void) async -> Bool
+ {
+ let box = Box()
+ return await withTaskCancellationHandler {
+ await withCheckedContinuation(isolation: nil) { continuation in
+ guard !Task.isCancelled else {
+ continuation.resume(returning: false)
+ return
+ }
+ guard box.install(continuation) else { return }
+ Task { @MainActor in
+ guard box.canStartRequest() else { return }
+ start { granted in
+ box.resume(granted)
+ }
+ }
+ }
+ } onCancel: {
+ box.resume(false)
+ }
+ }
+}
diff --git a/apps/ios/Sources/Reminders/RemindersService.swift b/apps/ios/Sources/Reminders/RemindersService.swift
index 19591908fb9..da6ff67f8bb 100644
--- a/apps/ios/Sources/Reminders/RemindersService.swift
+++ b/apps/ios/Sources/Reminders/RemindersService.swift
@@ -4,15 +4,19 @@ import OpenClawKit
final class RemindersService: RemindersServicing {
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload {
- let store = EKEventStore()
let status = EKEventStore.authorizationStatus(for: .reminder)
- let authorized = EventKitAuthorization.allowsRead(status: status)
+ let authorized: Bool = if status == .notDetermined || status == .writeOnly {
+ await Self.requestFullReminderAccess()
+ } else {
+ EventKitAuthorization.allowsRead(status: status)
+ }
guard authorized else {
throw NSError(domain: "Reminders", code: 1, userInfo: [
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
])
}
+ let store = EKEventStore()
let limit = max(1, min(params.limit ?? 50, 500))
let statusFilter = params.status ?? .incomplete
@@ -48,15 +52,19 @@ final class RemindersService: RemindersServicing {
}
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload {
- let store = EKEventStore()
let status = EKEventStore.authorizationStatus(for: .reminder)
- let authorized = EventKitAuthorization.allowsWrite(status: status)
+ let authorized: Bool = if status == .notDetermined {
+ await Self.requestFullReminderAccess()
+ } else {
+ EventKitAuthorization.allowsWrite(status: status)
+ }
guard authorized else {
throw NSError(domain: "Reminders", code: 2, userInfo: [
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
])
}
+ let store = EKEventStore()
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else {
throw NSError(domain: "Reminders", code: 3, userInfo: [
@@ -100,6 +108,15 @@ final class RemindersService: RemindersServicing {
return OpenClawRemindersAddPayload(reminder: payload)
}
+ private static func requestFullReminderAccess() async -> Bool {
+ await PermissionRequestBridge.awaitRequest { completion in
+ let store = EKEventStore()
+ store.requestFullAccessToReminders { granted, _ in
+ completion(granted)
+ }
+ }
+ }
+
private static func resolveList(
store: EKEventStore,
listId: String?,
diff --git a/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift b/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift
new file mode 100644
index 00000000000..a69c02db7d6
--- /dev/null
+++ b/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift
@@ -0,0 +1,298 @@
+import Contacts
+import EventKit
+import SwiftUI
+import UIKit
+
+struct PrivacyAccessSectionView: View {
+ @State private var contactsStatus: CNAuthorizationStatus = CNContactStore.authorizationStatus(for: .contacts)
+ @State private var calendarStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .event)
+ @State private var remindersStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .reminder)
+
+ @Environment(\.scenePhase) private var scenePhase
+
+ var body: some View {
+ DisclosureGroup("Privacy & Access") {
+ self.permissionRow(
+ title: "Contacts",
+ icon: "person.crop.circle",
+ status: self.statusText(for: self.contactsStatus),
+ detail: "Search and add contacts from the assistant.",
+ actionTitle: self.actionTitle(for: self.contactsStatus),
+ action: self.handleContactsAction)
+
+ self.permissionRow(
+ title: "Calendar (Add Events)",
+ icon: "calendar.badge.plus",
+ status: self.calendarWriteStatusText,
+ detail: "Add events with least privilege.",
+ actionTitle: self.calendarWriteActionTitle,
+ action: self.handleCalendarWriteAction)
+
+ self.permissionRow(
+ title: "Calendar (View Events)",
+ icon: "calendar",
+ status: self.calendarReadStatusText,
+ detail: "List and read calendar events.",
+ actionTitle: self.calendarReadActionTitle,
+ action: self.handleCalendarReadAction)
+
+ self.permissionRow(
+ title: "Reminders",
+ icon: "checklist",
+ status: self.remindersStatusText,
+ detail: "List, add, and complete reminders.",
+ actionTitle: self.remindersActionTitle,
+ action: self.handleRemindersAction)
+ }
+ .onAppear { self.refreshAll() }
+ .onChange(of: self.scenePhase) { _, phase in
+ if phase == .active {
+ self.refreshAll()
+ }
+ }
+ }
+
+ private func permissionRow(
+ title: String,
+ icon: String,
+ status: String,
+ detail: String,
+ actionTitle: String?,
+ action: (() -> Void)?) -> some View
+ {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack {
+ Label(title, systemImage: icon)
+ Spacer()
+ Text(status)
+ .font(.footnote.weight(.medium))
+ .foregroundStyle(self.statusColor(for: status))
+ }
+ Text(detail)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ if let actionTitle, let action {
+ Button(actionTitle, action: action)
+ .font(.footnote)
+ .buttonStyle(.bordered)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+
+ private func statusColor(for status: String) -> Color {
+ switch status {
+ case "Allowed":
+ .green
+ case "Not Set":
+ .orange
+ case "Add-Only":
+ .yellow
+ default:
+ .red
+ }
+ }
+
+ private func statusText(for cnStatus: CNAuthorizationStatus) -> String {
+ switch cnStatus {
+ case .authorized, .limited:
+ "Allowed"
+ case .notDetermined:
+ "Not Set"
+ case .denied, .restricted:
+ "Not Allowed"
+ @unknown default:
+ "Unknown"
+ }
+ }
+
+ private func actionTitle(for cnStatus: CNAuthorizationStatus) -> String? {
+ switch cnStatus {
+ case .notDetermined:
+ "Request Access"
+ case .denied, .restricted:
+ "Open Settings"
+ default:
+ nil
+ }
+ }
+
+ private func handleContactsAction() {
+ switch self.contactsStatus {
+ case .notDetermined:
+ Task {
+ _ = await PermissionRequestBridge.awaitRequest { completion in
+ let store = CNContactStore()
+ store.requestAccess(for: .contacts) { granted, _ in
+ completion(granted)
+ }
+ }
+ await MainActor.run { self.refreshAll() }
+ }
+ case .denied, .restricted:
+ self.openSettings()
+ default:
+ break
+ }
+ }
+
+ private var calendarWriteStatusText: String {
+ switch self.calendarStatus {
+ case .authorized, .fullAccess, .writeOnly:
+ "Allowed"
+ case .notDetermined:
+ "Not Set"
+ case .denied, .restricted:
+ "Not Allowed"
+ @unknown default:
+ "Unknown"
+ }
+ }
+
+ private var calendarWriteActionTitle: String? {
+ switch self.calendarStatus {
+ case .notDetermined:
+ "Request Access"
+ case .denied, .restricted:
+ "Open Settings"
+ default:
+ nil
+ }
+ }
+
+ private func handleCalendarWriteAction() {
+ switch self.calendarStatus {
+ case .notDetermined:
+ Task {
+ _ = await self.requestCalendarWriteOnly()
+ await MainActor.run { self.refreshAll() }
+ }
+ case .denied, .restricted:
+ self.openSettings()
+ default:
+ break
+ }
+ }
+
+ private var calendarReadStatusText: String {
+ switch self.calendarStatus {
+ case .authorized, .fullAccess:
+ "Allowed"
+ case .writeOnly:
+ "Add-Only"
+ case .notDetermined:
+ "Not Set"
+ case .denied, .restricted:
+ "Not Allowed"
+ @unknown default:
+ "Unknown"
+ }
+ }
+
+ private var calendarReadActionTitle: String? {
+ switch self.calendarStatus {
+ case .notDetermined:
+ "Request Full Access"
+ case .writeOnly:
+ "Upgrade to Full Access"
+ case .denied, .restricted:
+ "Open Settings"
+ default:
+ nil
+ }
+ }
+
+ private func handleCalendarReadAction() {
+ switch self.calendarStatus {
+ case .notDetermined, .writeOnly:
+ Task {
+ _ = await self.requestCalendarFull()
+ await MainActor.run { self.refreshAll() }
+ }
+ case .denied, .restricted:
+ self.openSettings()
+ default:
+ break
+ }
+ }
+
+ private var remindersStatusText: String {
+ switch self.remindersStatus {
+ case .authorized, .fullAccess:
+ "Allowed"
+ case .writeOnly:
+ "Add-Only"
+ case .notDetermined:
+ "Not Set"
+ case .denied, .restricted:
+ "Not Allowed"
+ @unknown default:
+ "Unknown"
+ }
+ }
+
+ private var remindersActionTitle: String? {
+ switch self.remindersStatus {
+ case .notDetermined:
+ "Request Access"
+ case .writeOnly:
+ "Upgrade to Full Access"
+ case .denied, .restricted:
+ "Open Settings"
+ default:
+ nil
+ }
+ }
+
+ private func handleRemindersAction() {
+ switch self.remindersStatus {
+ case .notDetermined, .writeOnly:
+ Task {
+ _ = await self.requestRemindersFull()
+ await MainActor.run { self.refreshAll() }
+ }
+ case .denied, .restricted:
+ self.openSettings()
+ default:
+ break
+ }
+ }
+
+ private func refreshAll() {
+ self.contactsStatus = CNContactStore.authorizationStatus(for: .contacts)
+ self.calendarStatus = EKEventStore.authorizationStatus(for: .event)
+ self.remindersStatus = EKEventStore.authorizationStatus(for: .reminder)
+ }
+
+ private func requestCalendarWriteOnly() async -> Bool {
+ await PermissionRequestBridge.awaitRequest { completion in
+ let store = EKEventStore()
+ store.requestWriteOnlyAccessToEvents { granted, _ in
+ completion(granted)
+ }
+ }
+ }
+
+ private func requestCalendarFull() async -> Bool {
+ await PermissionRequestBridge.awaitRequest { completion in
+ let store = EKEventStore()
+ store.requestFullAccessToEvents { granted, _ in
+ completion(granted)
+ }
+ }
+ }
+
+ private func requestRemindersFull() async -> Bool {
+ await PermissionRequestBridge.awaitRequest { completion in
+ let store = EKEventStore()
+ store.requestFullAccessToReminders { granted, _ in
+ completion(granted)
+ }
+ }
+ }
+
+ private func openSettings() {
+ guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
+ UIApplication.shared.open(url)
+ }
+}
diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift
index c9a08b00ac2..5524c81e124 100644
--- a/apps/ios/Sources/Settings/SettingsTab.swift
+++ b/apps/ios/Sources/Settings/SettingsTab.swift
@@ -405,6 +405,8 @@ struct SettingsTab: View {
}
}
+ AnyView(PrivacyAccessSectionView())
+
DisclosureGroup("Device Info") {
TextField("Name", text: self.$displayName)
Text(self.instanceId)
@@ -419,16 +421,7 @@ struct SettingsTab: View {
}
}
.navigationTitle("Settings")
- .toolbar {
- ToolbarItem(placement: .topBarTrailing) {
- Button {
- self.dismiss()
- } label: {
- Image(systemName: "xmark")
- }
- .accessibilityLabel("Close")
- }
- }
+ .modifier(SettingsCloseToolbar())
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
@@ -488,90 +481,91 @@ struct SettingsTab: View {
Text(self.scannerError ?? "")
}
.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.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 ?? ""
+ if self.isGatewayConnected {
+ self.appModel.reloadTalkConfig()
+ }
}
- 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 ?? ""
- if self.isGatewayConnected {
- self.appModel.reloadTalkConfig()
+ .onChange(of: self.selectedAgentPickerId) { _, newValue in
+ let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
}
- }
- .onChange(of: self.selectedAgentPickerId) { _, newValue in
- let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
- self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
- }
- .onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
- if newValue != self.selectedAgentPickerId {
- self.selectedAgentPickerId = newValue
+ .onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
+ if newValue != self.selectedAgentPickerId {
+ self.selectedAgentPickerId = newValue
+ }
}
- }
- .onChange(of: self.preferredGatewayStableID) { _, newValue in
- let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return }
- GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
- }
- .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)
- }
- .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)
- }
- .onChange(of: self.defaultShareInstruction) { _, newValue in
- ShareToAgentSettings.saveDefaultInstruction(newValue)
- }
- .onChange(of: self.manualGatewayPort) { _, _ in
- self.syncManualPortText()
- }
- .onChange(of: self.appModel.gatewayServerName) { _, newValue in
- if newValue != nil {
- self.setupCode = ""
- self.setupStatusText = nil
- return
+ .onChange(of: self.preferredGatewayStableID) { _, newValue in
+ let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
}
- if self.manualGatewayEnabled {
- self.setupStatusText = self.appModel.gatewayStatusText
+ .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)
}
- }
- .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
- }
- .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.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)
+ }
+ .onChange(of: self.defaultShareInstruction) { _, newValue in
+ ShareToAgentSettings.saveDefaultInstruction(newValue)
+ }
+ .onChange(of: self.manualGatewayPort) { _, _ in
+ self.syncManualPortText()
+ }
+ .onChange(of: self.appModel.gatewayServerName) { _, newValue in
+ if newValue != nil {
+ self.setupCode = ""
+ self.setupStatusText = nil
return
}
- await MainActor.run {
- self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
+ if self.manualGatewayEnabled {
+ self.setupStatusText = self.appModel.gatewayStatusText
+ }
+ }
+ .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
+ }
+ .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
+ }
+ return
+ }
+ await MainActor.run {
+ self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
+ }
}
}
- }
}
.gatewayTrustPromptAlert()
}
@@ -1138,4 +1132,21 @@ struct SettingsTab: View {
}
}
+private struct SettingsCloseToolbar: ViewModifier {
+ @Environment(\.dismiss) private var dismiss
+
+ func body(content: Content) -> some View {
+ content.toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ self.dismiss()
+ } label: {
+ Image(systemName: "xmark")
+ }
+ .accessibilityLabel("Close")
+ }
+ }
+ }
+}
+
// swiftlint:enable type_body_length
diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist
index 99eda1c355e..9cfb07dc6d5 100644
--- a/apps/ios/SwiftSources.input.xcfilelist
+++ b/apps/ios/SwiftSources.input.xcfilelist
@@ -40,6 +40,7 @@ Sources/Onboarding/OnboardingStateStore.swift
Sources/Onboarding/OnboardingWizardView.swift
Sources/Onboarding/QRScannerView.swift
Sources/OpenClawApp.swift
+Sources/Permissions/PermissionRequestBridge.swift
Sources/Push/ExecApprovalNotificationBridge.swift
Sources/Push/BackgroundAliveBeacon.swift
Sources/Push/PushBuildConfig.swift
@@ -60,6 +61,7 @@ Sources/Services/WatchConnectivityTransport.swift
Sources/Services/WatchMessagingPayloadCodec.swift
Sources/Services/WatchMessagingService.swift
Sources/SessionKey.swift
+Sources/Settings/PrivacyAccessSectionView.swift
Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/SettingsTab.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift
diff --git a/apps/ios/Tests/PermissionRequestBridgeTests.swift b/apps/ios/Tests/PermissionRequestBridgeTests.swift
new file mode 100644
index 00000000000..8447d01068d
--- /dev/null
+++ b/apps/ios/Tests/PermissionRequestBridgeTests.swift
@@ -0,0 +1,26 @@
+import Testing
+@testable import OpenClaw
+
+@Suite(.serialized) struct PermissionRequestBridgeTests {
+ @Test func `box resumes immediately when cancelled before install`() async {
+ let box = PermissionRequestBridge.Box()
+ box.resume(false)
+ let granted: Bool = await withCheckedContinuation { continuation in
+ _ = box.install(continuation)
+ }
+ #expect(granted == false)
+ #expect(box.canStartRequest() == false)
+ }
+
+ @Test func `box resumes installed continuation once`() async {
+ let box = PermissionRequestBridge.Box()
+
+ let granted: Bool = await withCheckedContinuation { continuation in
+ _ = box.install(continuation)
+ box.resume(true)
+ box.resume(false)
+ }
+
+ #expect(granted == true)
+ }
+}
diff --git a/apps/ios/project.yml b/apps/ios/project.yml
index cd219783101..3fbdac496b6 100644
--- a/apps/ios/project.yml
+++ b/apps/ios/project.yml
@@ -136,11 +136,16 @@ targets:
NSBonjourServices:
- _openclaw-gw._tcp
NSCameraUsageDescription: OpenClaw can capture photos or short video clips when requested via the gateway.
+ NSCalendarsUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.
+ NSCalendarsFullAccessUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.
+ NSCalendarsWriteOnlyAccessUsageDescription: OpenClaw uses your calendars to add events when you enable calendar access.
+ NSContactsUsageDescription: OpenClaw uses your contacts so you can search and reference people while using the assistant.
NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing.
NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always.
NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake.
NSMotionUsageDescription: OpenClaw may use motion data to support device-aware interactions and automations.
NSPhotoLibraryUsageDescription: OpenClaw needs photo library access when you choose existing photos to share with your assistant.
+ NSRemindersFullAccessUsageDescription: OpenClaw uses your reminders to list, add, and complete tasks when you enable reminders access.
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
NSSupportsLiveActivities: true
ITSAppUsesNonExemptEncryption: false
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
index 5bf818220f7..8e2dbddd5da 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
@@ -57,7 +57,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
{
return link
}
- return fromGatewayURLString(
+ return self.fromGatewayURLString(
trimmed,
bootstrapToken: nil,
token: nil,
@@ -89,7 +89,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
{
return link
}
- for candidate in setupCodeCandidates(in: trimmed) where candidate != trimmed {
+ for candidate in self.setupCodeCandidates(in: trimmed) where candidate != trimmed {
if let data = decodeBase64Url(candidate),
let link = decodeSetupPayload(from: data)
{
@@ -104,7 +104,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
if let urlString = payload.url?.trimmingCharacters(in: .whitespacesAndNewlines),
!urlString.isEmpty
{
- return fromGatewayURLString(
+ return self.fromGatewayURLString(
urlString,
bootstrapToken: payload.bootstrapToken,
token: payload.token,